1
0
forked from boos/BoosAPI

refactor: share schema metadata normalization rules

This commit is contained in:
Cita
2026-03-23 12:23:04 +08:00
parent 86518202fa
commit 8b3653c7c9
4 changed files with 183 additions and 176 deletions
+12 -65
View File
@@ -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> {
+10 -111
View File
@@ -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');
}
});
});
+139
View File
@@ -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,
};