forked from boos/BoosAPI
refactor: share schema metadata normalization rules
This commit is contained in:
@@ -3,14 +3,11 @@ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
export type LogicalColumnType =
|
normalizeLogicalColumnType,
|
||||||
| 'integer'
|
normalizeSchemaMetadataDefaultValue,
|
||||||
| 'real'
|
type LogicalColumnType,
|
||||||
| 'text'
|
} from './schemaMetadata.js';
|
||||||
| 'boolean'
|
|
||||||
| 'datetime'
|
|
||||||
| 'json';
|
|
||||||
|
|
||||||
export interface SchemaContractColumn {
|
export interface SchemaContractColumn {
|
||||||
logicalType: LogicalColumnType;
|
logicalType: LogicalColumnType;
|
||||||
@@ -118,66 +115,16 @@ function applySqliteMigrations(sqlite: Database.Database, migrationsFolder: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDefaultValue(defaultValue: string | null): string | null {
|
function normalizeDefaultValue(defaultValue: string | null): string | null {
|
||||||
if (defaultValue == null) return null;
|
return normalizeSchemaMetadataDefaultValue(defaultValue);
|
||||||
return String(defaultValue).trim() || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBooleanLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
|
||||||
const normalizedColumn = columnName.toLowerCase();
|
|
||||||
const normalizedDefault = (defaultValue || '').trim().toLowerCase();
|
|
||||||
if (normalizedDefault === 'true' || normalizedDefault === 'false') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return normalizedColumn.startsWith('is_')
|
|
||||||
|| normalizedColumn.startsWith('use_')
|
|
||||||
|| normalizedColumn.startsWith('has_')
|
|
||||||
|| normalizedColumn.endsWith('_enabled')
|
|
||||||
|| normalizedColumn.endsWith('_available')
|
|
||||||
|| normalizedColumn === 'read'
|
|
||||||
|| normalizedColumn === 'enabled'
|
|
||||||
|| normalizedColumn === 'available'
|
|
||||||
|| normalizedColumn === 'manual_override';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDateTimeLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
|
||||||
const normalizedColumn = columnName.toLowerCase();
|
|
||||||
const normalizedDefault = (defaultValue || '').toLowerCase();
|
|
||||||
return normalizedColumn.endsWith('_at')
|
|
||||||
|| normalizedColumn.endsWith('_until')
|
|
||||||
|| normalizedColumn.endsWith('_refresh')
|
|
||||||
|| normalizedDefault.includes('datetime(')
|
|
||||||
|| normalizedDefault.includes('current_timestamp');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isJsonLikeColumn(columnName: string): boolean {
|
|
||||||
const normalizedColumn = columnName.toLowerCase();
|
|
||||||
return normalizedColumn.endsWith('_json')
|
|
||||||
|| normalizedColumn.includes('snapshot')
|
|
||||||
|| normalizedColumn.includes('mapping')
|
|
||||||
|| normalizedColumn.includes('headers')
|
|
||||||
|| normalizedColumn.includes('config')
|
|
||||||
|| normalizedColumn.includes('details')
|
|
||||||
|| normalizedColumn.includes('meta')
|
|
||||||
|| normalizedColumn.includes('models')
|
|
||||||
|| normalizedColumn.includes('route_ids')
|
|
||||||
|| normalizedColumn.includes('multipliers');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLogicalType(columnName: string, declaredType: string, defaultValue: string | null): LogicalColumnType {
|
function normalizeLogicalType(columnName: string, declaredType: string, defaultValue: string | null): LogicalColumnType {
|
||||||
const normalizedType = declaredType.trim().toLowerCase();
|
return normalizeLogicalColumnType({
|
||||||
if (normalizedType.includes('int')) {
|
columnName,
|
||||||
return isBooleanLikeColumn(columnName, defaultValue) ? 'boolean' : 'integer';
|
declaredType,
|
||||||
}
|
defaultValue,
|
||||||
if (normalizedType.includes('real') || normalizedType.includes('double') || normalizedType.includes('float')) {
|
dialect: 'sqlite',
|
||||||
return 'real';
|
});
|
||||||
}
|
|
||||||
if (normalizedType.includes('text') || normalizedType.includes('char') || normalizedType.includes('clob')) {
|
|
||||||
if (isDateTimeLikeColumn(columnName, defaultValue)) return 'datetime';
|
|
||||||
if (isJsonLikeColumn(columnName)) return 'json';
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
if (isDateTimeLikeColumn(columnName, defaultValue)) return 'datetime';
|
|
||||||
return 'text';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readTables(sqlite: Database.Database): Record<string, SchemaContractTable> {
|
function readTables(sqlite: Database.Database): Record<string, SchemaContractTable> {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
resolveGeneratedArtifactPath,
|
resolveGeneratedArtifactPath,
|
||||||
} from './schemaArtifactGenerator.js';
|
} from './schemaArtifactGenerator.js';
|
||||||
import type {
|
import type {
|
||||||
LogicalColumnType,
|
|
||||||
SchemaContract,
|
SchemaContract,
|
||||||
SchemaContractColumn,
|
SchemaContractColumn,
|
||||||
SchemaContractForeignKey,
|
SchemaContractForeignKey,
|
||||||
@@ -20,6 +19,10 @@ import type {
|
|||||||
SchemaContractUnique,
|
SchemaContractUnique,
|
||||||
} from './schemaContract.js';
|
} from './schemaContract.js';
|
||||||
import { resolveMigrationsFolder } from './schemaContract.js';
|
import { resolveMigrationsFolder } from './schemaContract.js';
|
||||||
|
import {
|
||||||
|
normalizeLogicalColumnType,
|
||||||
|
type LogicalColumnType,
|
||||||
|
} from './schemaMetadata.js';
|
||||||
|
|
||||||
export type SchemaIntrospectionDialect = 'sqlite' | 'mysql' | 'postgres';
|
export type SchemaIntrospectionDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||||
|
|
||||||
@@ -182,122 +185,18 @@ function normalizeDeleteRule(value: string | null | undefined): string | null {
|
|||||||
return value.trim().replace(/\s+/g, ' ').toUpperCase();
|
return value.trim().replace(/\s+/g, ' ').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBooleanLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
|
||||||
const normalizedColumn = columnName.toLowerCase();
|
|
||||||
const normalizedDefault = (defaultValue || '').trim().toLowerCase();
|
|
||||||
if (normalizedDefault === 'true' || normalizedDefault === 'false') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return normalizedColumn.startsWith('is_')
|
|
||||||
|| normalizedColumn.startsWith('use_')
|
|
||||||
|| normalizedColumn.startsWith('has_')
|
|
||||||
|| normalizedColumn.endsWith('_enabled')
|
|
||||||
|| normalizedColumn.endsWith('_available')
|
|
||||||
|| normalizedColumn === 'read'
|
|
||||||
|| normalizedColumn === 'enabled'
|
|
||||||
|| normalizedColumn === 'available'
|
|
||||||
|| normalizedColumn === 'manual_override';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDateTimeLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
|
||||||
const normalizedColumn = columnName.toLowerCase();
|
|
||||||
const normalizedDefault = (defaultValue || '').toLowerCase();
|
|
||||||
return normalizedColumn.endsWith('_at')
|
|
||||||
|| normalizedColumn.endsWith('_until')
|
|
||||||
|| normalizedColumn.endsWith('_refresh')
|
|
||||||
|| normalizedDefault.includes("datetime('now')")
|
|
||||||
|| normalizedDefault.includes('current_timestamp')
|
|
||||||
|| normalizedDefault.includes('now()');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isJsonLikeColumn(columnName: string): boolean {
|
|
||||||
const normalizedColumn = columnName.toLowerCase();
|
|
||||||
return normalizedColumn.endsWith('_json')
|
|
||||||
|| normalizedColumn.includes('snapshot')
|
|
||||||
|| normalizedColumn.includes('mapping')
|
|
||||||
|| normalizedColumn.includes('headers')
|
|
||||||
|| normalizedColumn.includes('config')
|
|
||||||
|| normalizedColumn.includes('details')
|
|
||||||
|| normalizedColumn.includes('meta')
|
|
||||||
|| normalizedColumn.includes('models')
|
|
||||||
|| normalizedColumn.includes('route_ids')
|
|
||||||
|| normalizedColumn.includes('multipliers');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeSqlType(
|
export function normalizeSqlType(
|
||||||
dialect: SchemaIntrospectionDialect,
|
dialect: SchemaIntrospectionDialect,
|
||||||
declaredType: string,
|
declaredType: string,
|
||||||
columnName: string,
|
columnName: string,
|
||||||
rawDefaultValue: string | null = null,
|
rawDefaultValue: string | null = null,
|
||||||
): LogicalColumnType {
|
): LogicalColumnType {
|
||||||
const normalizedType = declaredType.trim().toLowerCase();
|
return normalizeLogicalColumnType({
|
||||||
const normalizedDefault = normalizeDefaultValue(rawDefaultValue);
|
dialect,
|
||||||
|
declaredType,
|
||||||
if (dialect === 'mysql') {
|
columnName,
|
||||||
if (normalizedType.includes('tinyint(1)') || normalizedType.includes('boolean') || normalizedType.includes('bool')) {
|
defaultValue: normalizeDefaultValue(rawDefaultValue),
|
||||||
return 'boolean';
|
});
|
||||||
}
|
|
||||||
if (normalizedType.includes('json')) {
|
|
||||||
return 'json';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') {
|
|
||||||
return 'datetime';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('int')) {
|
|
||||||
return isBooleanLikeColumn(columnName, normalizedDefault) ? 'boolean' : 'integer';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('real') || normalizedType.includes('decimal')) {
|
|
||||||
return 'real';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('char') || normalizedType.includes('text')) {
|
|
||||||
if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime';
|
|
||||||
if (isJsonLikeColumn(columnName)) return 'json';
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dialect === 'postgres') {
|
|
||||||
if (normalizedType.includes('bool')) {
|
|
||||||
return 'boolean';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('json')) {
|
|
||||||
return 'json';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('timestamp') || normalizedType === 'date') {
|
|
||||||
return 'datetime';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('int')) {
|
|
||||||
return isBooleanLikeColumn(columnName, normalizedDefault) ? 'boolean' : 'integer';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('double') || normalizedType.includes('real') || normalizedType.includes('numeric') || normalizedType.includes('decimal')) {
|
|
||||||
return 'real';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('char') || normalizedType.includes('text')) {
|
|
||||||
if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime';
|
|
||||||
if (isJsonLikeColumn(columnName)) return 'json';
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedType.includes('int')) {
|
|
||||||
return isBooleanLikeColumn(columnName, normalizedDefault) ? 'boolean' : 'integer';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('real') || normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('decimal')) {
|
|
||||||
return 'real';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('json')) {
|
|
||||||
return 'json';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') {
|
|
||||||
return 'datetime';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('text') || normalizedType.includes('char') || normalizedType.includes('clob')) {
|
|
||||||
if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime';
|
|
||||||
if (isJsonLikeColumn(columnName)) return 'json';
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime';
|
|
||||||
return 'text';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDefaultValueForColumn(
|
function normalizeDefaultValueForColumn(
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function readSource(relativePath: string): string {
|
||||||
|
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('schema metadata architecture boundaries', () => {
|
||||||
|
it('keeps logical column type heuristics in a shared schemaMetadata helper', () => {
|
||||||
|
const contractSource = readSource('./schemaContract.ts');
|
||||||
|
const introspectionSource = readSource('./schemaIntrospection.ts');
|
||||||
|
|
||||||
|
expect(contractSource).toContain("from './schemaMetadata.js'");
|
||||||
|
expect(introspectionSource).toContain("from './schemaMetadata.js'");
|
||||||
|
|
||||||
|
for (const source of [contractSource, introspectionSource]) {
|
||||||
|
expect(source).not.toContain('function isBooleanLikeColumn');
|
||||||
|
expect(source).not.toContain('function isDateTimeLikeColumn');
|
||||||
|
expect(source).not.toContain('function isJsonLikeColumn');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
export type LogicalColumnType =
|
||||||
|
| 'integer'
|
||||||
|
| 'real'
|
||||||
|
| 'text'
|
||||||
|
| 'boolean'
|
||||||
|
| 'datetime'
|
||||||
|
| 'json';
|
||||||
|
|
||||||
|
export type SchemaMetadataDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||||
|
|
||||||
|
function isBooleanLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
||||||
|
const normalizedColumn = columnName.toLowerCase();
|
||||||
|
const normalizedDefault = (defaultValue || '').trim().toLowerCase();
|
||||||
|
if (normalizedDefault === 'true' || normalizedDefault === 'false') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return normalizedColumn.startsWith('is_')
|
||||||
|
|| normalizedColumn.startsWith('use_')
|
||||||
|
|| normalizedColumn.startsWith('has_')
|
||||||
|
|| normalizedColumn.endsWith('_enabled')
|
||||||
|
|| normalizedColumn.endsWith('_available')
|
||||||
|
|| normalizedColumn === 'read'
|
||||||
|
|| normalizedColumn === 'enabled'
|
||||||
|
|| normalizedColumn === 'available'
|
||||||
|
|| normalizedColumn === 'manual_override';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateTimeLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
||||||
|
const normalizedColumn = columnName.toLowerCase();
|
||||||
|
const normalizedDefault = (defaultValue || '').toLowerCase();
|
||||||
|
return normalizedColumn.endsWith('_at')
|
||||||
|
|| normalizedColumn.endsWith('_until')
|
||||||
|
|| normalizedColumn.endsWith('_refresh')
|
||||||
|
|| normalizedDefault.includes('datetime(')
|
||||||
|
|| normalizedDefault.includes('current_timestamp')
|
||||||
|
|| normalizedDefault.includes('now()');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonLikeColumn(columnName: string): boolean {
|
||||||
|
const normalizedColumn = columnName.toLowerCase();
|
||||||
|
return normalizedColumn.endsWith('_json')
|
||||||
|
|| normalizedColumn.includes('snapshot')
|
||||||
|
|| normalizedColumn.includes('mapping')
|
||||||
|
|| normalizedColumn.includes('headers')
|
||||||
|
|| normalizedColumn.includes('config')
|
||||||
|
|| normalizedColumn.includes('details')
|
||||||
|
|| normalizedColumn.includes('meta')
|
||||||
|
|| normalizedColumn.includes('models')
|
||||||
|
|| normalizedColumn.includes('route_ids')
|
||||||
|
|| normalizedColumn.includes('multipliers');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSchemaMetadataDefaultValue(defaultValue: string | null | undefined): string | null {
|
||||||
|
if (defaultValue == null) return null;
|
||||||
|
return String(defaultValue).trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLogicalColumnType(input: {
|
||||||
|
declaredType: string;
|
||||||
|
columnName: string;
|
||||||
|
defaultValue?: string | null;
|
||||||
|
dialect?: SchemaMetadataDialect;
|
||||||
|
}): LogicalColumnType {
|
||||||
|
const normalizedType = input.declaredType.trim().toLowerCase();
|
||||||
|
const normalizedDefault = normalizeSchemaMetadataDefaultValue(input.defaultValue);
|
||||||
|
const dialect = input.dialect;
|
||||||
|
|
||||||
|
if (dialect === 'mysql') {
|
||||||
|
if (normalizedType.includes('tinyint(1)') || normalizedType.includes('boolean') || normalizedType.includes('bool')) {
|
||||||
|
return 'boolean';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('json')) {
|
||||||
|
return 'json';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') {
|
||||||
|
return 'datetime';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('int')) {
|
||||||
|
return isBooleanLikeColumn(input.columnName, normalizedDefault) ? 'boolean' : 'integer';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('real') || normalizedType.includes('decimal')) {
|
||||||
|
return 'real';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('char') || normalizedType.includes('text')) {
|
||||||
|
if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime';
|
||||||
|
if (isJsonLikeColumn(input.columnName)) return 'json';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialect === 'postgres') {
|
||||||
|
if (normalizedType.includes('bool')) {
|
||||||
|
return 'boolean';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('json')) {
|
||||||
|
return 'json';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('timestamp') || normalizedType === 'date') {
|
||||||
|
return 'datetime';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('int')) {
|
||||||
|
return isBooleanLikeColumn(input.columnName, normalizedDefault) ? 'boolean' : 'integer';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('double') || normalizedType.includes('real') || normalizedType.includes('numeric') || normalizedType.includes('decimal')) {
|
||||||
|
return 'real';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('char') || normalizedType.includes('text')) {
|
||||||
|
if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime';
|
||||||
|
if (isJsonLikeColumn(input.columnName)) return 'json';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedType.includes('int')) {
|
||||||
|
return isBooleanLikeColumn(input.columnName, normalizedDefault) ? 'boolean' : 'integer';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('real') || normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('decimal')) {
|
||||||
|
return 'real';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('json')) {
|
||||||
|
return 'json';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') {
|
||||||
|
return 'datetime';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('text') || normalizedType.includes('char') || normalizedType.includes('clob')) {
|
||||||
|
if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime';
|
||||||
|
if (isJsonLikeColumn(input.columnName)) return 'json';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __schemaMetadataTestUtils = {
|
||||||
|
isBooleanLikeColumn,
|
||||||
|
isDateTimeLikeColumn,
|
||||||
|
isJsonLikeColumn,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user