Skip to content

Commit 9e48ae3

Browse files
authored
feat: naming strategy (#19848)
* feat: naming strategy * feat: detect renames
1 parent 1d19d30 commit 9e48ae3

35 files changed

+517
-127
lines changed

server/src/bin/migrations.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ import { ConfigRepository } from 'src/repositories/config.repository';
99
import { DatabaseRepository } from 'src/repositories/database.repository';
1010
import { LoggingRepository } from 'src/repositories/logging.repository';
1111
import 'src/schema';
12-
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
12+
import {
13+
DefaultNamingStrategy,
14+
HashNamingStrategy,
15+
schemaDiff,
16+
schemaFromCode,
17+
schemaFromDatabase,
18+
} from 'src/sql-tools';
1319
import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
1420

1521
const main = async () => {
@@ -107,7 +113,22 @@ const compare = async () => {
107113
const { database } = configRepository.getEnv();
108114
const db = postgres(asPostgresConnectionConfig(database.config));
109115

110-
const source = schemaFromCode({ overrides: true });
116+
const tables = new Set<string>();
117+
const preferred = new DefaultNamingStrategy();
118+
const fallback = new HashNamingStrategy();
119+
120+
const source = schemaFromCode({
121+
overrides: true,
122+
namingStrategy: {
123+
getName(item) {
124+
if ('tableName' in item && tables.has(item.tableName)) {
125+
return preferred.getName(item);
126+
}
127+
128+
return fallback.getName(item);
129+
},
130+
},
131+
});
111132
const target = await schemaFromDatabase(db, {});
112133

113134
console.log(source.warnings.join('\n'));

server/src/schema/tables/stack.table.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ export class StackTable {
3535
updateId!: Generated<string>;
3636

3737
//TODO: Add constraint to ensure primary asset exists in the assets array
38-
@ForeignKeyColumn(() => AssetTable, { nullable: false, unique: true })
38+
@ForeignKeyColumn(() => AssetTable, {
39+
nullable: false,
40+
unique: true,
41+
uniqueConstraintName: 'REL_91704e101438fd0653f582426d',
42+
})
3943
primaryAssetId!: string;
4044

4145
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })

server/src/sql-tools/comparers/column.comparer.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
55
const testColumn: DatabaseColumn = {
66
name: 'test',
77
tableName: 'table1',
8+
primary: false,
89
nullable: false,
910
isArray: false,
1011
type: 'character varying',

server/src/sql-tools/comparers/column.comparer.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,32 @@
1-
import { getColumnType, isDefaultEqual } from 'src/sql-tools/helpers';
1+
import { asRenameKey, getColumnType, isDefaultEqual } from 'src/sql-tools/helpers';
22
import { Comparer, DatabaseColumn, Reason, SchemaDiff } from 'src/sql-tools/types';
33

4-
export const compareColumns: Comparer<DatabaseColumn> = {
4+
export const compareColumns = {
5+
getRenameKey: (column) => {
6+
return asRenameKey([
7+
column.tableName,
8+
column.type,
9+
column.nullable,
10+
column.default,
11+
column.storage,
12+
column.primary,
13+
column.isArray,
14+
column.length,
15+
column.identity,
16+
column.enumName,
17+
column.numericPrecision,
18+
column.numericScale,
19+
]);
20+
},
21+
onRename: (source, target) => [
22+
{
23+
type: 'ColumnRename',
24+
tableName: source.tableName,
25+
oldName: target.name,
26+
newName: source.name,
27+
reason: Reason.Rename,
28+
},
29+
],
530
onMissing: (source) => [
631
{
732
type: 'ColumnAdd',
@@ -67,7 +92,7 @@ export const compareColumns: Comparer<DatabaseColumn> = {
6792

6893
return items;
6994
},
70-
};
95+
} satisfies Comparer<DatabaseColumn>;
7196

7297
const dropAndRecreateColumn = (source: DatabaseColumn, target: DatabaseColumn, reason: string): SchemaDiff[] => {
7398
return [

server/src/sql-tools/comparers/constraint.comparer.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { haveEqualColumns } from 'src/sql-tools/helpers';
1+
import { asRenameKey, haveEqualColumns } from 'src/sql-tools/helpers';
22
import {
33
CompareFunction,
44
Comparer,
@@ -13,6 +13,37 @@ import {
1313
} from 'src/sql-tools/types';
1414

1515
export const compareConstraints: Comparer<DatabaseConstraint> = {
16+
getRenameKey: (constraint) => {
17+
switch (constraint.type) {
18+
case ConstraintType.PRIMARY_KEY:
19+
case ConstraintType.UNIQUE: {
20+
return asRenameKey([constraint.type, constraint.tableName, ...constraint.columnNames.toSorted()]);
21+
}
22+
23+
case ConstraintType.FOREIGN_KEY: {
24+
return asRenameKey([
25+
constraint.type,
26+
constraint.tableName,
27+
...constraint.columnNames.toSorted(),
28+
constraint.referenceTableName,
29+
...constraint.referenceColumnNames.toSorted(),
30+
]);
31+
}
32+
33+
case ConstraintType.CHECK: {
34+
return asRenameKey([constraint.type, constraint.tableName, constraint.expression]);
35+
}
36+
}
37+
},
38+
onRename: (source, target) => [
39+
{
40+
type: 'ConstraintRename',
41+
tableName: target.tableName,
42+
oldName: target.name,
43+
newName: source.name,
44+
reason: Reason.Rename,
45+
},
46+
],
1647
onMissing: (source) => [
1748
{
1849
type: 'ConstraintAdd',

server/src/sql-tools/comparers/index.comparer.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
1-
import { haveEqualColumns } from 'src/sql-tools/helpers';
1+
import { asRenameKey, haveEqualColumns } from 'src/sql-tools/helpers';
22
import { Comparer, DatabaseIndex, Reason } from 'src/sql-tools/types';
33

44
export const compareIndexes: Comparer<DatabaseIndex> = {
5+
getRenameKey: (index) => {
6+
if (index.override) {
7+
return index.override.value.sql.replace(index.name, 'INDEX_NAME');
8+
}
9+
10+
return asRenameKey([index.tableName, ...(index.columnNames || []).toSorted(), index.unique]);
11+
},
12+
onRename: (source, target) => [
13+
{
14+
type: 'IndexRename',
15+
tableName: source.tableName,
16+
oldName: target.name,
17+
newName: source.name,
18+
reason: Reason.Rename,
19+
},
20+
],
521
onMissing: (source) => [
622
{
723
type: 'IndexCreate',

server/src/sql-tools/contexts/base-context.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { DefaultNamingStrategy } from 'src/sql-tools/naming/default.naming';
2+
import { HashNamingStrategy } from 'src/sql-tools/naming/hash.naming';
3+
import { NamingInterface, NamingItem } from 'src/sql-tools/naming/naming.interface';
14
import {
25
BaseContextOptions,
36
DatabaseEnum,
@@ -11,6 +14,26 @@ import {
1114

1215
const asOverrideKey = (type: string, name: string) => `${type}:${name}`;
1316

17+
const isNamingInterface = (strategy: any): strategy is NamingInterface => {
18+
return typeof strategy === 'object' && typeof strategy.getName === 'function';
19+
};
20+
21+
const asNamingStrategy = (strategy: 'hash' | 'default' | NamingInterface): NamingInterface => {
22+
if (isNamingInterface(strategy)) {
23+
return strategy;
24+
}
25+
26+
switch (strategy) {
27+
case 'hash': {
28+
return new HashNamingStrategy();
29+
}
30+
31+
default: {
32+
return new DefaultNamingStrategy();
33+
}
34+
}
35+
};
36+
1437
export class BaseContext {
1538
databaseName: string;
1639
schemaName: string;
@@ -24,10 +47,17 @@ export class BaseContext {
2447
overrides: DatabaseOverride[] = [];
2548
warnings: string[] = [];
2649

50+
private namingStrategy: NamingInterface;
51+
2752
constructor(options: BaseContextOptions) {
2853
this.databaseName = options.databaseName ?? 'postgres';
2954
this.schemaName = options.schemaName ?? 'public';
3055
this.overrideTableName = options.overrideTableName ?? 'migration_overrides';
56+
this.namingStrategy = asNamingStrategy(options.namingStrategy ?? 'hash');
57+
}
58+
59+
getNameFor(item: NamingItem) {
60+
return this.namingStrategy.getName(item);
3161
}
3262

3363
getTableByName(name: string) {

server/src/sql-tools/contexts/processor-context.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
22
import { BaseContext } from 'src/sql-tools/contexts/base-context';
3-
import { ColumnOptions, TableOptions } from 'src/sql-tools/decorators';
4-
import { asKey } from 'src/sql-tools/helpers';
3+
import { ColumnOptions } from 'src/sql-tools/decorators/column.decorator';
4+
import { TableOptions } from 'src/sql-tools/decorators/table.decorator';
55
import { DatabaseColumn, DatabaseTable, SchemaFromCodeOptions } from 'src/sql-tools/types';
66

77
type TableMetadata = { options: TableOptions; object: Function; methodToColumn: Map<string | symbol, DatabaseColumn> };
@@ -59,19 +59,6 @@ export class ProcessorContext extends BaseContext {
5959
tableMetadata.methodToColumn.set(propertyName, column);
6060
}
6161

62-
asIndexName(table: string, columns?: string[], where?: string) {
63-
const items: string[] = [];
64-
for (const columnName of columns ?? []) {
65-
items.push(columnName);
66-
}
67-
68-
if (where) {
69-
items.push(where);
70-
}
71-
72-
return asKey('IDX_', table, items);
73-
}
74-
7562
warnMissingTable(context: string, object: object, propertyName?: symbol | string) {
7663
const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : '');
7764
this.warn(context, `Unable to find table (${label})`);

server/src/sql-tools/decorators/index.ts

Lines changed: 0 additions & 22 deletions
This file was deleted.

server/src/sql-tools/helpers.ts

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,6 @@ import { createHash } from 'node:crypto';
22
import { ColumnValue } from 'src/sql-tools/decorators/column.decorator';
33
import { Comparer, DatabaseColumn, DatabaseOverride, IgnoreOptions, SchemaDiff } from 'src/sql-tools/types';
44

5-
export const asMetadataKey = (name: string) => `sql-tools:${name}`;
6-
7-
export const asSnakeCase = (name: string): string => name.replaceAll(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
8-
// match TypeORM
9-
export const asKey = (prefix: string, tableName: string, values: string[]) =>
10-
(prefix + sha1(`${tableName}_${values.toSorted().join('_')}`)).slice(0, 30);
11-
125
export const asOptions = <T extends { name?: string }>(options: string | T): T => {
136
if (typeof options === 'string') {
147
return { name: options } as T;
@@ -79,6 +72,10 @@ export const compare = <T extends { name: string; synchronize: boolean }>(
7972
const items: SchemaDiff[] = [];
8073

8174
const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]);
75+
const missingKeys = new Set<string>();
76+
const extraKeys = new Set<string>();
77+
78+
// common keys
8279
for (const key of keys) {
8380
const source = sourceMap[key];
8481
const target = targetMap[key];
@@ -92,22 +89,63 @@ export const compare = <T extends { name: string; synchronize: boolean }>(
9289
}
9390

9491
if (source && !target) {
95-
items.push(...comparer.onMissing(source));
96-
} else if (!source && target) {
97-
items.push(...comparer.onExtra(target));
98-
} else {
99-
if (
100-
haveEqualOverrides(
101-
source as unknown as { override?: DatabaseOverride },
102-
target as unknown as { override?: DatabaseOverride },
103-
)
104-
) {
92+
missingKeys.add(key);
93+
continue;
94+
}
95+
96+
if (!source && target) {
97+
extraKeys.add(key);
98+
continue;
99+
}
100+
101+
if (
102+
haveEqualOverrides(
103+
source as unknown as { override?: DatabaseOverride },
104+
target as unknown as { override?: DatabaseOverride },
105+
)
106+
) {
107+
continue;
108+
}
109+
110+
items.push(...comparer.onCompare(source, target));
111+
}
112+
113+
// renames
114+
if (comparer.getRenameKey && comparer.onRename) {
115+
const renameMap: Record<string, string> = {};
116+
for (const sourceKey of missingKeys) {
117+
const source = sourceMap[sourceKey];
118+
const renameKey = comparer.getRenameKey(source);
119+
renameMap[renameKey] = sourceKey;
120+
}
121+
122+
for (const targetKey of extraKeys) {
123+
const target = targetMap[targetKey];
124+
const renameKey = comparer.getRenameKey(target);
125+
const sourceKey = renameMap[renameKey];
126+
if (!sourceKey) {
105127
continue;
106128
}
107-
items.push(...comparer.onCompare(source, target));
129+
130+
const source = sourceMap[sourceKey];
131+
132+
items.push(...comparer.onRename(source, target));
133+
134+
missingKeys.delete(sourceKey);
135+
extraKeys.delete(targetKey);
108136
}
109137
}
110138

139+
// missing
140+
for (const key of missingKeys) {
141+
items.push(...comparer.onMissing(sourceMap[key]));
142+
}
143+
144+
// extra
145+
for (const key of extraKeys) {
146+
items.push(...comparer.onExtra(targetMap[key]));
147+
}
148+
111149
return items;
112150
};
113151

@@ -186,8 +224,6 @@ export const asColumnComment = (tableName: string, columnName: string, comment:
186224

187225
export const asColumnList = (columns: string[]) => columns.map((column) => `"${column}"`).join(', ');
188226

189-
export const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, [...columns]);
190-
191227
export const asJsonString = (value: unknown): string => {
192228
return `'${escape(JSON.stringify(value))}'::jsonb`;
193229
};
@@ -202,3 +238,6 @@ const escape = (value: string) => {
202238
.replaceAll(/[\r]/g, String.raw`\r`)
203239
.replaceAll(/[\t]/g, String.raw`\t`);
204240
};
241+
242+
export const asRenameKey = (values: Array<string | boolean | number | undefined>) =>
243+
values.map((value) => value ?? '').join('|');

0 commit comments

Comments
 (0)