feat: schema diff sql tools (#17116)

This commit is contained in:
Jason Rasmussen 2025-03-28 10:40:09 -04:00 committed by GitHub
parent 3fde5a8328
commit 4b4bcd23f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
132 changed files with 5837 additions and 1246 deletions

View 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;
};

View file

@ -0,0 +1 @@
export * from 'src/sql-tools/public_api';

View 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';

View 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`]);
});
});
});

View 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}";`;
};

View 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)',
},
]);
});
});
});

View 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 },
];
};

View 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();
};

View 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);
});
}
});
});

View 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,
});
}
};

View 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;
};