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 { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export type LogicalColumnType =
|
||||
| 'integer'
|
||||
| 'real'
|
||||
| 'text'
|
||||
| 'boolean'
|
||||
| 'datetime'
|
||||
| 'json';
|
||||
import {
|
||||
normalizeLogicalColumnType,
|
||||
normalizeSchemaMetadataDefaultValue,
|
||||
type LogicalColumnType,
|
||||
} from './schemaMetadata.js';
|
||||
|
||||
export interface SchemaContractColumn {
|
||||
logicalType: LogicalColumnType;
|
||||
@@ -118,66 +115,16 @@ function applySqliteMigrations(sqlite: Database.Database, migrationsFolder: stri
|
||||
}
|
||||
|
||||
function normalizeDefaultValue(defaultValue: string | null): string | null {
|
||||
if (defaultValue == null) return null;
|
||||
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');
|
||||
return normalizeSchemaMetadataDefaultValue(defaultValue);
|
||||
}
|
||||
|
||||
function normalizeLogicalType(columnName: string, declaredType: string, defaultValue: string | null): LogicalColumnType {
|
||||
const normalizedType = declaredType.trim().toLowerCase();
|
||||
if (normalizedType.includes('int')) {
|
||||
return isBooleanLikeColumn(columnName, defaultValue) ? 'boolean' : 'integer';
|
||||
}
|
||||
if (normalizedType.includes('real') || normalizedType.includes('double') || normalizedType.includes('float')) {
|
||||
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';
|
||||
return normalizeLogicalColumnType({
|
||||
columnName,
|
||||
declaredType,
|
||||
defaultValue,
|
||||
dialect: 'sqlite',
|
||||
});
|
||||
}
|
||||
|
||||
function readTables(sqlite: Database.Database): Record<string, SchemaContractTable> {
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
resolveGeneratedArtifactPath,
|
||||
} from './schemaArtifactGenerator.js';
|
||||
import type {
|
||||
LogicalColumnType,
|
||||
SchemaContract,
|
||||
SchemaContractColumn,
|
||||
SchemaContractForeignKey,
|
||||
@@ -20,6 +19,10 @@ import type {
|
||||
SchemaContractUnique,
|
||||
} from './schemaContract.js';
|
||||
import { resolveMigrationsFolder } from './schemaContract.js';
|
||||
import {
|
||||
normalizeLogicalColumnType,
|
||||
type LogicalColumnType,
|
||||
} from './schemaMetadata.js';
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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(
|
||||
dialect: SchemaIntrospectionDialect,
|
||||
declaredType: string,
|
||||
columnName: string,
|
||||
rawDefaultValue: string | null = null,
|
||||
): LogicalColumnType {
|
||||
const normalizedType = declaredType.trim().toLowerCase();
|
||||
const normalizedDefault = normalizeDefaultValue(rawDefaultValue);
|
||||
|
||||
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(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';
|
||||
return normalizeLogicalColumnType({
|
||||
dialect,
|
||||
declaredType,
|
||||
columnName,
|
||||
defaultValue: normalizeDefaultValue(rawDefaultValue),
|
||||
});
|
||||
}
|
||||
|
||||
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