mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat: schema diff sql tools (#17116)
This commit is contained in:
parent
3fde5a8328
commit
4b4bcd23f4
132 changed files with 5837 additions and 1246 deletions
107
server/src/sql-tools/decorators.ts
Normal file
107
server/src/sql-tools/decorators.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
|
||||
import { register } from 'src/sql-tools/schema-from-decorators';
|
||||
import {
|
||||
CheckOptions,
|
||||
ColumnDefaultValue,
|
||||
ColumnIndexOptions,
|
||||
ColumnOptions,
|
||||
ForeignKeyColumnOptions,
|
||||
GenerateColumnOptions,
|
||||
IndexOptions,
|
||||
TableOptions,
|
||||
UniqueOptions,
|
||||
} from 'src/sql-tools/types';
|
||||
|
||||
export const Table = (options: string | TableOptions = {}): ClassDecorator => {
|
||||
return (object: Function) => void register({ type: 'table', item: { object, options: asOptions(options) } });
|
||||
};
|
||||
|
||||
export const Column = (options: string | ColumnOptions = {}): PropertyDecorator => {
|
||||
return (object: object, propertyName: string | symbol) =>
|
||||
void register({ type: 'column', item: { object, propertyName, options: asOptions(options) } });
|
||||
};
|
||||
|
||||
export const Index = (options: string | IndexOptions = {}): ClassDecorator => {
|
||||
return (object: Function) => void register({ type: 'index', item: { object, options: asOptions(options) } });
|
||||
};
|
||||
|
||||
export const Unique = (options: UniqueOptions): ClassDecorator => {
|
||||
return (object: Function) => void register({ type: 'uniqueConstraint', item: { object, options } });
|
||||
};
|
||||
|
||||
export const Check = (options: CheckOptions): ClassDecorator => {
|
||||
return (object: Function) => void register({ type: 'checkConstraint', item: { object, options } });
|
||||
};
|
||||
|
||||
export const ColumnIndex = (options: string | ColumnIndexOptions = {}): PropertyDecorator => {
|
||||
return (object: object, propertyName: string | symbol) =>
|
||||
void register({ type: 'columnIndex', item: { object, propertyName, options: asOptions(options) } });
|
||||
};
|
||||
|
||||
export const ForeignKeyColumn = (target: () => object, options: ForeignKeyColumnOptions): PropertyDecorator => {
|
||||
return (object: object, propertyName: string | symbol) => {
|
||||
register({ type: 'foreignKeyColumn', item: { object, propertyName, options, target } });
|
||||
};
|
||||
};
|
||||
|
||||
export const CreateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
|
||||
return Column({
|
||||
type: 'timestamp with time zone',
|
||||
default: () => 'now()',
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const UpdateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
|
||||
return Column({
|
||||
type: 'timestamp with time zone',
|
||||
default: () => 'now()',
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const DeleteDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
|
||||
return Column({
|
||||
type: 'timestamp with time zone',
|
||||
nullable: true,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const PrimaryGeneratedColumn = (options: Omit<GenerateColumnOptions, 'primary'> = {}) =>
|
||||
GeneratedColumn({ type: 'v4', ...options, primary: true });
|
||||
|
||||
export const PrimaryColumn = (options: Omit<ColumnOptions, 'primary'> = {}) => Column({ ...options, primary: true });
|
||||
|
||||
export const GeneratedColumn = ({ type = 'v4', ...options }: GenerateColumnOptions): PropertyDecorator => {
|
||||
const columnType = type === 'v4' || type === 'v7' ? 'uuid' : type;
|
||||
|
||||
let columnDefault: ColumnDefaultValue | undefined;
|
||||
switch (type) {
|
||||
case 'v4': {
|
||||
columnDefault = () => 'uuid_generate_v4()';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'v7': {
|
||||
columnDefault = () => 'immich_uuid_v7()';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Column({
|
||||
type: columnType,
|
||||
default: columnDefault,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const UpdateIdColumn = () => GeneratedColumn({ type: 'v7', nullable: false });
|
||||
|
||||
const asOptions = <T extends { name?: string }>(options: string | T): T => {
|
||||
if (typeof options === 'string') {
|
||||
return { name: options } as T;
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
1
server/src/sql-tools/index.ts
Normal file
1
server/src/sql-tools/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from 'src/sql-tools/public_api';
|
||||
6
server/src/sql-tools/public_api.ts
Normal file
6
server/src/sql-tools/public_api.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from 'src/sql-tools/decorators';
|
||||
export { schemaDiff } from 'src/sql-tools/schema-diff';
|
||||
export { schemaDiffToSql } from 'src/sql-tools/schema-diff-to-sql';
|
||||
export { schemaFromDatabase } from 'src/sql-tools/schema-from-database';
|
||||
export { schemaFromDecorators } from 'src/sql-tools/schema-from-decorators';
|
||||
export * from 'src/sql-tools/types';
|
||||
473
server/src/sql-tools/schema-diff-to-sql.spec.ts
Normal file
473
server/src/sql-tools/schema-diff-to-sql.spec.ts
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
import { DatabaseConstraintType, schemaDiffToSql } from 'src/sql-tools';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('diffToSql', () => {
|
||||
describe('table.drop', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'table.drop',
|
||||
tableName: 'table1',
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual([`DROP TABLE "table1";`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('table.create', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'table.create',
|
||||
tableName: 'table1',
|
||||
columns: [
|
||||
{
|
||||
tableName: 'table1',
|
||||
name: 'column1',
|
||||
type: 'character varying',
|
||||
nullable: true,
|
||||
isArray: false,
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual([`CREATE TABLE "table1" ("column1" character varying);`]);
|
||||
});
|
||||
|
||||
it('should handle a non-nullable column', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'table.create',
|
||||
tableName: 'table1',
|
||||
columns: [
|
||||
{
|
||||
tableName: 'table1',
|
||||
name: 'column1',
|
||||
type: 'character varying',
|
||||
isArray: false,
|
||||
nullable: false,
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual([`CREATE TABLE "table1" ("column1" character varying NOT NULL);`]);
|
||||
});
|
||||
|
||||
it('should handle a default value', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'table.create',
|
||||
tableName: 'table1',
|
||||
columns: [
|
||||
{
|
||||
tableName: 'table1',
|
||||
name: 'column1',
|
||||
type: 'character varying',
|
||||
isArray: false,
|
||||
nullable: true,
|
||||
default: 'uuid_generate_v4()',
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual([`CREATE TABLE "table1" ("column1" character varying DEFAULT uuid_generate_v4());`]);
|
||||
});
|
||||
|
||||
it('should handle an array type', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'table.create',
|
||||
tableName: 'table1',
|
||||
columns: [
|
||||
{
|
||||
tableName: 'table1',
|
||||
name: 'column1',
|
||||
type: 'character varying',
|
||||
isArray: true,
|
||||
nullable: true,
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual([`CREATE TABLE "table1" ("column1" character varying[]);`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('column.add', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'column.add',
|
||||
column: {
|
||||
name: 'column1',
|
||||
tableName: 'table1',
|
||||
type: 'character varying',
|
||||
nullable: false,
|
||||
isArray: false,
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual(['ALTER TABLE "table1" ADD "column1" character varying NOT NULL;']);
|
||||
});
|
||||
|
||||
it('should add a nullable column', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'column.add',
|
||||
column: {
|
||||
name: 'column1',
|
||||
tableName: 'table1',
|
||||
type: 'character varying',
|
||||
nullable: true,
|
||||
isArray: false,
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual(['ALTER TABLE "table1" ADD "column1" character varying;']);
|
||||
});
|
||||
|
||||
it('should add a column with an enum type', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'column.add',
|
||||
column: {
|
||||
name: 'column1',
|
||||
tableName: 'table1',
|
||||
type: 'character varying',
|
||||
enumName: 'table1_column1_enum',
|
||||
nullable: true,
|
||||
isArray: false,
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual(['ALTER TABLE "table1" ADD "column1" table1_column1_enum;']);
|
||||
});
|
||||
|
||||
it('should add a column that is an array type', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'column.add',
|
||||
column: {
|
||||
name: 'column1',
|
||||
tableName: 'table1',
|
||||
type: 'boolean',
|
||||
nullable: true,
|
||||
isArray: true,
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual(['ALTER TABLE "table1" ADD "column1" boolean[];']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('column.alter', () => {
|
||||
it('should make a column nullable', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'column.alter',
|
||||
tableName: 'table1',
|
||||
columnName: 'column1',
|
||||
changes: { nullable: true },
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" DROP NOT NULL;`]);
|
||||
});
|
||||
|
||||
it('should make a column non-nullable', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'column.alter',
|
||||
tableName: 'table1',
|
||||
columnName: 'column1',
|
||||
changes: { nullable: false },
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET NOT NULL;`]);
|
||||
});
|
||||
|
||||
it('should update the default value', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'column.alter',
|
||||
tableName: 'table1',
|
||||
columnName: 'column1',
|
||||
changes: { default: 'uuid_generate_v4()' },
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET DEFAULT uuid_generate_v4();`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('column.drop', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'column.drop',
|
||||
tableName: 'table1',
|
||||
columnName: 'column1',
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual([`ALTER TABLE "table1" DROP COLUMN "column1";`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constraint.add', () => {
|
||||
describe('primary keys', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'constraint.add',
|
||||
constraint: {
|
||||
type: DatabaseConstraintType.PRIMARY_KEY,
|
||||
name: 'PK_test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['id'],
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual(['ALTER TABLE "table1" ADD CONSTRAINT "PK_test" PRIMARY KEY ("id");']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('foreign keys', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'constraint.add',
|
||||
constraint: {
|
||||
type: DatabaseConstraintType.FOREIGN_KEY,
|
||||
name: 'FK_test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['parentId'],
|
||||
referenceColumnNames: ['id'],
|
||||
referenceTableName: 'table2',
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
'ALTER TABLE "table1" ADD CONSTRAINT "FK_test" FOREIGN KEY ("parentId") REFERENCES "table2" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION;',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unique', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'constraint.add',
|
||||
constraint: {
|
||||
type: DatabaseConstraintType.UNIQUE,
|
||||
name: 'UQ_test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['id'],
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual(['ALTER TABLE "table1" ADD CONSTRAINT "UQ_test" UNIQUE ("id");']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('check', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'constraint.add',
|
||||
constraint: {
|
||||
type: DatabaseConstraintType.CHECK,
|
||||
name: 'CHK_test',
|
||||
tableName: 'table1',
|
||||
expression: '"id" IS NOT NULL',
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual(['ALTER TABLE "table1" ADD CONSTRAINT "CHK_test" CHECK ("id" IS NOT NULL);']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('constraint.drop', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'constraint.drop',
|
||||
tableName: 'table1',
|
||||
constraintName: 'PK_test',
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual([`ALTER TABLE "table1" DROP CONSTRAINT "PK_test";`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('index.create', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'index.create',
|
||||
index: {
|
||||
name: 'IDX_test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['column1'],
|
||||
unique: false,
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual(['CREATE INDEX "IDX_test" ON "table1" ("column1")']);
|
||||
});
|
||||
|
||||
it('should create an unique index', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'index.create',
|
||||
index: {
|
||||
name: 'IDX_test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['column1'],
|
||||
unique: true,
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual(['CREATE UNIQUE INDEX "IDX_test" ON "table1" ("column1")']);
|
||||
});
|
||||
|
||||
it('should create an index with a custom expression', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'index.create',
|
||||
index: {
|
||||
name: 'IDX_test',
|
||||
tableName: 'table1',
|
||||
unique: false,
|
||||
expression: '"id" IS NOT NULL',
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual(['CREATE INDEX "IDX_test" ON "table1" ("id" IS NOT NULL)']);
|
||||
});
|
||||
|
||||
it('should create an index with a where clause', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'index.create',
|
||||
index: {
|
||||
name: 'IDX_test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['id'],
|
||||
unique: false,
|
||||
where: '("id" IS NOT NULL)',
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual(['CREATE INDEX "IDX_test" ON "table1" ("id") WHERE ("id" IS NOT NULL)']);
|
||||
});
|
||||
|
||||
it('should create an index with a custom expression', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'index.create',
|
||||
index: {
|
||||
name: 'IDX_test',
|
||||
tableName: 'table1',
|
||||
unique: false,
|
||||
using: 'gin',
|
||||
expression: '"id" IS NOT NULL',
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual(['CREATE INDEX "IDX_test" ON "table1" USING gin ("id" IS NOT NULL)']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('index.drop', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
schemaDiffToSql([
|
||||
{
|
||||
type: 'index.drop',
|
||||
indexName: 'IDX_test',
|
||||
reason: 'unknown',
|
||||
},
|
||||
]),
|
||||
).toEqual([`DROP INDEX "IDX_test";`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('comments', () => {
|
||||
it('should include the reason in a SQL comment', () => {
|
||||
expect(
|
||||
schemaDiffToSql(
|
||||
[
|
||||
{
|
||||
type: 'index.drop',
|
||||
indexName: 'IDX_test',
|
||||
reason: 'unknown',
|
||||
},
|
||||
],
|
||||
{ comments: true },
|
||||
),
|
||||
).toEqual([`DROP INDEX "IDX_test"; -- unknown`]);
|
||||
});
|
||||
});
|
||||
});
|
||||
204
server/src/sql-tools/schema-diff-to-sql.ts
Normal file
204
server/src/sql-tools/schema-diff-to-sql.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import {
|
||||
DatabaseActionType,
|
||||
DatabaseColumn,
|
||||
DatabaseColumnChanges,
|
||||
DatabaseConstraint,
|
||||
DatabaseConstraintType,
|
||||
DatabaseIndex,
|
||||
SchemaDiff,
|
||||
SchemaDiffToSqlOptions,
|
||||
} from 'src/sql-tools/types';
|
||||
|
||||
const asColumnList = (columns: string[]) =>
|
||||
columns
|
||||
.toSorted()
|
||||
.map((column) => `"${column}"`)
|
||||
.join(', ');
|
||||
const withNull = (column: DatabaseColumn) => (column.nullable ? '' : ' NOT NULL');
|
||||
const withDefault = (column: DatabaseColumn) => (column.default ? ` DEFAULT ${column.default}` : '');
|
||||
const withAction = (constraint: { onDelete?: DatabaseActionType; onUpdate?: DatabaseActionType }) =>
|
||||
` ON UPDATE ${constraint.onUpdate ?? DatabaseActionType.NO_ACTION} ON DELETE ${constraint.onDelete ?? DatabaseActionType.NO_ACTION}`;
|
||||
|
||||
const withComments = (comments: boolean | undefined, item: SchemaDiff): string => {
|
||||
if (!comments) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return ` -- ${item.reason}`;
|
||||
};
|
||||
|
||||
const asArray = <T>(items: T | T[]): T[] => {
|
||||
if (Array.isArray(items)) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return [items];
|
||||
};
|
||||
|
||||
export const getColumnType = (column: DatabaseColumn) => {
|
||||
let type = column.enumName || column.type;
|
||||
if (column.isArray) {
|
||||
type += '[]';
|
||||
}
|
||||
|
||||
return type;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert schema diffs into SQL statements
|
||||
*/
|
||||
export const schemaDiffToSql = (items: SchemaDiff[], options: SchemaDiffToSqlOptions = {}): string[] => {
|
||||
return items.flatMap((item) => asArray(asSql(item)).map((result) => result + withComments(options.comments, item)));
|
||||
};
|
||||
|
||||
const asSql = (item: SchemaDiff): string | string[] => {
|
||||
switch (item.type) {
|
||||
case 'table.create': {
|
||||
return asTableCreate(item.tableName, item.columns);
|
||||
}
|
||||
|
||||
case 'table.drop': {
|
||||
return asTableDrop(item.tableName);
|
||||
}
|
||||
|
||||
case 'column.add': {
|
||||
return asColumnAdd(item.column);
|
||||
}
|
||||
|
||||
case 'column.alter': {
|
||||
return asColumnAlter(item.tableName, item.columnName, item.changes);
|
||||
}
|
||||
|
||||
case 'column.drop': {
|
||||
return asColumnDrop(item.tableName, item.columnName);
|
||||
}
|
||||
|
||||
case 'constraint.add': {
|
||||
return asConstraintAdd(item.constraint);
|
||||
}
|
||||
|
||||
case 'constraint.drop': {
|
||||
return asConstraintDrop(item.tableName, item.constraintName);
|
||||
}
|
||||
|
||||
case 'index.create': {
|
||||
return asIndexCreate(item.index);
|
||||
}
|
||||
|
||||
case 'index.drop': {
|
||||
return asIndexDrop(item.indexName);
|
||||
}
|
||||
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const asTableCreate = (tableName: string, tableColumns: DatabaseColumn[]): string => {
|
||||
const columns = tableColumns
|
||||
.map((column) => `"${column.name}" ${getColumnType(column)}` + withNull(column) + withDefault(column))
|
||||
.join(', ');
|
||||
return `CREATE TABLE "${tableName}" (${columns});`;
|
||||
};
|
||||
|
||||
const asTableDrop = (tableName: string): string => {
|
||||
return `DROP TABLE "${tableName}";`;
|
||||
};
|
||||
|
||||
const asColumnAdd = (column: DatabaseColumn): string => {
|
||||
return (
|
||||
`ALTER TABLE "${column.tableName}" ADD "${column.name}" ${getColumnType(column)}` +
|
||||
withNull(column) +
|
||||
withDefault(column) +
|
||||
';'
|
||||
);
|
||||
};
|
||||
|
||||
const asColumnAlter = (tableName: string, columnName: string, changes: DatabaseColumnChanges): string[] => {
|
||||
const base = `ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}"`;
|
||||
const items: string[] = [];
|
||||
if (changes.nullable !== undefined) {
|
||||
items.push(changes.nullable ? `${base} DROP NOT NULL;` : `${base} SET NOT NULL;`);
|
||||
}
|
||||
|
||||
if (changes.default !== undefined) {
|
||||
items.push(`${base} SET DEFAULT ${changes.default};`);
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const asColumnDrop = (tableName: string, columnName: string): string => {
|
||||
return `ALTER TABLE "${tableName}" DROP COLUMN "${columnName}";`;
|
||||
};
|
||||
|
||||
const asConstraintAdd = (constraint: DatabaseConstraint): string | string[] => {
|
||||
const base = `ALTER TABLE "${constraint.tableName}" ADD CONSTRAINT "${constraint.name}"`;
|
||||
switch (constraint.type) {
|
||||
case DatabaseConstraintType.PRIMARY_KEY: {
|
||||
const columnNames = asColumnList(constraint.columnNames);
|
||||
return `${base} PRIMARY KEY (${columnNames});`;
|
||||
}
|
||||
|
||||
case DatabaseConstraintType.FOREIGN_KEY: {
|
||||
const columnNames = asColumnList(constraint.columnNames);
|
||||
const referenceColumnNames = asColumnList(constraint.referenceColumnNames);
|
||||
return (
|
||||
`${base} FOREIGN KEY (${columnNames}) REFERENCES "${constraint.referenceTableName}" (${referenceColumnNames})` +
|
||||
withAction(constraint) +
|
||||
';'
|
||||
);
|
||||
}
|
||||
|
||||
case DatabaseConstraintType.UNIQUE: {
|
||||
const columnNames = asColumnList(constraint.columnNames);
|
||||
return `${base} UNIQUE (${columnNames});`;
|
||||
}
|
||||
|
||||
case DatabaseConstraintType.CHECK: {
|
||||
return `${base} CHECK (${constraint.expression});`;
|
||||
}
|
||||
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const asConstraintDrop = (tableName: string, constraintName: string): string => {
|
||||
return `ALTER TABLE "${tableName}" DROP CONSTRAINT "${constraintName}";`;
|
||||
};
|
||||
|
||||
const asIndexCreate = (index: DatabaseIndex): string => {
|
||||
let sql = `CREATE`;
|
||||
|
||||
if (index.unique) {
|
||||
sql += ' UNIQUE';
|
||||
}
|
||||
|
||||
sql += ` INDEX "${index.name}" ON "${index.tableName}"`;
|
||||
|
||||
if (index.columnNames) {
|
||||
const columnNames = asColumnList(index.columnNames);
|
||||
sql += ` (${columnNames})`;
|
||||
}
|
||||
|
||||
if (index.using && index.using !== 'btree') {
|
||||
sql += ` USING ${index.using}`;
|
||||
}
|
||||
|
||||
if (index.expression) {
|
||||
sql += ` (${index.expression})`;
|
||||
}
|
||||
|
||||
if (index.where) {
|
||||
sql += ` WHERE ${index.where}`;
|
||||
}
|
||||
|
||||
return sql;
|
||||
};
|
||||
|
||||
const asIndexDrop = (indexName: string): string => {
|
||||
return `DROP INDEX "${indexName}";`;
|
||||
};
|
||||
635
server/src/sql-tools/schema-diff.spec.ts
Normal file
635
server/src/sql-tools/schema-diff.spec.ts
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
import { schemaDiff } from 'src/sql-tools/schema-diff';
|
||||
import {
|
||||
DatabaseActionType,
|
||||
DatabaseColumn,
|
||||
DatabaseColumnType,
|
||||
DatabaseConstraint,
|
||||
DatabaseConstraintType,
|
||||
DatabaseIndex,
|
||||
DatabaseSchema,
|
||||
DatabaseTable,
|
||||
} from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const fromColumn = (column: Partial<Omit<DatabaseColumn, 'tableName'>>): DatabaseSchema => {
|
||||
const tableName = 'table1';
|
||||
|
||||
return {
|
||||
name: 'public',
|
||||
tables: [
|
||||
{
|
||||
name: tableName,
|
||||
columns: [
|
||||
{
|
||||
name: 'column1',
|
||||
synchronize: true,
|
||||
isArray: false,
|
||||
type: 'character varying',
|
||||
nullable: false,
|
||||
...column,
|
||||
tableName,
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
constraints: [],
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
};
|
||||
|
||||
const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => {
|
||||
const tableName = constraint?.tableName || 'table1';
|
||||
|
||||
return {
|
||||
name: 'public',
|
||||
tables: [
|
||||
{
|
||||
name: tableName,
|
||||
columns: [
|
||||
{
|
||||
name: 'column1',
|
||||
synchronize: true,
|
||||
isArray: false,
|
||||
type: 'character varying',
|
||||
nullable: false,
|
||||
tableName,
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
constraints: constraint ? [constraint] : [],
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
};
|
||||
|
||||
const fromIndex = (index?: DatabaseIndex): DatabaseSchema => {
|
||||
const tableName = index?.tableName || 'table1';
|
||||
|
||||
return {
|
||||
name: 'public',
|
||||
tables: [
|
||||
{
|
||||
name: tableName,
|
||||
columns: [
|
||||
{
|
||||
name: 'column1',
|
||||
synchronize: true,
|
||||
isArray: false,
|
||||
type: 'character varying',
|
||||
nullable: false,
|
||||
tableName,
|
||||
},
|
||||
],
|
||||
indexes: index ? [index] : [],
|
||||
constraints: [],
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
};
|
||||
|
||||
const newSchema = (schema: {
|
||||
name?: string;
|
||||
tables: Array<{
|
||||
name: string;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
type?: DatabaseColumnType;
|
||||
nullable?: boolean;
|
||||
isArray?: boolean;
|
||||
}>;
|
||||
indexes?: DatabaseIndex[];
|
||||
constraints?: DatabaseConstraint[];
|
||||
}>;
|
||||
}): DatabaseSchema => {
|
||||
const tables: DatabaseTable[] = [];
|
||||
|
||||
for (const table of schema.tables || []) {
|
||||
const tableName = table.name;
|
||||
const columns: DatabaseColumn[] = [];
|
||||
|
||||
for (const column of table.columns || []) {
|
||||
const columnName = column.name;
|
||||
|
||||
columns.push({
|
||||
tableName,
|
||||
name: columnName,
|
||||
type: column.type || 'character varying',
|
||||
isArray: column.isArray ?? false,
|
||||
nullable: column.nullable ?? false,
|
||||
synchronize: true,
|
||||
});
|
||||
}
|
||||
|
||||
tables.push({
|
||||
name: tableName,
|
||||
columns,
|
||||
indexes: table.indexes ?? [],
|
||||
constraints: table.constraints ?? [],
|
||||
synchronize: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: schema?.name || 'public',
|
||||
tables,
|
||||
warnings: [],
|
||||
};
|
||||
};
|
||||
|
||||
describe('schemaDiff', () => {
|
||||
it('should work', () => {
|
||||
const diff = schemaDiff(newSchema({ tables: [] }), newSchema({ tables: [] }));
|
||||
expect(diff.items).toEqual([]);
|
||||
});
|
||||
|
||||
describe('table', () => {
|
||||
describe('table.create', () => {
|
||||
it('should find a missing table', () => {
|
||||
const column: DatabaseColumn = {
|
||||
type: 'character varying',
|
||||
tableName: 'table1',
|
||||
name: 'column1',
|
||||
isArray: false,
|
||||
nullable: false,
|
||||
synchronize: true,
|
||||
};
|
||||
const diff = schemaDiff(
|
||||
newSchema({ tables: [{ name: 'table1', columns: [column] }] }),
|
||||
newSchema({ tables: [] }),
|
||||
);
|
||||
|
||||
expect(diff.items).toHaveLength(1);
|
||||
expect(diff.items[0]).toEqual({
|
||||
type: 'table.create',
|
||||
tableName: 'table1',
|
||||
columns: [column],
|
||||
reason: 'missing in target',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('table.drop', () => {
|
||||
it('should find an extra table', () => {
|
||||
const diff = schemaDiff(
|
||||
newSchema({ tables: [] }),
|
||||
newSchema({
|
||||
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
|
||||
}),
|
||||
{ ignoreExtraTables: false },
|
||||
);
|
||||
|
||||
expect(diff.items).toHaveLength(1);
|
||||
expect(diff.items[0]).toEqual({
|
||||
type: 'table.drop',
|
||||
tableName: 'table1',
|
||||
reason: 'missing in source',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip identical tables', () => {
|
||||
const diff = schemaDiff(
|
||||
newSchema({
|
||||
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
|
||||
}),
|
||||
newSchema({
|
||||
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('column', () => {
|
||||
describe('column.add', () => {
|
||||
it('should find a new column', () => {
|
||||
const diff = schemaDiff(
|
||||
newSchema({
|
||||
tables: [
|
||||
{
|
||||
name: 'table1',
|
||||
columns: [{ name: 'column1' }, { name: 'column2' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
newSchema({
|
||||
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([
|
||||
{
|
||||
type: 'column.add',
|
||||
column: {
|
||||
tableName: 'table1',
|
||||
isArray: false,
|
||||
name: 'column2',
|
||||
nullable: false,
|
||||
type: 'character varying',
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'missing in target',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('column.drop', () => {
|
||||
it('should find an extra column', () => {
|
||||
const diff = schemaDiff(
|
||||
newSchema({
|
||||
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
|
||||
}),
|
||||
newSchema({
|
||||
tables: [
|
||||
{
|
||||
name: 'table1',
|
||||
columns: [{ name: 'column1' }, { name: 'column2' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([
|
||||
{
|
||||
type: 'column.drop',
|
||||
tableName: 'table1',
|
||||
columnName: 'column2',
|
||||
reason: 'missing in source',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nullable', () => {
|
||||
it('should make a column nullable', () => {
|
||||
const diff = schemaDiff(
|
||||
fromColumn({ name: 'column1', nullable: true }),
|
||||
fromColumn({ name: 'column1', nullable: false }),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([
|
||||
{
|
||||
type: 'column.alter',
|
||||
tableName: 'table1',
|
||||
columnName: 'column1',
|
||||
changes: {
|
||||
nullable: true,
|
||||
},
|
||||
reason: 'nullable is different (true vs false)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should make a column non-nullable', () => {
|
||||
const diff = schemaDiff(
|
||||
fromColumn({ name: 'column1', nullable: false }),
|
||||
fromColumn({ name: 'column1', nullable: true }),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([
|
||||
{
|
||||
type: 'column.alter',
|
||||
tableName: 'table1',
|
||||
columnName: 'column1',
|
||||
changes: {
|
||||
nullable: false,
|
||||
},
|
||||
reason: 'nullable is different (false vs true)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('default', () => {
|
||||
it('should set a default value to a function', () => {
|
||||
const diff = schemaDiff(
|
||||
fromColumn({ name: 'column1', default: 'uuid_generate_v4()' }),
|
||||
fromColumn({ name: 'column1' }),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([
|
||||
{
|
||||
type: 'column.alter',
|
||||
tableName: 'table1',
|
||||
columnName: 'column1',
|
||||
changes: {
|
||||
default: 'uuid_generate_v4()',
|
||||
},
|
||||
reason: 'default is different (uuid_generate_v4() vs undefined)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore explicit casts for strings', () => {
|
||||
const diff = schemaDiff(
|
||||
fromColumn({ name: 'column1', type: 'character varying', default: `''` }),
|
||||
fromColumn({ name: 'column1', type: 'character varying', default: `''::character varying` }),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore explicit casts for numbers', () => {
|
||||
const diff = schemaDiff(
|
||||
fromColumn({ name: 'column1', type: 'bigint', default: `0` }),
|
||||
fromColumn({ name: 'column1', type: 'bigint', default: `'0'::bigint` }),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore explicit casts for enums', () => {
|
||||
const diff = schemaDiff(
|
||||
fromColumn({ name: 'column1', type: 'enum', enumName: 'enum1', default: `test` }),
|
||||
fromColumn({ name: 'column1', type: 'enum', enumName: 'enum1', default: `'test'::enum1` }),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('constraint', () => {
|
||||
describe('constraint.add', () => {
|
||||
it('should detect a new constraint', () => {
|
||||
const diff = schemaDiff(
|
||||
fromConstraint({
|
||||
name: 'PK_test',
|
||||
type: DatabaseConstraintType.PRIMARY_KEY,
|
||||
tableName: 'table1',
|
||||
columnNames: ['id'],
|
||||
synchronize: true,
|
||||
}),
|
||||
fromConstraint(),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([
|
||||
{
|
||||
type: 'constraint.add',
|
||||
constraint: {
|
||||
type: DatabaseConstraintType.PRIMARY_KEY,
|
||||
name: 'PK_test',
|
||||
columnNames: ['id'],
|
||||
tableName: 'table1',
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'missing in target',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constraint.drop', () => {
|
||||
it('should detect an extra constraint', () => {
|
||||
const diff = schemaDiff(
|
||||
fromConstraint(),
|
||||
fromConstraint({
|
||||
name: 'PK_test',
|
||||
type: DatabaseConstraintType.PRIMARY_KEY,
|
||||
tableName: 'table1',
|
||||
columnNames: ['id'],
|
||||
synchronize: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([
|
||||
{
|
||||
type: 'constraint.drop',
|
||||
tableName: 'table1',
|
||||
constraintName: 'PK_test',
|
||||
reason: 'missing in source',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('primary key', () => {
|
||||
it('should skip identical primary key constraints', () => {
|
||||
const constraint: DatabaseConstraint = {
|
||||
type: DatabaseConstraintType.PRIMARY_KEY,
|
||||
name: 'PK_test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['id'],
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint }));
|
||||
|
||||
expect(diff.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('foreign key', () => {
|
||||
it('should skip identical foreign key constraints', () => {
|
||||
const constraint: DatabaseConstraint = {
|
||||
type: DatabaseConstraintType.FOREIGN_KEY,
|
||||
name: 'FK_test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['parentId'],
|
||||
referenceTableName: 'table2',
|
||||
referenceColumnNames: ['id'],
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
const diff = schemaDiff(fromConstraint(constraint), fromConstraint(constraint));
|
||||
|
||||
expect(diff.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('should drop and recreate when the column changes', () => {
|
||||
const constraint: DatabaseConstraint = {
|
||||
type: DatabaseConstraintType.FOREIGN_KEY,
|
||||
name: 'FK_test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['parentId'],
|
||||
referenceTableName: 'table2',
|
||||
referenceColumnNames: ['id'],
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
const diff = schemaDiff(
|
||||
fromConstraint(constraint),
|
||||
fromConstraint({ ...constraint, columnNames: ['parentId2'] }),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([
|
||||
{
|
||||
constraintName: 'FK_test',
|
||||
reason: 'columns are different (parentId vs parentId2)',
|
||||
tableName: 'table1',
|
||||
type: 'constraint.drop',
|
||||
},
|
||||
{
|
||||
constraint: {
|
||||
columnNames: ['parentId'],
|
||||
name: 'FK_test',
|
||||
referenceColumnNames: ['id'],
|
||||
referenceTableName: 'table2',
|
||||
synchronize: true,
|
||||
tableName: 'table1',
|
||||
type: 'foreign-key',
|
||||
},
|
||||
reason: 'columns are different (parentId vs parentId2)',
|
||||
type: 'constraint.add',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should drop and recreate when the ON DELETE action changes', () => {
|
||||
const constraint: DatabaseConstraint = {
|
||||
type: DatabaseConstraintType.FOREIGN_KEY,
|
||||
name: 'FK_test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['parentId'],
|
||||
referenceTableName: 'table2',
|
||||
referenceColumnNames: ['id'],
|
||||
onDelete: DatabaseActionType.CASCADE,
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
const diff = schemaDiff(fromConstraint(constraint), fromConstraint({ ...constraint, onDelete: undefined }));
|
||||
|
||||
expect(diff.items).toEqual([
|
||||
{
|
||||
constraintName: 'FK_test',
|
||||
reason: 'ON DELETE action is different (CASCADE vs NO ACTION)',
|
||||
tableName: 'table1',
|
||||
type: 'constraint.drop',
|
||||
},
|
||||
{
|
||||
constraint: {
|
||||
columnNames: ['parentId'],
|
||||
name: 'FK_test',
|
||||
referenceColumnNames: ['id'],
|
||||
referenceTableName: 'table2',
|
||||
onDelete: DatabaseActionType.CASCADE,
|
||||
synchronize: true,
|
||||
tableName: 'table1',
|
||||
type: 'foreign-key',
|
||||
},
|
||||
reason: 'ON DELETE action is different (CASCADE vs NO ACTION)',
|
||||
type: 'constraint.add',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unique', () => {
|
||||
it('should skip identical unique constraints', () => {
|
||||
const constraint: DatabaseConstraint = {
|
||||
type: DatabaseConstraintType.UNIQUE,
|
||||
name: 'UQ_test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['id'],
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint }));
|
||||
|
||||
expect(diff.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('check', () => {
|
||||
it('should skip identical check constraints', () => {
|
||||
const constraint: DatabaseConstraint = {
|
||||
type: DatabaseConstraintType.CHECK,
|
||||
name: 'CHK_test',
|
||||
tableName: 'table1',
|
||||
expression: 'column1 > 0',
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint }));
|
||||
|
||||
expect(diff.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('index', () => {
|
||||
describe('index.create', () => {
|
||||
it('should detect a new index', () => {
|
||||
const diff = schemaDiff(
|
||||
fromIndex({
|
||||
name: 'IDX_test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['id'],
|
||||
unique: false,
|
||||
synchronize: true,
|
||||
}),
|
||||
fromIndex(),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([
|
||||
{
|
||||
type: 'index.create',
|
||||
index: {
|
||||
name: 'IDX_test',
|
||||
columnNames: ['id'],
|
||||
tableName: 'table1',
|
||||
unique: false,
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'missing in target',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('index.drop', () => {
|
||||
it('should detect an extra index', () => {
|
||||
const diff = schemaDiff(
|
||||
fromIndex(),
|
||||
fromIndex({
|
||||
name: 'IDX_test',
|
||||
unique: true,
|
||||
tableName: 'table1',
|
||||
columnNames: ['id'],
|
||||
synchronize: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([
|
||||
{
|
||||
type: 'index.drop',
|
||||
indexName: 'IDX_test',
|
||||
reason: 'missing in source',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should recreate the index if unique changes', () => {
|
||||
const index: DatabaseIndex = {
|
||||
name: 'IDX_test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['id'],
|
||||
unique: true,
|
||||
synchronize: true,
|
||||
};
|
||||
const diff = schemaDiff(fromIndex(index), fromIndex({ ...index, unique: false }));
|
||||
|
||||
expect(diff.items).toEqual([
|
||||
{
|
||||
type: 'index.drop',
|
||||
indexName: 'IDX_test',
|
||||
reason: 'uniqueness is different (true vs false)',
|
||||
},
|
||||
{
|
||||
type: 'index.create',
|
||||
index,
|
||||
reason: 'uniqueness is different (true vs false)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
449
server/src/sql-tools/schema-diff.ts
Normal file
449
server/src/sql-tools/schema-diff.ts
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
import { getColumnType, schemaDiffToSql } from 'src/sql-tools/schema-diff-to-sql';
|
||||
import {
|
||||
DatabaseCheckConstraint,
|
||||
DatabaseColumn,
|
||||
DatabaseConstraint,
|
||||
DatabaseConstraintType,
|
||||
DatabaseForeignKeyConstraint,
|
||||
DatabaseIndex,
|
||||
DatabasePrimaryKeyConstraint,
|
||||
DatabaseSchema,
|
||||
DatabaseTable,
|
||||
DatabaseUniqueConstraint,
|
||||
SchemaDiff,
|
||||
SchemaDiffToSqlOptions,
|
||||
} from 'src/sql-tools/types';
|
||||
|
||||
enum Reason {
|
||||
MissingInSource = 'missing in source',
|
||||
MissingInTarget = 'missing in target',
|
||||
}
|
||||
|
||||
const setIsEqual = (source: Set<unknown>, target: Set<unknown>) =>
|
||||
source.size === target.size && [...source].every((x) => target.has(x));
|
||||
|
||||
const haveEqualColumns = (sourceColumns?: string[], targetColumns?: string[]) => {
|
||||
return setIsEqual(new Set(sourceColumns ?? []), new Set(targetColumns ?? []));
|
||||
};
|
||||
|
||||
const isSynchronizeDisabled = (source?: { synchronize?: boolean }, target?: { synchronize?: boolean }) => {
|
||||
return source?.synchronize === false || target?.synchronize === false;
|
||||
};
|
||||
|
||||
const withTypeCast = (value: string, type: string) => {
|
||||
if (!value.startsWith(`'`)) {
|
||||
value = `'${value}'`;
|
||||
}
|
||||
return `${value}::${type}`;
|
||||
};
|
||||
|
||||
const isDefaultEqual = (source: DatabaseColumn, target: DatabaseColumn) => {
|
||||
if (source.default === target.default) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (source.default === undefined || target.default === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
withTypeCast(source.default, getColumnType(source)) === target.default ||
|
||||
source.default === withTypeCast(target.default, getColumnType(target))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the difference between two database schemas
|
||||
*/
|
||||
export const schemaDiff = (
|
||||
source: DatabaseSchema,
|
||||
target: DatabaseSchema,
|
||||
options: { ignoreExtraTables?: boolean } = {},
|
||||
) => {
|
||||
const items = diffTables(source.tables, target.tables, {
|
||||
ignoreExtraTables: options.ignoreExtraTables ?? true,
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
asSql: (options?: SchemaDiffToSqlOptions) => schemaDiffToSql(items, options),
|
||||
};
|
||||
};
|
||||
|
||||
export const diffTables = (
|
||||
sources: DatabaseTable[],
|
||||
targets: DatabaseTable[],
|
||||
options: { ignoreExtraTables: boolean },
|
||||
) => {
|
||||
const items: SchemaDiff[] = [];
|
||||
const sourceMap = Object.fromEntries(sources.map((table) => [table.name, table]));
|
||||
const targetMap = Object.fromEntries(targets.map((table) => [table.name, table]));
|
||||
const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]);
|
||||
|
||||
for (const key of keys) {
|
||||
if (options.ignoreExtraTables && !sourceMap[key]) {
|
||||
continue;
|
||||
}
|
||||
items.push(...diffTable(sourceMap[key], targetMap[key]));
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const diffTable = (source?: DatabaseTable, target?: DatabaseTable): SchemaDiff[] => {
|
||||
if (isSynchronizeDisabled(source, target)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (source && !target) {
|
||||
return [
|
||||
{
|
||||
type: 'table.create',
|
||||
tableName: source.name,
|
||||
columns: Object.values(source.columns),
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
...diffIndexes(source.indexes, []),
|
||||
// TODO merge constraints into table create record when possible
|
||||
...diffConstraints(source.constraints, []),
|
||||
];
|
||||
}
|
||||
|
||||
if (!source && target) {
|
||||
return [
|
||||
{
|
||||
type: 'table.drop',
|
||||
tableName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!source || !target) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...diffColumns(source.columns, target.columns),
|
||||
...diffConstraints(source.constraints, target.constraints),
|
||||
...diffIndexes(source.indexes, target.indexes),
|
||||
];
|
||||
};
|
||||
|
||||
const diffColumns = (sources: DatabaseColumn[], targets: DatabaseColumn[]): SchemaDiff[] => {
|
||||
const items: SchemaDiff[] = [];
|
||||
const sourceMap = Object.fromEntries(sources.map((column) => [column.name, column]));
|
||||
const targetMap = Object.fromEntries(targets.map((column) => [column.name, column]));
|
||||
const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]);
|
||||
|
||||
for (const key of keys) {
|
||||
items.push(...diffColumn(sourceMap[key], targetMap[key]));
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const diffColumn = (source?: DatabaseColumn, target?: DatabaseColumn): SchemaDiff[] => {
|
||||
if (isSynchronizeDisabled(source, target)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (source && !target) {
|
||||
return [
|
||||
{
|
||||
type: 'column.add',
|
||||
column: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!source && target) {
|
||||
return [
|
||||
{
|
||||
type: 'column.drop',
|
||||
tableName: target.tableName,
|
||||
columnName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!source || !target) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sourceType = getColumnType(source);
|
||||
const targetType = getColumnType(target);
|
||||
|
||||
const isTypeChanged = sourceType !== targetType;
|
||||
|
||||
if (isTypeChanged) {
|
||||
// TODO: convert between types via UPDATE when possible
|
||||
return dropAndRecreateColumn(source, target, `column type is different (${sourceType} vs ${targetType})`);
|
||||
}
|
||||
|
||||
const items: SchemaDiff[] = [];
|
||||
if (source.nullable !== target.nullable) {
|
||||
items.push({
|
||||
type: 'column.alter',
|
||||
tableName: source.tableName,
|
||||
columnName: source.name,
|
||||
changes: {
|
||||
nullable: source.nullable,
|
||||
},
|
||||
reason: `nullable is different (${source.nullable} vs ${target.nullable})`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isDefaultEqual(source, target)) {
|
||||
items.push({
|
||||
type: 'column.alter',
|
||||
tableName: source.tableName,
|
||||
columnName: source.name,
|
||||
changes: {
|
||||
default: String(source.default),
|
||||
},
|
||||
reason: `default is different (${source.default} vs ${target.default})`,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const diffConstraints = (sources: DatabaseConstraint[], targets: DatabaseConstraint[]): SchemaDiff[] => {
|
||||
const items: SchemaDiff[] = [];
|
||||
|
||||
for (const type of Object.values(DatabaseConstraintType)) {
|
||||
const sourceMap = Object.fromEntries(sources.filter((item) => item.type === type).map((item) => [item.name, item]));
|
||||
const targetMap = Object.fromEntries(targets.filter((item) => item.type === type).map((item) => [item.name, item]));
|
||||
const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]);
|
||||
|
||||
for (const key of keys) {
|
||||
items.push(...diffConstraint(sourceMap[key], targetMap[key]));
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const diffConstraint = <T extends DatabaseConstraint>(source?: T, target?: T): SchemaDiff[] => {
|
||||
if (isSynchronizeDisabled(source, target)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (source && !target) {
|
||||
return [
|
||||
{
|
||||
type: 'constraint.add',
|
||||
constraint: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!source && target) {
|
||||
return [
|
||||
{
|
||||
type: 'constraint.drop',
|
||||
tableName: target.tableName,
|
||||
constraintName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!source || !target) {
|
||||
return [];
|
||||
}
|
||||
|
||||
switch (source.type) {
|
||||
case DatabaseConstraintType.PRIMARY_KEY: {
|
||||
return diffPrimaryKeyConstraint(source, target as DatabasePrimaryKeyConstraint);
|
||||
}
|
||||
|
||||
case DatabaseConstraintType.FOREIGN_KEY: {
|
||||
return diffForeignKeyConstraint(source, target as DatabaseForeignKeyConstraint);
|
||||
}
|
||||
|
||||
case DatabaseConstraintType.UNIQUE: {
|
||||
return diffUniqueConstraint(source, target as DatabaseUniqueConstraint);
|
||||
}
|
||||
|
||||
case DatabaseConstraintType.CHECK: {
|
||||
return diffCheckConstraint(source, target as DatabaseCheckConstraint);
|
||||
}
|
||||
|
||||
default: {
|
||||
return dropAndRecreateConstraint(source, target, `Unknown constraint type: ${(source as any).type}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const diffPrimaryKeyConstraint = (
|
||||
source: DatabasePrimaryKeyConstraint,
|
||||
target: DatabasePrimaryKeyConstraint,
|
||||
): SchemaDiff[] => {
|
||||
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
|
||||
return dropAndRecreateConstraint(
|
||||
source,
|
||||
target,
|
||||
`Primary key columns are different: (${source.columnNames} vs ${target.columnNames})`,
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const diffForeignKeyConstraint = (
|
||||
source: DatabaseForeignKeyConstraint,
|
||||
target: DatabaseForeignKeyConstraint,
|
||||
): SchemaDiff[] => {
|
||||
let reason = '';
|
||||
|
||||
const sourceDeleteAction = source.onDelete ?? 'NO ACTION';
|
||||
const targetDeleteAction = target.onDelete ?? 'NO ACTION';
|
||||
|
||||
const sourceUpdateAction = source.onUpdate ?? 'NO ACTION';
|
||||
const targetUpdateAction = target.onUpdate ?? 'NO ACTION';
|
||||
|
||||
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
|
||||
reason = `columns are different (${source.columnNames} vs ${target.columnNames})`;
|
||||
} else if (!haveEqualColumns(source.referenceColumnNames, target.referenceColumnNames)) {
|
||||
reason = `reference columns are different (${source.referenceColumnNames} vs ${target.referenceColumnNames})`;
|
||||
} else if (source.referenceTableName !== target.referenceTableName) {
|
||||
reason = `reference table is different (${source.referenceTableName} vs ${target.referenceTableName})`;
|
||||
} else if (sourceDeleteAction !== targetDeleteAction) {
|
||||
reason = `ON DELETE action is different (${sourceDeleteAction} vs ${targetDeleteAction})`;
|
||||
} else if (sourceUpdateAction !== targetUpdateAction) {
|
||||
reason = `ON UPDATE action is different (${sourceUpdateAction} vs ${targetUpdateAction})`;
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
return dropAndRecreateConstraint(source, target, reason);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const diffUniqueConstraint = (source: DatabaseUniqueConstraint, target: DatabaseUniqueConstraint): SchemaDiff[] => {
|
||||
let reason = '';
|
||||
|
||||
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
|
||||
reason = `columns are different (${source.columnNames} vs ${target.columnNames})`;
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
return dropAndRecreateConstraint(source, target, reason);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const diffCheckConstraint = (source: DatabaseCheckConstraint, target: DatabaseCheckConstraint): SchemaDiff[] => {
|
||||
if (source.expression !== target.expression) {
|
||||
// comparing expressions is hard because postgres reconstructs it with different formatting
|
||||
// for now if the constraint exists with the same name, we will just skip it
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const diffIndexes = (sources: DatabaseIndex[], targets: DatabaseIndex[]) => {
|
||||
const items: SchemaDiff[] = [];
|
||||
const sourceMap = Object.fromEntries(sources.map((index) => [index.name, index]));
|
||||
const targetMap = Object.fromEntries(targets.map((index) => [index.name, index]));
|
||||
const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]);
|
||||
|
||||
for (const key of keys) {
|
||||
items.push(...diffIndex(sourceMap[key], targetMap[key]));
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const diffIndex = (source?: DatabaseIndex, target?: DatabaseIndex): SchemaDiff[] => {
|
||||
if (isSynchronizeDisabled(source, target)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (source && !target) {
|
||||
return [{ type: 'index.create', index: source, reason: Reason.MissingInTarget }];
|
||||
}
|
||||
|
||||
if (!source && target) {
|
||||
return [
|
||||
{
|
||||
type: 'index.drop',
|
||||
indexName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!target || !source) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sourceUsing = source.using ?? 'btree';
|
||||
const targetUsing = target.using ?? 'btree';
|
||||
|
||||
let reason = '';
|
||||
|
||||
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
|
||||
reason = `columns are different (${source.columnNames} vs ${target.columnNames})`;
|
||||
} else if (source.unique !== target.unique) {
|
||||
reason = `uniqueness is different (${source.unique} vs ${target.unique})`;
|
||||
} else if (sourceUsing !== targetUsing) {
|
||||
reason = `using method is different (${source.using} vs ${target.using})`;
|
||||
} else if (source.where !== target.where) {
|
||||
reason = `where clause is different (${source.where} vs ${target.where})`;
|
||||
} else if (source.expression !== target.expression) {
|
||||
reason = `expression is different (${source.expression} vs ${target.expression})`;
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
return dropAndRecreateIndex(source, target, reason);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const dropAndRecreateColumn = (source: DatabaseColumn, target: DatabaseColumn, reason: string): SchemaDiff[] => {
|
||||
return [
|
||||
{
|
||||
type: 'column.drop',
|
||||
tableName: target.tableName,
|
||||
columnName: target.name,
|
||||
reason,
|
||||
},
|
||||
{ type: 'column.add', column: source, reason },
|
||||
];
|
||||
};
|
||||
|
||||
const dropAndRecreateConstraint = (
|
||||
source: DatabaseConstraint,
|
||||
target: DatabaseConstraint,
|
||||
reason: string,
|
||||
): SchemaDiff[] => {
|
||||
return [
|
||||
{
|
||||
type: 'constraint.drop',
|
||||
tableName: target.tableName,
|
||||
constraintName: target.name,
|
||||
reason,
|
||||
},
|
||||
{ type: 'constraint.add', constraint: source, reason },
|
||||
];
|
||||
};
|
||||
|
||||
const dropAndRecreateIndex = (source: DatabaseIndex, target: DatabaseIndex, reason: string): SchemaDiff[] => {
|
||||
return [
|
||||
{ type: 'index.drop', indexName: target.name, reason },
|
||||
{ type: 'index.create', index: source, reason },
|
||||
];
|
||||
};
|
||||
394
server/src/sql-tools/schema-from-database.ts
Normal file
394
server/src/sql-tools/schema-from-database.ts
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
import { Kysely, sql } from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { Sql } from 'postgres';
|
||||
import {
|
||||
DatabaseActionType,
|
||||
DatabaseClient,
|
||||
DatabaseColumn,
|
||||
DatabaseColumnType,
|
||||
DatabaseConstraintType,
|
||||
DatabaseSchema,
|
||||
DatabaseTable,
|
||||
LoadSchemaOptions,
|
||||
PostgresDB,
|
||||
} from 'src/sql-tools/types';
|
||||
|
||||
/**
|
||||
* Load the database schema from the database
|
||||
*/
|
||||
export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptions = {}): Promise<DatabaseSchema> => {
|
||||
const db = createDatabaseClient(postgres);
|
||||
|
||||
const warnings: string[] = [];
|
||||
const warn = (message: string) => {
|
||||
warnings.push(message);
|
||||
};
|
||||
|
||||
const schemaName = options.schemaName || 'public';
|
||||
const tablesMap: Record<string, DatabaseTable> = {};
|
||||
|
||||
const [tables, columns, indexes, constraints, enums] = await Promise.all([
|
||||
getTables(db, schemaName),
|
||||
getTableColumns(db, schemaName),
|
||||
getTableIndexes(db, schemaName),
|
||||
getTableConstraints(db, schemaName),
|
||||
getUserDefinedEnums(db, schemaName),
|
||||
]);
|
||||
|
||||
const enumMap = Object.fromEntries(enums.map((e) => [e.name, e.values]));
|
||||
|
||||
// add tables
|
||||
for (const table of tables) {
|
||||
const tableName = table.table_name;
|
||||
if (tablesMap[tableName]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tablesMap[table.table_name] = {
|
||||
name: table.table_name,
|
||||
columns: [],
|
||||
indexes: [],
|
||||
constraints: [],
|
||||
synchronize: true,
|
||||
};
|
||||
}
|
||||
|
||||
// add columns to tables
|
||||
for (const column of columns) {
|
||||
const table = tablesMap[column.table_name];
|
||||
if (!table) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const columnName = column.column_name;
|
||||
|
||||
const item: DatabaseColumn = {
|
||||
type: column.data_type as DatabaseColumnType,
|
||||
name: columnName,
|
||||
tableName: column.table_name,
|
||||
nullable: column.is_nullable === 'YES',
|
||||
isArray: column.array_type !== null,
|
||||
numericPrecision: column.numeric_precision ?? undefined,
|
||||
numericScale: column.numeric_scale ?? undefined,
|
||||
default: column.column_default ?? undefined,
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
const columnLabel = `${table.name}.${columnName}`;
|
||||
|
||||
switch (column.data_type) {
|
||||
// array types
|
||||
case 'ARRAY': {
|
||||
if (!column.array_type) {
|
||||
warn(`Unable to find type for ${columnLabel} (ARRAY)`);
|
||||
continue;
|
||||
}
|
||||
item.type = column.array_type as DatabaseColumnType;
|
||||
break;
|
||||
}
|
||||
|
||||
// enum types
|
||||
case 'USER-DEFINED': {
|
||||
if (!enumMap[column.udt_name]) {
|
||||
warn(`Unable to find type for ${columnLabel} (ENUM)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
item.type = 'enum';
|
||||
item.enumName = column.udt_name;
|
||||
item.enumValues = enumMap[column.udt_name];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
table.columns.push(item);
|
||||
}
|
||||
|
||||
// add table indexes
|
||||
for (const index of indexes) {
|
||||
const table = tablesMap[index.table_name];
|
||||
if (!table) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const indexName = index.index_name;
|
||||
|
||||
table.indexes.push({
|
||||
name: indexName,
|
||||
tableName: index.table_name,
|
||||
columnNames: index.column_names ?? undefined,
|
||||
expression: index.expression ?? undefined,
|
||||
using: index.using,
|
||||
where: index.where ?? undefined,
|
||||
unique: index.unique,
|
||||
synchronize: true,
|
||||
});
|
||||
}
|
||||
|
||||
// add table constraints
|
||||
for (const constraint of constraints) {
|
||||
const table = tablesMap[constraint.table_name];
|
||||
if (!table) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const constraintName = constraint.constraint_name;
|
||||
|
||||
switch (constraint.constraint_type) {
|
||||
// primary key constraint
|
||||
case 'p': {
|
||||
if (!constraint.column_names) {
|
||||
warn(`Skipping CONSTRAINT "${constraintName}", no columns found`);
|
||||
continue;
|
||||
}
|
||||
table.constraints.push({
|
||||
type: DatabaseConstraintType.PRIMARY_KEY,
|
||||
name: constraintName,
|
||||
tableName: constraint.table_name,
|
||||
columnNames: constraint.column_names,
|
||||
synchronize: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// foreign key constraint
|
||||
case 'f': {
|
||||
if (!constraint.column_names || !constraint.reference_table_name || !constraint.reference_column_names) {
|
||||
warn(
|
||||
`Skipping CONSTRAINT "${constraintName}", missing either columns, referenced table, or referenced columns,`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
table.constraints.push({
|
||||
type: DatabaseConstraintType.FOREIGN_KEY,
|
||||
name: constraintName,
|
||||
tableName: constraint.table_name,
|
||||
columnNames: constraint.column_names,
|
||||
referenceTableName: constraint.reference_table_name,
|
||||
referenceColumnNames: constraint.reference_column_names,
|
||||
onUpdate: asDatabaseAction(constraint.update_action),
|
||||
onDelete: asDatabaseAction(constraint.delete_action),
|
||||
synchronize: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// unique constraint
|
||||
case 'u': {
|
||||
table.constraints.push({
|
||||
type: DatabaseConstraintType.UNIQUE,
|
||||
name: constraintName,
|
||||
tableName: constraint.table_name,
|
||||
columnNames: constraint.column_names as string[],
|
||||
synchronize: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// check constraint
|
||||
case 'c': {
|
||||
table.constraints.push({
|
||||
type: DatabaseConstraintType.CHECK,
|
||||
name: constraint.constraint_name,
|
||||
tableName: constraint.table_name,
|
||||
expression: constraint.expression.replace('CHECK ', ''),
|
||||
synchronize: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.destroy();
|
||||
|
||||
return {
|
||||
name: schemaName,
|
||||
tables: Object.values(tablesMap),
|
||||
warnings,
|
||||
};
|
||||
};
|
||||
|
||||
const createDatabaseClient = (postgres: Sql): DatabaseClient =>
|
||||
new Kysely<PostgresDB>({ dialect: new PostgresJSDialect({ postgres }) });
|
||||
|
||||
const asDatabaseAction = (action: string) => {
|
||||
switch (action) {
|
||||
case 'a': {
|
||||
return DatabaseActionType.NO_ACTION;
|
||||
}
|
||||
case 'c': {
|
||||
return DatabaseActionType.CASCADE;
|
||||
}
|
||||
case 'r': {
|
||||
return DatabaseActionType.RESTRICT;
|
||||
}
|
||||
case 'n': {
|
||||
return DatabaseActionType.SET_NULL;
|
||||
}
|
||||
case 'd': {
|
||||
return DatabaseActionType.SET_DEFAULT;
|
||||
}
|
||||
|
||||
default: {
|
||||
return DatabaseActionType.NO_ACTION;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getTables = (db: DatabaseClient, schemaName: string) => {
|
||||
return db
|
||||
.selectFrom('information_schema.tables')
|
||||
.where('table_schema', '=', schemaName)
|
||||
.where('table_type', '=', sql.lit('BASE TABLE'))
|
||||
.selectAll()
|
||||
.execute();
|
||||
};
|
||||
|
||||
const getUserDefinedEnums = async (db: DatabaseClient, schemaName: string) => {
|
||||
const items = await db
|
||||
.selectFrom('pg_type')
|
||||
.innerJoin('pg_namespace', (join) =>
|
||||
join.onRef('pg_namespace.oid', '=', 'pg_type.typnamespace').on('pg_namespace.nspname', '=', schemaName),
|
||||
)
|
||||
.where('typtype', '=', sql.lit('e'))
|
||||
.select((eb) => [
|
||||
'pg_type.typname as name',
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom('pg_enum as e').select(['e.enumlabel as value']).whereRef('e.enumtypid', '=', 'pg_type.oid'),
|
||||
).as('values'),
|
||||
])
|
||||
.execute();
|
||||
|
||||
return items.map((item) => ({
|
||||
name: item.name,
|
||||
values: item.values.map(({ value }) => value),
|
||||
}));
|
||||
};
|
||||
|
||||
const getTableColumns = (db: DatabaseClient, schemaName: string) => {
|
||||
return db
|
||||
.selectFrom('information_schema.columns as c')
|
||||
.leftJoin('information_schema.element_types as o', (join) =>
|
||||
join
|
||||
.onRef('c.table_catalog', '=', 'o.object_catalog')
|
||||
.onRef('c.table_schema', '=', 'o.object_schema')
|
||||
.onRef('c.table_name', '=', 'o.object_name')
|
||||
.on('o.object_type', '=', sql.lit('TABLE'))
|
||||
.onRef('c.dtd_identifier', '=', 'o.collection_type_identifier'),
|
||||
)
|
||||
.leftJoin('pg_type as t', (join) =>
|
||||
join.onRef('t.typname', '=', 'c.udt_name').on('c.data_type', '=', sql.lit('USER-DEFINED')),
|
||||
)
|
||||
.leftJoin('pg_enum as e', (join) => join.onRef('e.enumtypid', '=', 't.oid'))
|
||||
.select([
|
||||
'c.table_name',
|
||||
'c.column_name',
|
||||
|
||||
// is ARRAY, USER-DEFINED, or data type
|
||||
'c.data_type',
|
||||
'c.column_default',
|
||||
'c.is_nullable',
|
||||
|
||||
// number types
|
||||
'c.numeric_precision',
|
||||
'c.numeric_scale',
|
||||
|
||||
// date types
|
||||
'c.datetime_precision',
|
||||
|
||||
// user defined type
|
||||
'c.udt_catalog',
|
||||
'c.udt_schema',
|
||||
'c.udt_name',
|
||||
|
||||
// data type for ARRAYs
|
||||
'o.data_type as array_type',
|
||||
])
|
||||
.where('table_schema', '=', schemaName)
|
||||
.execute();
|
||||
};
|
||||
|
||||
const getTableIndexes = (db: DatabaseClient, schemaName: string) => {
|
||||
return (
|
||||
db
|
||||
.selectFrom('pg_index as ix')
|
||||
// matching index, which has column information
|
||||
.innerJoin('pg_class as i', 'ix.indexrelid', 'i.oid')
|
||||
.innerJoin('pg_am as a', 'i.relam', 'a.oid')
|
||||
// matching table
|
||||
.innerJoin('pg_class as t', 'ix.indrelid', 't.oid')
|
||||
// namespace
|
||||
.innerJoin('pg_namespace', 'pg_namespace.oid', 'i.relnamespace')
|
||||
// PK and UQ constraints automatically have indexes, so we can ignore those
|
||||
.leftJoin('pg_constraint', (join) =>
|
||||
join
|
||||
.onRef('pg_constraint.conindid', '=', 'i.oid')
|
||||
.on('pg_constraint.contype', 'in', [sql.lit('p'), sql.lit('u')]),
|
||||
)
|
||||
.where('pg_constraint.oid', 'is', null)
|
||||
.select((eb) => [
|
||||
'i.relname as index_name',
|
||||
't.relname as table_name',
|
||||
'ix.indisunique as unique',
|
||||
'a.amname as using',
|
||||
eb.fn<string>('pg_get_expr', ['ix.indexprs', 'ix.indrelid']).as('expression'),
|
||||
eb.fn<string>('pg_get_expr', ['ix.indpred', 'ix.indrelid']).as('where'),
|
||||
eb
|
||||
.selectFrom('pg_attribute as a')
|
||||
.where('t.relkind', '=', sql.lit('r'))
|
||||
.whereRef('a.attrelid', '=', 't.oid')
|
||||
// list of columns numbers in the index
|
||||
.whereRef('a.attnum', '=', sql`any("ix"."indkey")`)
|
||||
.select((eb) => eb.fn<string[]>('json_agg', ['a.attname']).as('column_name'))
|
||||
.as('column_names'),
|
||||
])
|
||||
.where('pg_namespace.nspname', '=', schemaName)
|
||||
.where('ix.indisprimary', '=', sql.lit(false))
|
||||
.execute()
|
||||
);
|
||||
};
|
||||
|
||||
const getTableConstraints = (db: DatabaseClient, schemaName: string) => {
|
||||
return db
|
||||
.selectFrom('pg_constraint')
|
||||
.innerJoin('pg_namespace', 'pg_namespace.oid', 'pg_constraint.connamespace') // namespace
|
||||
.innerJoin('pg_class as source_table', (join) =>
|
||||
join.onRef('source_table.oid', '=', 'pg_constraint.conrelid').on('source_table.relkind', 'in', [
|
||||
// ordinary table
|
||||
sql.lit('r'),
|
||||
// partitioned table
|
||||
sql.lit('p'),
|
||||
// foreign table
|
||||
sql.lit('f'),
|
||||
]),
|
||||
) // table
|
||||
.leftJoin('pg_class as reference_table', 'reference_table.oid', 'pg_constraint.confrelid') // reference table
|
||||
.select((eb) => [
|
||||
'pg_constraint.contype as constraint_type',
|
||||
'pg_constraint.conname as constraint_name',
|
||||
'source_table.relname as table_name',
|
||||
'reference_table.relname as reference_table_name',
|
||||
'pg_constraint.confupdtype as update_action',
|
||||
'pg_constraint.confdeltype as delete_action',
|
||||
// 'pg_constraint.oid as constraint_id',
|
||||
eb
|
||||
.selectFrom('pg_attribute')
|
||||
// matching table for PK, FK, and UQ
|
||||
.whereRef('pg_attribute.attrelid', '=', 'pg_constraint.conrelid')
|
||||
.whereRef('pg_attribute.attnum', '=', sql`any("pg_constraint"."conkey")`)
|
||||
.select((eb) => eb.fn<string[]>('json_agg', ['pg_attribute.attname']).as('column_name'))
|
||||
.as('column_names'),
|
||||
eb
|
||||
.selectFrom('pg_attribute')
|
||||
// matching foreign table for FK
|
||||
.whereRef('pg_attribute.attrelid', '=', 'pg_constraint.confrelid')
|
||||
.whereRef('pg_attribute.attnum', '=', sql`any("pg_constraint"."confkey")`)
|
||||
.select((eb) => eb.fn<string[]>('json_agg', ['pg_attribute.attname']).as('column_name'))
|
||||
.as('reference_column_names'),
|
||||
eb.fn<string>('pg_get_constraintdef', ['pg_constraint.oid']).as('expression'),
|
||||
])
|
||||
.where('pg_namespace.nspname', '=', schemaName)
|
||||
.execute();
|
||||
};
|
||||
31
server/src/sql-tools/schema-from-decorators.spec.ts
Normal file
31
server/src/sql-tools/schema-from-decorators.spec.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { reset, schemaFromDecorators } from 'src/sql-tools/schema-from-decorators';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('schemaDiff', () => {
|
||||
beforeEach(() => {
|
||||
reset();
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(schemaFromDecorators()).toEqual({
|
||||
name: 'public',
|
||||
tables: [],
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe('test files', () => {
|
||||
const files = readdirSync('test/sql-tools', { withFileTypes: true });
|
||||
for (const file of files) {
|
||||
const filePath = join(file.parentPath, file.name);
|
||||
it(filePath, async () => {
|
||||
const module = await import(filePath);
|
||||
expect(module.description).toBeDefined();
|
||||
expect(module.schema).toBeDefined();
|
||||
expect(schemaFromDecorators(), module.description).toEqual(module.schema);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
443
server/src/sql-tools/schema-from-decorators.ts
Normal file
443
server/src/sql-tools/schema-from-decorators.ts
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
|
||||
import { createHash } from 'node:crypto';
|
||||
import 'reflect-metadata';
|
||||
import {
|
||||
CheckOptions,
|
||||
ColumnDefaultValue,
|
||||
ColumnIndexOptions,
|
||||
ColumnOptions,
|
||||
DatabaseActionType,
|
||||
DatabaseColumn,
|
||||
DatabaseConstraintType,
|
||||
DatabaseSchema,
|
||||
DatabaseTable,
|
||||
ForeignKeyColumnOptions,
|
||||
IndexOptions,
|
||||
TableOptions,
|
||||
UniqueOptions,
|
||||
} from 'src/sql-tools/types';
|
||||
|
||||
enum SchemaKey {
|
||||
TableName = 'immich-schema:table-name',
|
||||
ColumnName = 'immich-schema:column-name',
|
||||
IndexName = 'immich-schema:index-name',
|
||||
}
|
||||
|
||||
type SchemaTable = DatabaseTable & { options: TableOptions };
|
||||
type SchemaTables = SchemaTable[];
|
||||
type ClassBased<T> = { object: Function } & T;
|
||||
type PropertyBased<T> = { object: object; propertyName: string | symbol } & T;
|
||||
type RegisterItem =
|
||||
| { type: 'table'; item: ClassBased<{ options: TableOptions }> }
|
||||
| { type: 'index'; item: ClassBased<{ options: IndexOptions }> }
|
||||
| { type: 'uniqueConstraint'; item: ClassBased<{ options: UniqueOptions }> }
|
||||
| { type: 'checkConstraint'; item: ClassBased<{ options: CheckOptions }> }
|
||||
| { type: 'column'; item: PropertyBased<{ options: ColumnOptions }> }
|
||||
| { type: 'columnIndex'; item: PropertyBased<{ options: ColumnIndexOptions }> }
|
||||
| { type: 'foreignKeyColumn'; item: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }> };
|
||||
|
||||
const items: RegisterItem[] = [];
|
||||
export const register = (item: RegisterItem) => void items.push(item);
|
||||
|
||||
const asSnakeCase = (name: string): string => name.replaceAll(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
|
||||
const asKey = (prefix: string, tableName: string, values: string[]) =>
|
||||
(prefix + sha1(`${tableName}_${values.toSorted().join('_')}`)).slice(0, 30);
|
||||
const asPrimaryKeyConstraintName = (table: string, columns: string[]) => asKey('PK_', table, columns);
|
||||
const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, columns);
|
||||
const asRelationKeyConstraintName = (table: string, columns: string[]) => asKey('REL_', table, columns);
|
||||
const asUniqueConstraintName = (table: string, columns: string[]) => asKey('UQ_', table, columns);
|
||||
const asCheckConstraintName = (table: string, expression: string) => asKey('CHK_', table, [expression]);
|
||||
const asIndexName = (table: string, columns: string[] | undefined, where: string | undefined) => {
|
||||
const items: string[] = [];
|
||||
for (const columnName of columns ?? []) {
|
||||
items.push(columnName);
|
||||
}
|
||||
|
||||
if (where) {
|
||||
items.push(where);
|
||||
}
|
||||
|
||||
return asKey('IDX_', table, items);
|
||||
};
|
||||
|
||||
const makeColumn = ({
|
||||
name,
|
||||
tableName,
|
||||
options,
|
||||
}: {
|
||||
name: string;
|
||||
tableName: string;
|
||||
options: ColumnOptions;
|
||||
}): DatabaseColumn => {
|
||||
const columnName = options.name ?? name;
|
||||
const enumName = options.enumName ?? `${tableName}_${columnName}_enum`.toLowerCase();
|
||||
let defaultValue = asDefaultValue(options);
|
||||
let nullable = options.nullable ?? false;
|
||||
|
||||
if (defaultValue === null) {
|
||||
nullable = true;
|
||||
defaultValue = undefined;
|
||||
}
|
||||
|
||||
const isEnum = !!options.enum;
|
||||
|
||||
return {
|
||||
name: columnName,
|
||||
tableName,
|
||||
primary: options.primary ?? false,
|
||||
default: defaultValue,
|
||||
nullable,
|
||||
enumName: isEnum ? enumName : undefined,
|
||||
enumValues: isEnum ? Object.values(options.enum as object) : undefined,
|
||||
isArray: options.array ?? false,
|
||||
type: isEnum ? 'enum' : options.type || 'character varying',
|
||||
synchronize: options.synchronize ?? true,
|
||||
};
|
||||
};
|
||||
|
||||
const asDefaultValue = (options: { nullable?: boolean; default?: ColumnDefaultValue }) => {
|
||||
if (typeof options.default === 'function') {
|
||||
return options.default() as string;
|
||||
}
|
||||
|
||||
if (options.default === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = options.default;
|
||||
|
||||
if (value === null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return `'${value.toISOString()}'`;
|
||||
}
|
||||
|
||||
return `'${String(value)}'`;
|
||||
};
|
||||
|
||||
const missingTableError = (context: string, object: object, propertyName?: string | symbol) => {
|
||||
const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : '');
|
||||
return `[${context}] Unable to find table (${label})`;
|
||||
};
|
||||
|
||||
// match TypeORM
|
||||
const sha1 = (value: string) => createHash('sha1').update(value).digest('hex');
|
||||
|
||||
const findByName = <T extends { name: string }>(items: T[], name?: string) =>
|
||||
name ? items.find((item) => item.name === name) : undefined;
|
||||
const resolveTable = (tables: SchemaTables, object: object) =>
|
||||
findByName(tables, Reflect.getMetadata(SchemaKey.TableName, object));
|
||||
|
||||
let initialized = false;
|
||||
let schema: DatabaseSchema;
|
||||
|
||||
export const reset = () => {
|
||||
initialized = false;
|
||||
items.length = 0;
|
||||
};
|
||||
|
||||
export const schemaFromDecorators = () => {
|
||||
if (!initialized) {
|
||||
const schemaTables: SchemaTables = [];
|
||||
|
||||
const warnings: string[] = [];
|
||||
const warn = (message: string) => void warnings.push(message);
|
||||
|
||||
for (const { item } of items.filter((item) => item.type === 'table')) {
|
||||
processTable(schemaTables, item);
|
||||
}
|
||||
|
||||
for (const { item } of items.filter((item) => item.type === 'column')) {
|
||||
processColumn(schemaTables, item, { warn });
|
||||
}
|
||||
|
||||
for (const { item } of items.filter((item) => item.type === 'foreignKeyColumn')) {
|
||||
processForeignKeyColumn(schemaTables, item, { warn });
|
||||
}
|
||||
|
||||
for (const { item } of items.filter((item) => item.type === 'uniqueConstraint')) {
|
||||
processUniqueConstraint(schemaTables, item, { warn });
|
||||
}
|
||||
|
||||
for (const { item } of items.filter((item) => item.type === 'checkConstraint')) {
|
||||
processCheckConstraint(schemaTables, item, { warn });
|
||||
}
|
||||
|
||||
for (const table of schemaTables) {
|
||||
processPrimaryKeyConstraint(table);
|
||||
}
|
||||
|
||||
for (const { item } of items.filter((item) => item.type === 'index')) {
|
||||
processIndex(schemaTables, item, { warn });
|
||||
}
|
||||
|
||||
for (const { item } of items.filter((item) => item.type === 'columnIndex')) {
|
||||
processColumnIndex(schemaTables, item, { warn });
|
||||
}
|
||||
|
||||
for (const { item } of items.filter((item) => item.type === 'foreignKeyColumn')) {
|
||||
processForeignKeyConstraint(schemaTables, item, { warn });
|
||||
}
|
||||
|
||||
schema = {
|
||||
name: 'public',
|
||||
tables: schemaTables.map(({ options: _, ...table }) => table),
|
||||
warnings,
|
||||
};
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
return schema;
|
||||
};
|
||||
|
||||
const processTable = (tables: SchemaTables, { object, options }: ClassBased<{ options: TableOptions }>) => {
|
||||
const tableName = options.name || asSnakeCase(object.name);
|
||||
Reflect.defineMetadata(SchemaKey.TableName, tableName, object);
|
||||
tables.push({
|
||||
name: tableName,
|
||||
columns: [],
|
||||
constraints: [],
|
||||
indexes: [],
|
||||
options,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
};
|
||||
|
||||
type OnWarn = (message: string) => void;
|
||||
|
||||
const processColumn = (
|
||||
tables: SchemaTables,
|
||||
{ object, propertyName, options }: PropertyBased<{ options: ColumnOptions }>,
|
||||
{ warn }: { warn: OnWarn },
|
||||
) => {
|
||||
const table = resolveTable(tables, object.constructor);
|
||||
if (!table) {
|
||||
warn(missingTableError('@Column', object, propertyName));
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO make sure column name is unique
|
||||
|
||||
const column = makeColumn({ name: String(propertyName), tableName: table.name, options });
|
||||
|
||||
Reflect.defineMetadata(SchemaKey.ColumnName, column.name, object, propertyName);
|
||||
|
||||
table.columns.push(column);
|
||||
|
||||
if (!options.primary && options.unique) {
|
||||
table.constraints.push({
|
||||
type: DatabaseConstraintType.UNIQUE,
|
||||
name: options.uniqueConstraintName || asUniqueConstraintName(table.name, [column.name]),
|
||||
tableName: table.name,
|
||||
columnNames: [column.name],
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const processUniqueConstraint = (
|
||||
tables: SchemaTables,
|
||||
{ object, options }: ClassBased<{ options: UniqueOptions }>,
|
||||
{ warn }: { warn: OnWarn },
|
||||
) => {
|
||||
const table = resolveTable(tables, object);
|
||||
if (!table) {
|
||||
warn(missingTableError('@Unique', object));
|
||||
return;
|
||||
}
|
||||
|
||||
const tableName = table.name;
|
||||
const columnNames = options.columns;
|
||||
|
||||
table.constraints.push({
|
||||
type: DatabaseConstraintType.UNIQUE,
|
||||
name: options.name || asUniqueConstraintName(tableName, columnNames),
|
||||
tableName,
|
||||
columnNames,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
};
|
||||
|
||||
const processCheckConstraint = (
|
||||
tables: SchemaTables,
|
||||
{ object, options }: ClassBased<{ options: CheckOptions }>,
|
||||
{ warn }: { warn: OnWarn },
|
||||
) => {
|
||||
const table = resolveTable(tables, object);
|
||||
if (!table) {
|
||||
warn(missingTableError('@Check', object));
|
||||
return;
|
||||
}
|
||||
|
||||
const tableName = table.name;
|
||||
|
||||
table.constraints.push({
|
||||
type: DatabaseConstraintType.CHECK,
|
||||
name: options.name || asCheckConstraintName(tableName, options.expression),
|
||||
tableName,
|
||||
expression: options.expression,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
};
|
||||
|
||||
const processPrimaryKeyConstraint = (table: SchemaTable) => {
|
||||
const columnNames: string[] = [];
|
||||
|
||||
for (const column of table.columns) {
|
||||
if (column.primary) {
|
||||
columnNames.push(column.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (columnNames.length > 0) {
|
||||
table.constraints.push({
|
||||
type: DatabaseConstraintType.PRIMARY_KEY,
|
||||
name: table.options.primaryConstraintName || asPrimaryKeyConstraintName(table.name, columnNames),
|
||||
tableName: table.name,
|
||||
columnNames,
|
||||
synchronize: table.options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const processIndex = (
|
||||
tables: SchemaTables,
|
||||
{ object, options }: ClassBased<{ options: IndexOptions }>,
|
||||
{ warn }: { warn: OnWarn },
|
||||
) => {
|
||||
const table = resolveTable(tables, object);
|
||||
if (!table) {
|
||||
warn(missingTableError('@Index', object));
|
||||
return;
|
||||
}
|
||||
|
||||
table.indexes.push({
|
||||
name: options.name || asIndexName(table.name, options.columns, options.where),
|
||||
tableName: table.name,
|
||||
unique: options.unique ?? false,
|
||||
expression: options.expression,
|
||||
using: options.using,
|
||||
where: options.where,
|
||||
columnNames: options.columns,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
};
|
||||
|
||||
const processColumnIndex = (
|
||||
tables: SchemaTables,
|
||||
{ object, propertyName, options }: PropertyBased<{ options: ColumnIndexOptions }>,
|
||||
{ warn }: { warn: OnWarn },
|
||||
) => {
|
||||
const table = resolveTable(tables, object.constructor);
|
||||
if (!table) {
|
||||
warn(missingTableError('@ColumnIndex', object, propertyName));
|
||||
return;
|
||||
}
|
||||
|
||||
const column = findByName(table.columns, Reflect.getMetadata(SchemaKey.ColumnName, object, propertyName));
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
|
||||
table.indexes.push({
|
||||
name: options.name || asIndexName(table.name, [column.name], options.where),
|
||||
tableName: table.name,
|
||||
unique: options.unique ?? false,
|
||||
expression: options.expression,
|
||||
using: options.using,
|
||||
where: options.where,
|
||||
columnNames: [column.name],
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
};
|
||||
|
||||
const processForeignKeyColumn = (
|
||||
tables: SchemaTables,
|
||||
{ object, propertyName, options }: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }>,
|
||||
{ warn }: { warn: OnWarn },
|
||||
) => {
|
||||
const table = resolveTable(tables, object.constructor);
|
||||
if (!table) {
|
||||
warn(missingTableError('@ForeignKeyColumn', object));
|
||||
return;
|
||||
}
|
||||
|
||||
const columnName = String(propertyName);
|
||||
const existingColumn = table.columns.find((column) => column.name === columnName);
|
||||
if (existingColumn) {
|
||||
// TODO log warnings if column options and `@Column` is also used
|
||||
return;
|
||||
}
|
||||
|
||||
const column = makeColumn({ name: columnName, tableName: table.name, options });
|
||||
|
||||
Reflect.defineMetadata(SchemaKey.ColumnName, columnName, object, propertyName);
|
||||
|
||||
table.columns.push(column);
|
||||
};
|
||||
|
||||
const processForeignKeyConstraint = (
|
||||
tables: SchemaTables,
|
||||
{ object, propertyName, options, target }: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }>,
|
||||
{ warn }: { warn: OnWarn },
|
||||
) => {
|
||||
const childTable = resolveTable(tables, object.constructor);
|
||||
if (!childTable) {
|
||||
warn(missingTableError('@ForeignKeyColumn', object));
|
||||
return;
|
||||
}
|
||||
|
||||
const parentTable = resolveTable(tables, target());
|
||||
if (!parentTable) {
|
||||
warn(missingTableError('@ForeignKeyColumn', object, propertyName));
|
||||
return;
|
||||
}
|
||||
|
||||
const columnName = String(propertyName);
|
||||
const column = childTable.columns.find((column) => column.name === columnName);
|
||||
if (!column) {
|
||||
warn('@ForeignKeyColumn: Column not found, creating a new one');
|
||||
return;
|
||||
}
|
||||
|
||||
const columnNames = [column.name];
|
||||
const referenceColumns = parentTable.columns.filter((column) => column.primary);
|
||||
|
||||
// infer FK column type from reference table
|
||||
if (referenceColumns.length === 1) {
|
||||
column.type = referenceColumns[0].type;
|
||||
}
|
||||
|
||||
childTable.constraints.push({
|
||||
name: options.constraintName || asForeignKeyConstraintName(childTable.name, columnNames),
|
||||
tableName: childTable.name,
|
||||
columnNames,
|
||||
type: DatabaseConstraintType.FOREIGN_KEY,
|
||||
referenceTableName: parentTable.name,
|
||||
referenceColumnNames: referenceColumns.map((column) => column.name),
|
||||
onUpdate: options.onUpdate as DatabaseActionType,
|
||||
onDelete: options.onDelete as DatabaseActionType,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
|
||||
if (options.unique) {
|
||||
childTable.constraints.push({
|
||||
name: options.uniqueConstraintName || asRelationKeyConstraintName(childTable.name, columnNames),
|
||||
tableName: childTable.name,
|
||||
columnNames,
|
||||
type: DatabaseConstraintType.UNIQUE,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
||||
363
server/src/sql-tools/types.ts
Normal file
363
server/src/sql-tools/types.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import { Kysely } from 'kysely';
|
||||
|
||||
export type PostgresDB = {
|
||||
pg_am: {
|
||||
oid: number;
|
||||
amname: string;
|
||||
amhandler: string;
|
||||
amtype: string;
|
||||
};
|
||||
|
||||
pg_attribute: {
|
||||
attrelid: number;
|
||||
attname: string;
|
||||
attnum: number;
|
||||
atttypeid: number;
|
||||
attstattarget: number;
|
||||
attstatarget: number;
|
||||
aanum: number;
|
||||
};
|
||||
|
||||
pg_class: {
|
||||
oid: number;
|
||||
relname: string;
|
||||
relkind: string;
|
||||
relnamespace: string;
|
||||
reltype: string;
|
||||
relowner: string;
|
||||
relam: string;
|
||||
relfilenode: string;
|
||||
reltablespace: string;
|
||||
relpages: number;
|
||||
reltuples: number;
|
||||
relallvisible: number;
|
||||
reltoastrelid: string;
|
||||
relhasindex: PostgresYesOrNo;
|
||||
relisshared: PostgresYesOrNo;
|
||||
relpersistence: string;
|
||||
};
|
||||
|
||||
pg_constraint: {
|
||||
oid: number;
|
||||
conname: string;
|
||||
conrelid: string;
|
||||
contype: string;
|
||||
connamespace: string;
|
||||
conkey: number[];
|
||||
confkey: number[];
|
||||
confrelid: string;
|
||||
confupdtype: string;
|
||||
confdeltype: string;
|
||||
confmatchtype: number;
|
||||
condeferrable: PostgresYesOrNo;
|
||||
condeferred: PostgresYesOrNo;
|
||||
convalidated: PostgresYesOrNo;
|
||||
conindid: number;
|
||||
};
|
||||
|
||||
pg_enum: {
|
||||
oid: string;
|
||||
enumtypid: string;
|
||||
enumsortorder: number;
|
||||
enumlabel: string;
|
||||
};
|
||||
|
||||
pg_index: {
|
||||
indexrelid: string;
|
||||
indrelid: string;
|
||||
indisready: boolean;
|
||||
indexprs: string | null;
|
||||
indpred: string | null;
|
||||
indkey: number[];
|
||||
indisprimary: boolean;
|
||||
indisunique: boolean;
|
||||
};
|
||||
|
||||
pg_indexes: {
|
||||
schemaname: string;
|
||||
tablename: string;
|
||||
indexname: string;
|
||||
tablespace: string | null;
|
||||
indexrelid: string;
|
||||
indexdef: string;
|
||||
};
|
||||
|
||||
pg_namespace: {
|
||||
oid: number;
|
||||
nspname: string;
|
||||
nspowner: number;
|
||||
nspacl: string[];
|
||||
};
|
||||
|
||||
pg_type: {
|
||||
oid: string;
|
||||
typname: string;
|
||||
typnamespace: string;
|
||||
typowner: string;
|
||||
typtype: string;
|
||||
typcategory: string;
|
||||
typarray: string;
|
||||
};
|
||||
|
||||
'information_schema.tables': {
|
||||
table_catalog: string;
|
||||
table_schema: string;
|
||||
table_name: string;
|
||||
table_type: 'VIEW' | 'BASE TABLE' | string;
|
||||
is_insertable_info: PostgresYesOrNo;
|
||||
is_typed: PostgresYesOrNo;
|
||||
commit_action: string | null;
|
||||
};
|
||||
|
||||
'information_schema.columns': {
|
||||
table_catalog: string;
|
||||
table_schema: string;
|
||||
table_name: string;
|
||||
column_name: string;
|
||||
ordinal_position: number;
|
||||
column_default: string | null;
|
||||
is_nullable: PostgresYesOrNo;
|
||||
data_type: string;
|
||||
dtd_identifier: string;
|
||||
character_maximum_length: number | null;
|
||||
character_octet_length: number | null;
|
||||
numeric_precision: number | null;
|
||||
numeric_precision_radix: number | null;
|
||||
numeric_scale: number | null;
|
||||
datetime_precision: number | null;
|
||||
interval_type: string | null;
|
||||
interval_precision: number | null;
|
||||
udt_catalog: string;
|
||||
udt_schema: string;
|
||||
udt_name: string;
|
||||
maximum_cardinality: number | null;
|
||||
is_updatable: PostgresYesOrNo;
|
||||
};
|
||||
|
||||
'information_schema.element_types': {
|
||||
object_catalog: string;
|
||||
object_schema: string;
|
||||
object_name: string;
|
||||
object_type: string;
|
||||
collection_type_identifier: string;
|
||||
data_type: string;
|
||||
};
|
||||
};
|
||||
|
||||
type PostgresYesOrNo = 'YES' | 'NO';
|
||||
|
||||
export type ColumnDefaultValue = null | boolean | string | number | object | Date | (() => string);
|
||||
|
||||
export type DatabaseClient = Kysely<PostgresDB>;
|
||||
|
||||
export enum DatabaseConstraintType {
|
||||
PRIMARY_KEY = 'primary-key',
|
||||
FOREIGN_KEY = 'foreign-key',
|
||||
UNIQUE = 'unique',
|
||||
CHECK = 'check',
|
||||
}
|
||||
|
||||
export enum DatabaseActionType {
|
||||
NO_ACTION = 'NO ACTION',
|
||||
RESTRICT = 'RESTRICT',
|
||||
CASCADE = 'CASCADE',
|
||||
SET_NULL = 'SET NULL',
|
||||
SET_DEFAULT = 'SET DEFAULT',
|
||||
}
|
||||
|
||||
export type DatabaseColumnType =
|
||||
| 'bigint'
|
||||
| 'boolean'
|
||||
| 'bytea'
|
||||
| 'character'
|
||||
| 'character varying'
|
||||
| 'date'
|
||||
| 'double precision'
|
||||
| 'integer'
|
||||
| 'jsonb'
|
||||
| 'polygon'
|
||||
| 'text'
|
||||
| 'time'
|
||||
| 'time with time zone'
|
||||
| 'time without time zone'
|
||||
| 'timestamp'
|
||||
| 'timestamp with time zone'
|
||||
| 'timestamp without time zone'
|
||||
| 'uuid'
|
||||
| 'vector'
|
||||
| 'enum'
|
||||
| 'serial';
|
||||
|
||||
export type TableOptions = {
|
||||
name?: string;
|
||||
primaryConstraintName?: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
|
||||
type ColumnBaseOptions = {
|
||||
name?: string;
|
||||
primary?: boolean;
|
||||
type?: DatabaseColumnType;
|
||||
nullable?: boolean;
|
||||
length?: number;
|
||||
default?: ColumnDefaultValue;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
|
||||
export type ColumnOptions = ColumnBaseOptions & {
|
||||
enum?: object;
|
||||
enumName?: string;
|
||||
array?: boolean;
|
||||
unique?: boolean;
|
||||
uniqueConstraintName?: string;
|
||||
};
|
||||
|
||||
export type GenerateColumnOptions = Omit<ColumnOptions, 'type'> & {
|
||||
type?: 'v4' | 'v7';
|
||||
};
|
||||
|
||||
export type ColumnIndexOptions = {
|
||||
name?: string;
|
||||
unique?: boolean;
|
||||
expression?: string;
|
||||
using?: string;
|
||||
where?: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
|
||||
export type IndexOptions = ColumnIndexOptions & {
|
||||
columns?: string[];
|
||||
synchronize?: boolean;
|
||||
};
|
||||
|
||||
export type UniqueOptions = {
|
||||
name?: string;
|
||||
columns: string[];
|
||||
synchronize?: boolean;
|
||||
};
|
||||
|
||||
export type CheckOptions = {
|
||||
name?: string;
|
||||
expression: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
|
||||
export type DatabaseSchema = {
|
||||
name: string;
|
||||
tables: DatabaseTable[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type DatabaseTable = {
|
||||
name: string;
|
||||
columns: DatabaseColumn[];
|
||||
indexes: DatabaseIndex[];
|
||||
constraints: DatabaseConstraint[];
|
||||
synchronize: boolean;
|
||||
};
|
||||
|
||||
export type DatabaseConstraint =
|
||||
| DatabasePrimaryKeyConstraint
|
||||
| DatabaseForeignKeyConstraint
|
||||
| DatabaseUniqueConstraint
|
||||
| DatabaseCheckConstraint;
|
||||
|
||||
export type DatabaseColumn = {
|
||||
primary?: boolean;
|
||||
name: string;
|
||||
tableName: string;
|
||||
|
||||
type: DatabaseColumnType;
|
||||
nullable: boolean;
|
||||
isArray: boolean;
|
||||
synchronize: boolean;
|
||||
|
||||
default?: string;
|
||||
length?: number;
|
||||
|
||||
// enum values
|
||||
enumValues?: string[];
|
||||
enumName?: string;
|
||||
|
||||
// numeric types
|
||||
numericPrecision?: number;
|
||||
numericScale?: number;
|
||||
};
|
||||
|
||||
export type DatabaseColumnChanges = {
|
||||
nullable?: boolean;
|
||||
default?: string;
|
||||
};
|
||||
|
||||
type ColumBasedConstraint = {
|
||||
name: string;
|
||||
tableName: string;
|
||||
columnNames: string[];
|
||||
};
|
||||
|
||||
export type DatabasePrimaryKeyConstraint = ColumBasedConstraint & {
|
||||
type: DatabaseConstraintType.PRIMARY_KEY;
|
||||
synchronize: boolean;
|
||||
};
|
||||
|
||||
export type DatabaseUniqueConstraint = ColumBasedConstraint & {
|
||||
type: DatabaseConstraintType.UNIQUE;
|
||||
synchronize: boolean;
|
||||
};
|
||||
|
||||
export type DatabaseForeignKeyConstraint = ColumBasedConstraint & {
|
||||
type: DatabaseConstraintType.FOREIGN_KEY;
|
||||
referenceTableName: string;
|
||||
referenceColumnNames: string[];
|
||||
onUpdate?: DatabaseActionType;
|
||||
onDelete?: DatabaseActionType;
|
||||
synchronize: boolean;
|
||||
};
|
||||
|
||||
export type DatabaseCheckConstraint = {
|
||||
type: DatabaseConstraintType.CHECK;
|
||||
name: string;
|
||||
tableName: string;
|
||||
expression: string;
|
||||
synchronize: boolean;
|
||||
};
|
||||
|
||||
export type DatabaseIndex = {
|
||||
name: string;
|
||||
tableName: string;
|
||||
columnNames?: string[];
|
||||
expression?: string;
|
||||
unique: boolean;
|
||||
using?: string;
|
||||
where?: string;
|
||||
synchronize: boolean;
|
||||
};
|
||||
|
||||
export type LoadSchemaOptions = {
|
||||
schemaName?: string;
|
||||
};
|
||||
|
||||
export type SchemaDiffToSqlOptions = {
|
||||
comments?: boolean;
|
||||
};
|
||||
|
||||
export type SchemaDiff = { reason: string } & (
|
||||
| { type: 'table.create'; tableName: string; columns: DatabaseColumn[] }
|
||||
| { type: 'table.drop'; tableName: string }
|
||||
| { type: 'column.add'; column: DatabaseColumn }
|
||||
| { type: 'column.alter'; tableName: string; columnName: string; changes: DatabaseColumnChanges }
|
||||
| { type: 'column.drop'; tableName: string; columnName: string }
|
||||
| { type: 'constraint.add'; constraint: DatabaseConstraint }
|
||||
| { type: 'constraint.drop'; tableName: string; constraintName: string }
|
||||
| { type: 'index.create'; index: DatabaseIndex }
|
||||
| { type: 'index.drop'; indexName: string }
|
||||
);
|
||||
|
||||
type Action = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION';
|
||||
export type ForeignKeyColumnOptions = ColumnBaseOptions & {
|
||||
onUpdate?: Action;
|
||||
onDelete?: Action;
|
||||
constraintName?: string;
|
||||
unique?: boolean;
|
||||
uniqueConstraintName?: string;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue