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 { 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> {
+10 -111
View File
@@ -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');
}
});
});
+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,
};