mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat: extension, triggers, functions, comments, parameters management in sql-tools (#17269)
feat: sql-tools extension, triggers, functions, comments, parameters
This commit is contained in:
parent
51c2c60231
commit
e7a5b96ed0
170 changed files with 5205 additions and 2295 deletions
|
|
@ -1,107 +0,0 @@
|
|||
/* 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;
|
||||
};
|
||||
81
server/src/sql-tools/diff/comparers/column.comparer.spec.ts
Normal file
81
server/src/sql-tools/diff/comparers/column.comparer.spec.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { compareColumns } from 'src/sql-tools/diff/comparers/column.comparer';
|
||||
import { DatabaseColumn, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testColumn: DatabaseColumn = {
|
||||
name: 'test',
|
||||
tableName: 'table1',
|
||||
nullable: false,
|
||||
isArray: false,
|
||||
type: 'character varying',
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareColumns', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareColumns.onExtra(testColumn)).toEqual([
|
||||
{
|
||||
tableName: 'table1',
|
||||
columnName: 'test',
|
||||
type: 'column.drop',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareColumns.onMissing(testColumn)).toEqual([
|
||||
{
|
||||
type: 'column.add',
|
||||
column: testColumn,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareColumns.onCompare(testColumn, testColumn)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect a change in type', () => {
|
||||
const source: DatabaseColumn = { ...testColumn };
|
||||
const target: DatabaseColumn = { ...testColumn, type: 'text' };
|
||||
const reason = 'column type is different (character varying vs text)';
|
||||
expect(compareColumns.onCompare(source, target)).toEqual([
|
||||
{
|
||||
columnName: 'test',
|
||||
tableName: 'table1',
|
||||
type: 'column.drop',
|
||||
reason,
|
||||
},
|
||||
{
|
||||
type: 'column.add',
|
||||
column: source,
|
||||
reason,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should detect a comment change', () => {
|
||||
const source: DatabaseColumn = { ...testColumn, comment: 'new comment' };
|
||||
const target: DatabaseColumn = { ...testColumn, comment: 'old comment' };
|
||||
const reason = 'comment is different (new comment vs old comment)';
|
||||
expect(compareColumns.onCompare(source, target)).toEqual([
|
||||
{
|
||||
columnName: 'test',
|
||||
tableName: 'table1',
|
||||
type: 'column.alter',
|
||||
changes: {
|
||||
comment: 'new comment',
|
||||
},
|
||||
reason,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
82
server/src/sql-tools/diff/comparers/column.comparer.ts
Normal file
82
server/src/sql-tools/diff/comparers/column.comparer.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { getColumnType, isDefaultEqual } from 'src/sql-tools/helpers';
|
||||
import { Comparer, DatabaseColumn, Reason, SchemaDiff } from 'src/sql-tools/types';
|
||||
|
||||
export const compareColumns: Comparer<DatabaseColumn> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'column.add',
|
||||
column: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'column.drop',
|
||||
tableName: target.tableName,
|
||||
columnName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => {
|
||||
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})`,
|
||||
});
|
||||
}
|
||||
|
||||
if (source.comment !== target.comment) {
|
||||
items.push({
|
||||
type: 'column.alter',
|
||||
tableName: source.tableName,
|
||||
columnName: source.name,
|
||||
changes: {
|
||||
comment: String(source.comment),
|
||||
},
|
||||
reason: `comment is different (${source.comment} vs ${target.comment})`,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
};
|
||||
|
||||
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 },
|
||||
];
|
||||
};
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { compareConstraints } from 'src/sql-tools/diff/comparers/constraint.comparer';
|
||||
import { DatabaseConstraint, DatabaseConstraintType, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testConstraint: DatabaseConstraint = {
|
||||
type: DatabaseConstraintType.PRIMARY_KEY,
|
||||
name: 'test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['column1'],
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareConstraints', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareConstraints.onExtra(testConstraint)).toEqual([
|
||||
{
|
||||
type: 'constraint.drop',
|
||||
constraintName: 'test',
|
||||
tableName: 'table1',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareConstraints.onMissing(testConstraint)).toEqual([
|
||||
{
|
||||
type: 'constraint.add',
|
||||
constraint: testConstraint,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareConstraints.onCompare(testConstraint, testConstraint)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect a change in type', () => {
|
||||
const source: DatabaseConstraint = { ...testConstraint };
|
||||
const target: DatabaseConstraint = { ...testConstraint, columnNames: ['column1', 'column2'] };
|
||||
const reason = 'Primary key columns are different: (column1 vs column1,column2)';
|
||||
expect(compareConstraints.onCompare(source, target)).toEqual([
|
||||
{
|
||||
constraintName: 'test',
|
||||
tableName: 'table1',
|
||||
type: 'constraint.drop',
|
||||
reason,
|
||||
},
|
||||
{
|
||||
type: 'constraint.add',
|
||||
constraint: source,
|
||||
reason,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
133
server/src/sql-tools/diff/comparers/constraint.comparer.ts
Normal file
133
server/src/sql-tools/diff/comparers/constraint.comparer.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { haveEqualColumns } from 'src/sql-tools/helpers';
|
||||
import {
|
||||
CompareFunction,
|
||||
Comparer,
|
||||
DatabaseCheckConstraint,
|
||||
DatabaseConstraint,
|
||||
DatabaseConstraintType,
|
||||
DatabaseForeignKeyConstraint,
|
||||
DatabasePrimaryKeyConstraint,
|
||||
DatabaseUniqueConstraint,
|
||||
Reason,
|
||||
SchemaDiff,
|
||||
} from 'src/sql-tools/types';
|
||||
|
||||
export const compareConstraints: Comparer<DatabaseConstraint> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'constraint.add',
|
||||
constraint: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'constraint.drop',
|
||||
tableName: target.tableName,
|
||||
constraintName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => {
|
||||
switch (source.type) {
|
||||
case DatabaseConstraintType.PRIMARY_KEY: {
|
||||
return comparePrimaryKeyConstraint(source, target as DatabasePrimaryKeyConstraint);
|
||||
}
|
||||
|
||||
case DatabaseConstraintType.FOREIGN_KEY: {
|
||||
return compareForeignKeyConstraint(source, target as DatabaseForeignKeyConstraint);
|
||||
}
|
||||
|
||||
case DatabaseConstraintType.UNIQUE: {
|
||||
return compareUniqueConstraint(source, target as DatabaseUniqueConstraint);
|
||||
}
|
||||
|
||||
case DatabaseConstraintType.CHECK: {
|
||||
return compareCheckConstraint(source, target as DatabaseCheckConstraint);
|
||||
}
|
||||
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const comparePrimaryKeyConstraint: CompareFunction<DatabasePrimaryKeyConstraint> = (source, target) => {
|
||||
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
|
||||
return dropAndRecreateConstraint(
|
||||
source,
|
||||
target,
|
||||
`Primary key columns are different: (${source.columnNames} vs ${target.columnNames})`,
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const compareForeignKeyConstraint: CompareFunction<DatabaseForeignKeyConstraint> = (source, target) => {
|
||||
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 compareUniqueConstraint: CompareFunction<DatabaseUniqueConstraint> = (source, target) => {
|
||||
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 compareCheckConstraint: CompareFunction<DatabaseCheckConstraint> = (source, target) => {
|
||||
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 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 },
|
||||
];
|
||||
};
|
||||
54
server/src/sql-tools/diff/comparers/enum.comparer.spec.ts
Normal file
54
server/src/sql-tools/diff/comparers/enum.comparer.spec.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { compareEnums } from 'src/sql-tools/diff/comparers/enum.comparer';
|
||||
import { DatabaseEnum, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testEnum: DatabaseEnum = { name: 'test', values: ['foo', 'bar'], synchronize: true };
|
||||
|
||||
describe('compareEnums', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareEnums.onExtra(testEnum)).toEqual([
|
||||
{
|
||||
enumName: 'test',
|
||||
type: 'enum.drop',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareEnums.onMissing(testEnum)).toEqual([
|
||||
{
|
||||
type: 'enum.create',
|
||||
enum: testEnum,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareEnums.onCompare(testEnum, testEnum)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should drop and recreate when values list is different', () => {
|
||||
const source = { name: 'test', values: ['foo', 'bar'], synchronize: true };
|
||||
const target = { name: 'test', values: ['foo', 'bar', 'world'], synchronize: true };
|
||||
expect(compareEnums.onCompare(source, target)).toEqual([
|
||||
{
|
||||
enumName: 'test',
|
||||
type: 'enum.drop',
|
||||
reason: 'enum values has changed (foo,bar vs foo,bar,world)',
|
||||
},
|
||||
{
|
||||
type: 'enum.create',
|
||||
enum: source,
|
||||
reason: 'enum values has changed (foo,bar vs foo,bar,world)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
38
server/src/sql-tools/diff/comparers/enum.comparer.ts
Normal file
38
server/src/sql-tools/diff/comparers/enum.comparer.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Comparer, DatabaseEnum, Reason } from 'src/sql-tools/types';
|
||||
|
||||
export const compareEnums: Comparer<DatabaseEnum> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'enum.create',
|
||||
enum: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'enum.drop',
|
||||
enumName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => {
|
||||
if (source.values.toString() !== target.values.toString()) {
|
||||
// TODO add or remove values if the lists are different or the order has changed
|
||||
const reason = `enum values has changed (${source.values} vs ${target.values})`;
|
||||
return [
|
||||
{
|
||||
type: 'enum.drop',
|
||||
enumName: source.name,
|
||||
reason,
|
||||
},
|
||||
{
|
||||
type: 'enum.create',
|
||||
enum: source,
|
||||
reason,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { compareExtensions } from 'src/sql-tools/diff/comparers/extension.comparer';
|
||||
import { Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testExtension = { name: 'test', synchronize: true };
|
||||
|
||||
describe('compareExtensions', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareExtensions.onExtra(testExtension)).toEqual([
|
||||
{
|
||||
extensionName: 'test',
|
||||
type: 'extension.drop',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareExtensions.onMissing(testExtension)).toEqual([
|
||||
{
|
||||
type: 'extension.create',
|
||||
extension: testExtension,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareExtensions.onCompare(testExtension, testExtension)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
22
server/src/sql-tools/diff/comparers/extension.comparer.ts
Normal file
22
server/src/sql-tools/diff/comparers/extension.comparer.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Comparer, DatabaseExtension, Reason } from 'src/sql-tools/types';
|
||||
|
||||
export const compareExtensions: Comparer<DatabaseExtension> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'extension.create',
|
||||
extension: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'extension.drop',
|
||||
extensionName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: () => {
|
||||
// if the name matches they are the same
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { compareFunctions } from 'src/sql-tools/diff/comparers/function.comparer';
|
||||
import { DatabaseFunction, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testFunction: DatabaseFunction = {
|
||||
name: 'test',
|
||||
expression: 'CREATE FUNCTION something something something',
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareFunctions', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareFunctions.onExtra(testFunction)).toEqual([
|
||||
{
|
||||
functionName: 'test',
|
||||
type: 'function.drop',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareFunctions.onMissing(testFunction)).toEqual([
|
||||
{
|
||||
type: 'function.create',
|
||||
function: testFunction,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should ignore functions with the same hash', () => {
|
||||
expect(compareFunctions.onCompare(testFunction, testFunction)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should report differences if functions have different hashes', () => {
|
||||
const source: DatabaseFunction = { ...testFunction, expression: 'SELECT 1' };
|
||||
const target: DatabaseFunction = { ...testFunction, expression: 'SELECT 2' };
|
||||
expect(compareFunctions.onCompare(source, target)).toEqual([
|
||||
{
|
||||
type: 'function.create',
|
||||
reason: 'function expression has changed (SELECT 1 vs SELECT 2)',
|
||||
function: source,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
32
server/src/sql-tools/diff/comparers/function.comparer.ts
Normal file
32
server/src/sql-tools/diff/comparers/function.comparer.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Comparer, DatabaseFunction, Reason } from 'src/sql-tools/types';
|
||||
|
||||
export const compareFunctions: Comparer<DatabaseFunction> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'function.create',
|
||||
function: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'function.drop',
|
||||
functionName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => {
|
||||
if (source.expression !== target.expression) {
|
||||
const reason = `function expression has changed (${source.expression} vs ${target.expression})`;
|
||||
return [
|
||||
{
|
||||
type: 'function.create',
|
||||
function: source,
|
||||
reason,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
||||
72
server/src/sql-tools/diff/comparers/index.comparer.spec.ts
Normal file
72
server/src/sql-tools/diff/comparers/index.comparer.spec.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { compareIndexes } from 'src/sql-tools/diff/comparers/index.comparer';
|
||||
import { DatabaseIndex, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testIndex: DatabaseIndex = {
|
||||
name: 'test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['column1', 'column2'],
|
||||
unique: false,
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareIndexes', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareIndexes.onExtra(testIndex)).toEqual([
|
||||
{
|
||||
type: 'index.drop',
|
||||
indexName: 'test',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareIndexes.onMissing(testIndex)).toEqual([
|
||||
{
|
||||
type: 'index.create',
|
||||
index: testIndex,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareIndexes.onCompare(testIndex, testIndex)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should drop and recreate when column list is different', () => {
|
||||
const source = {
|
||||
name: 'test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['column1'],
|
||||
unique: true,
|
||||
synchronize: true,
|
||||
};
|
||||
const target = {
|
||||
name: 'test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['column1', 'column2'],
|
||||
unique: true,
|
||||
synchronize: true,
|
||||
};
|
||||
expect(compareIndexes.onCompare(source, target)).toEqual([
|
||||
{
|
||||
indexName: 'test',
|
||||
type: 'index.drop',
|
||||
reason: 'columns are different (column1 vs column1,column2)',
|
||||
},
|
||||
{
|
||||
type: 'index.create',
|
||||
index: source,
|
||||
reason: 'columns are different (column1 vs column1,column2)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
server/src/sql-tools/diff/comparers/index.comparer.ts
Normal file
46
server/src/sql-tools/diff/comparers/index.comparer.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { haveEqualColumns } from 'src/sql-tools/helpers';
|
||||
import { Comparer, DatabaseIndex, Reason } from 'src/sql-tools/types';
|
||||
|
||||
export const compareIndexes: Comparer<DatabaseIndex> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'index.create',
|
||||
index: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'index.drop',
|
||||
indexName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => {
|
||||
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 [
|
||||
{ type: 'index.drop', indexName: target.name, reason },
|
||||
{ type: 'index.create', index: source, reason },
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { compareParameters } from 'src/sql-tools/diff/comparers/parameter.comparer';
|
||||
import { DatabaseParameter, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testParameter: DatabaseParameter = {
|
||||
name: 'test',
|
||||
databaseName: 'immich',
|
||||
value: 'on',
|
||||
scope: 'database',
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareParameters', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareParameters.onExtra(testParameter)).toEqual([
|
||||
{
|
||||
type: 'parameter.reset',
|
||||
databaseName: 'immich',
|
||||
parameterName: 'test',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareParameters.onMissing(testParameter)).toEqual([
|
||||
{
|
||||
type: 'parameter.set',
|
||||
parameter: testParameter,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareParameters.onCompare(testParameter, testParameter)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
23
server/src/sql-tools/diff/comparers/parameter.comparer.ts
Normal file
23
server/src/sql-tools/diff/comparers/parameter.comparer.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Comparer, DatabaseParameter, Reason } from 'src/sql-tools/types';
|
||||
|
||||
export const compareParameters: Comparer<DatabaseParameter> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'parameter.set',
|
||||
parameter: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'parameter.reset',
|
||||
databaseName: target.databaseName,
|
||||
parameterName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: () => {
|
||||
// TODO
|
||||
return [];
|
||||
},
|
||||
};
|
||||
44
server/src/sql-tools/diff/comparers/table.comparer.spec.ts
Normal file
44
server/src/sql-tools/diff/comparers/table.comparer.spec.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { compareTables } from 'src/sql-tools/diff/comparers/table.comparer';
|
||||
import { DatabaseTable, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testTable: DatabaseTable = {
|
||||
name: 'test',
|
||||
columns: [],
|
||||
constraints: [],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareParameters', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareTables.onExtra(testTable)).toEqual([
|
||||
{
|
||||
type: 'table.drop',
|
||||
tableName: 'test',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareTables.onMissing(testTable)).toEqual([
|
||||
{
|
||||
type: 'table.create',
|
||||
table: testTable,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareTables.onCompare(testTable, testTable)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
59
server/src/sql-tools/diff/comparers/table.comparer.ts
Normal file
59
server/src/sql-tools/diff/comparers/table.comparer.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { compareColumns } from 'src/sql-tools/diff/comparers/column.comparer';
|
||||
import { compareConstraints } from 'src/sql-tools/diff/comparers/constraint.comparer';
|
||||
import { compareIndexes } from 'src/sql-tools/diff/comparers/index.comparer';
|
||||
import { compareTriggers } from 'src/sql-tools/diff/comparers/trigger.comparer';
|
||||
import { compare } from 'src/sql-tools/helpers';
|
||||
import { Comparer, DatabaseTable, Reason, SchemaDiff } from 'src/sql-tools/types';
|
||||
|
||||
export const compareTables: Comparer<DatabaseTable> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'table.create',
|
||||
table: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
// TODO merge constraints into table create record when possible
|
||||
...compareTable(
|
||||
source,
|
||||
{
|
||||
name: source.name,
|
||||
columns: [],
|
||||
indexes: [],
|
||||
constraints: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
},
|
||||
|
||||
{ columns: false },
|
||||
),
|
||||
],
|
||||
onExtra: (target) => [
|
||||
...compareTable(
|
||||
{
|
||||
name: target.name,
|
||||
columns: [],
|
||||
indexes: [],
|
||||
constraints: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
},
|
||||
target,
|
||||
{ columns: false },
|
||||
),
|
||||
{
|
||||
type: 'table.drop',
|
||||
tableName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => compareTable(source, target, { columns: true }),
|
||||
};
|
||||
|
||||
const compareTable = (source: DatabaseTable, target: DatabaseTable, options: { columns?: boolean }): SchemaDiff[] => {
|
||||
return [
|
||||
...(options.columns ? compare(source.columns, target.columns, {}, compareColumns) : []),
|
||||
...compare(source.indexes, target.indexes, {}, compareIndexes),
|
||||
...compare(source.constraints, target.constraints, {}, compareConstraints),
|
||||
...compare(source.triggers, target.triggers, {}, compareTriggers),
|
||||
];
|
||||
};
|
||||
88
server/src/sql-tools/diff/comparers/trigger.comparer.spec.ts
Normal file
88
server/src/sql-tools/diff/comparers/trigger.comparer.spec.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { compareTriggers } from 'src/sql-tools/diff/comparers/trigger.comparer';
|
||||
import { DatabaseTrigger, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testTrigger: DatabaseTrigger = {
|
||||
name: 'test',
|
||||
tableName: 'table1',
|
||||
timing: 'before',
|
||||
actions: ['delete'],
|
||||
scope: 'row',
|
||||
functionName: 'my_trigger_function',
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareTriggers', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareTriggers.onExtra(testTrigger)).toEqual([
|
||||
{
|
||||
type: 'trigger.drop',
|
||||
tableName: 'table1',
|
||||
triggerName: 'test',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareTriggers.onMissing(testTrigger)).toEqual([
|
||||
{
|
||||
type: 'trigger.create',
|
||||
trigger: testTrigger,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareTriggers.onCompare(testTrigger, testTrigger)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect a change in function name', () => {
|
||||
const source: DatabaseTrigger = { ...testTrigger, functionName: 'my_new_name' };
|
||||
const target: DatabaseTrigger = { ...testTrigger, functionName: 'my_old_name' };
|
||||
const reason = `function is different (my_new_name vs my_old_name)`;
|
||||
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
|
||||
});
|
||||
|
||||
it('should detect a change in actions', () => {
|
||||
const source: DatabaseTrigger = { ...testTrigger, actions: ['delete'] };
|
||||
const target: DatabaseTrigger = { ...testTrigger, actions: ['delete', 'insert'] };
|
||||
const reason = `action is different (delete vs delete,insert)`;
|
||||
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
|
||||
});
|
||||
|
||||
it('should detect a change in timing', () => {
|
||||
const source: DatabaseTrigger = { ...testTrigger, timing: 'before' };
|
||||
const target: DatabaseTrigger = { ...testTrigger, timing: 'after' };
|
||||
const reason = `timing method is different (before vs after)`;
|
||||
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
|
||||
});
|
||||
|
||||
it('should detect a change in scope', () => {
|
||||
const source: DatabaseTrigger = { ...testTrigger, scope: 'row' };
|
||||
const target: DatabaseTrigger = { ...testTrigger, scope: 'statement' };
|
||||
const reason = `scope is different (row vs statement)`;
|
||||
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
|
||||
});
|
||||
|
||||
it('should detect a change in new table reference', () => {
|
||||
const source: DatabaseTrigger = { ...testTrigger, referencingNewTableAs: 'new_table' };
|
||||
const target: DatabaseTrigger = { ...testTrigger, referencingNewTableAs: undefined };
|
||||
const reason = `new table reference is different (new_table vs undefined)`;
|
||||
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
|
||||
});
|
||||
|
||||
it('should detect a change in old table reference', () => {
|
||||
const source: DatabaseTrigger = { ...testTrigger, referencingOldTableAs: 'old_table' };
|
||||
const target: DatabaseTrigger = { ...testTrigger, referencingOldTableAs: undefined };
|
||||
const reason = `old table reference is different (old_table vs undefined)`;
|
||||
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
41
server/src/sql-tools/diff/comparers/trigger.comparer.ts
Normal file
41
server/src/sql-tools/diff/comparers/trigger.comparer.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Comparer, DatabaseTrigger, Reason } from 'src/sql-tools/types';
|
||||
|
||||
export const compareTriggers: Comparer<DatabaseTrigger> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'trigger.create',
|
||||
trigger: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'trigger.drop',
|
||||
tableName: target.tableName,
|
||||
triggerName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => {
|
||||
let reason = '';
|
||||
if (source.functionName !== target.functionName) {
|
||||
reason = `function is different (${source.functionName} vs ${target.functionName})`;
|
||||
} else if (source.actions.join(' OR ') !== target.actions.join(' OR ')) {
|
||||
reason = `action is different (${source.actions} vs ${target.actions})`;
|
||||
} else if (source.timing !== target.timing) {
|
||||
reason = `timing method is different (${source.timing} vs ${target.timing})`;
|
||||
} else if (source.scope !== target.scope) {
|
||||
reason = `scope is different (${source.scope} vs ${target.scope})`;
|
||||
} else if (source.referencingNewTableAs !== target.referencingNewTableAs) {
|
||||
reason = `new table reference is different (${source.referencingNewTableAs} vs ${target.referencingNewTableAs})`;
|
||||
} else if (source.referencingOldTableAs !== target.referencingOldTableAs) {
|
||||
reason = `old table reference is different (${source.referencingOldTableAs} vs ${target.referencingOldTableAs})`;
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
return [{ type: 'trigger.create', trigger: source, reason }];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { schemaDiff } from 'src/sql-tools/schema-diff';
|
||||
import { schemaDiff } from 'src/sql-tools/diff';
|
||||
import {
|
||||
ColumnType,
|
||||
DatabaseActionType,
|
||||
DatabaseColumn,
|
||||
DatabaseColumnType,
|
||||
DatabaseConstraint,
|
||||
DatabaseConstraintType,
|
||||
DatabaseIndex,
|
||||
|
|
@ -15,7 +15,12 @@ const fromColumn = (column: Partial<Omit<DatabaseColumn, 'tableName'>>): Databas
|
|||
const tableName = 'table1';
|
||||
|
||||
return {
|
||||
name: 'public',
|
||||
name: 'postgres',
|
||||
schemaName: 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
tables: [
|
||||
{
|
||||
name: tableName,
|
||||
|
|
@ -31,6 +36,7 @@ const fromColumn = (column: Partial<Omit<DatabaseColumn, 'tableName'>>): Databas
|
|||
},
|
||||
],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
constraints: [],
|
||||
synchronize: true,
|
||||
},
|
||||
|
|
@ -43,7 +49,12 @@ const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => {
|
|||
const tableName = constraint?.tableName || 'table1';
|
||||
|
||||
return {
|
||||
name: 'public',
|
||||
name: 'postgres',
|
||||
schemaName: 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
tables: [
|
||||
{
|
||||
name: tableName,
|
||||
|
|
@ -58,6 +69,7 @@ const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => {
|
|||
},
|
||||
],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
constraints: constraint ? [constraint] : [],
|
||||
synchronize: true,
|
||||
},
|
||||
|
|
@ -70,7 +82,12 @@ const fromIndex = (index?: DatabaseIndex): DatabaseSchema => {
|
|||
const tableName = index?.tableName || 'table1';
|
||||
|
||||
return {
|
||||
name: 'public',
|
||||
name: 'postgres',
|
||||
schemaName: 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
tables: [
|
||||
{
|
||||
name: tableName,
|
||||
|
|
@ -86,6 +103,7 @@ const fromIndex = (index?: DatabaseIndex): DatabaseSchema => {
|
|||
],
|
||||
indexes: index ? [index] : [],
|
||||
constraints: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
|
|
@ -99,7 +117,7 @@ const newSchema = (schema: {
|
|||
name: string;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
type?: DatabaseColumnType;
|
||||
type?: ColumnType;
|
||||
nullable?: boolean;
|
||||
isArray?: boolean;
|
||||
}>;
|
||||
|
|
@ -131,12 +149,18 @@ const newSchema = (schema: {
|
|||
columns,
|
||||
indexes: table.indexes ?? [],
|
||||
constraints: table.constraints ?? [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: schema?.name || 'public',
|
||||
name: 'immich',
|
||||
schemaName: schema?.name || 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
tables,
|
||||
warnings: [],
|
||||
};
|
||||
|
|
@ -167,8 +191,14 @@ describe('schemaDiff', () => {
|
|||
expect(diff.items).toHaveLength(1);
|
||||
expect(diff.items[0]).toEqual({
|
||||
type: 'table.create',
|
||||
tableName: 'table1',
|
||||
columns: [column],
|
||||
table: {
|
||||
name: 'table1',
|
||||
columns: [column],
|
||||
constraints: [],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'missing in target',
|
||||
});
|
||||
});
|
||||
|
|
@ -181,7 +211,7 @@ describe('schemaDiff', () => {
|
|||
newSchema({
|
||||
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
|
||||
}),
|
||||
{ ignoreExtraTables: false },
|
||||
{ tables: { ignoreExtra: false } },
|
||||
);
|
||||
|
||||
expect(diff.items).toHaveLength(1);
|
||||
85
server/src/sql-tools/diff/index.ts
Normal file
85
server/src/sql-tools/diff/index.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { compareEnums } from 'src/sql-tools/diff/comparers/enum.comparer';
|
||||
import { compareExtensions } from 'src/sql-tools/diff/comparers/extension.comparer';
|
||||
import { compareFunctions } from 'src/sql-tools/diff/comparers/function.comparer';
|
||||
import { compareParameters } from 'src/sql-tools/diff/comparers/parameter.comparer';
|
||||
import { compareTables } from 'src/sql-tools/diff/comparers/table.comparer';
|
||||
import { compare } from 'src/sql-tools/helpers';
|
||||
import { schemaDiffToSql } from 'src/sql-tools/to-sql';
|
||||
import {
|
||||
DatabaseConstraintType,
|
||||
DatabaseSchema,
|
||||
SchemaDiff,
|
||||
SchemaDiffOptions,
|
||||
SchemaDiffToSqlOptions,
|
||||
} from 'src/sql-tools/types';
|
||||
|
||||
/**
|
||||
* Compute the difference between two database schemas
|
||||
*/
|
||||
export const schemaDiff = (source: DatabaseSchema, target: DatabaseSchema, options: SchemaDiffOptions = {}) => {
|
||||
const items = [
|
||||
...compare(source.parameters, target.parameters, options.parameters, compareParameters),
|
||||
...compare(source.extensions, target.extensions, options.extension, compareExtensions),
|
||||
...compare(source.functions, target.functions, options.functions, compareFunctions),
|
||||
...compare(source.enums, target.enums, options.enums, compareEnums),
|
||||
...compare(source.tables, target.tables, options.tables, compareTables),
|
||||
];
|
||||
|
||||
type SchemaName = SchemaDiff['type'];
|
||||
const itemMap: Record<SchemaName, SchemaDiff[]> = {
|
||||
'enum.create': [],
|
||||
'enum.drop': [],
|
||||
'extension.create': [],
|
||||
'extension.drop': [],
|
||||
'function.create': [],
|
||||
'function.drop': [],
|
||||
'table.create': [],
|
||||
'table.drop': [],
|
||||
'column.add': [],
|
||||
'column.alter': [],
|
||||
'column.drop': [],
|
||||
'constraint.add': [],
|
||||
'constraint.drop': [],
|
||||
'index.create': [],
|
||||
'index.drop': [],
|
||||
'trigger.create': [],
|
||||
'trigger.drop': [],
|
||||
'parameter.set': [],
|
||||
'parameter.reset': [],
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
itemMap[item.type].push(item);
|
||||
}
|
||||
|
||||
const constraintAdds = itemMap['constraint.add'].filter((item) => item.type === 'constraint.add');
|
||||
|
||||
const orderedItems = [
|
||||
...itemMap['extension.create'],
|
||||
...itemMap['function.create'],
|
||||
...itemMap['parameter.set'],
|
||||
...itemMap['parameter.reset'],
|
||||
...itemMap['enum.create'],
|
||||
...itemMap['trigger.drop'],
|
||||
...itemMap['index.drop'],
|
||||
...itemMap['constraint.drop'],
|
||||
...itemMap['table.create'],
|
||||
...itemMap['column.alter'],
|
||||
...itemMap['column.add'],
|
||||
...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.PRIMARY_KEY),
|
||||
...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.FOREIGN_KEY),
|
||||
...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.UNIQUE),
|
||||
...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.CHECK),
|
||||
...itemMap['index.create'],
|
||||
...itemMap['trigger.create'],
|
||||
...itemMap['column.drop'],
|
||||
...itemMap['table.drop'],
|
||||
...itemMap['enum.drop'],
|
||||
...itemMap['function.drop'],
|
||||
];
|
||||
|
||||
return {
|
||||
items: orderedItems,
|
||||
asSql: (options?: SchemaDiffToSqlOptions) => schemaDiffToSql(orderedItems, options),
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/from-code/decorators/trigger-function.decorator';
|
||||
|
||||
export const AfterDeleteTrigger = (options: Omit<TriggerFunctionOptions, 'timing' | 'actions'>) =>
|
||||
TriggerFunction({
|
||||
timing: 'after',
|
||||
actions: ['delete'],
|
||||
...options,
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/from-code/decorators/trigger-function.decorator';
|
||||
|
||||
export const BeforeUpdateTrigger = (options: Omit<TriggerFunctionOptions, 'timing' | 'actions'>) =>
|
||||
TriggerFunction({
|
||||
timing: 'before',
|
||||
actions: ['update'],
|
||||
...options,
|
||||
});
|
||||
11
server/src/sql-tools/from-code/decorators/check.decorator.ts
Normal file
11
server/src/sql-tools/from-code/decorators/check.decorator.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { register } from 'src/sql-tools/from-code/register';
|
||||
|
||||
export type CheckOptions = {
|
||||
name?: string;
|
||||
expression: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const Check = (options: CheckOptions): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'checkConstraint', item: { object, options } });
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asOptions } from 'src/sql-tools/helpers';
|
||||
|
||||
export type ColumnIndexOptions = {
|
||||
name?: string;
|
||||
unique?: boolean;
|
||||
expression?: string;
|
||||
using?: string;
|
||||
with?: string;
|
||||
where?: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const ColumnIndex = (options: string | ColumnIndexOptions = {}): PropertyDecorator => {
|
||||
return (object: object, propertyName: string | symbol) =>
|
||||
void register({ type: 'columnIndex', item: { object, propertyName, options: asOptions(options) } });
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asOptions } from 'src/sql-tools/helpers';
|
||||
import { ColumnStorage, ColumnType, DatabaseEnum } from 'src/sql-tools/types';
|
||||
|
||||
export type ColumnValue = null | boolean | string | number | object | Date | (() => string);
|
||||
|
||||
export type ColumnBaseOptions = {
|
||||
name?: string;
|
||||
primary?: boolean;
|
||||
type?: ColumnType;
|
||||
nullable?: boolean;
|
||||
length?: number;
|
||||
default?: ColumnValue;
|
||||
comment?: string;
|
||||
synchronize?: boolean;
|
||||
storage?: ColumnStorage;
|
||||
identity?: boolean;
|
||||
};
|
||||
|
||||
export type ColumnOptions = ColumnBaseOptions & {
|
||||
enum?: DatabaseEnum;
|
||||
array?: boolean;
|
||||
unique?: boolean;
|
||||
uniqueConstraintName?: string;
|
||||
};
|
||||
|
||||
export const Column = (options: string | ColumnOptions = {}): PropertyDecorator => {
|
||||
return (object: object, propertyName: string | symbol) =>
|
||||
void register({ type: 'column', item: { object, propertyName, options: asOptions(options) } });
|
||||
};
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { ParameterScope } from 'src/sql-tools/types';
|
||||
|
||||
export type ConfigurationParameterOptions = {
|
||||
name: string;
|
||||
value: ColumnValue;
|
||||
scope: ParameterScope;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const ConfigurationParameter = (options: ConfigurationParameterOptions): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'configurationParameter', item: { object, options } });
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
|
||||
export const CreateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
|
||||
return Column({
|
||||
type: 'timestamp with time zone',
|
||||
default: () => 'now()',
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { register } from 'src/sql-tools/from-code/register';
|
||||
|
||||
export type DatabaseOptions = {
|
||||
name?: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const Database = (options: DatabaseOptions): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'database', item: { object, options } });
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
|
||||
export const DeleteDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
|
||||
return Column({
|
||||
type: 'timestamp with time zone',
|
||||
nullable: true,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asOptions } from 'src/sql-tools/helpers';
|
||||
|
||||
export type ExtensionOptions = {
|
||||
name: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const Extension = (options: string | ExtensionOptions): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'extension', item: { object, options: asOptions(options) } });
|
||||
};
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asOptions } from 'src/sql-tools/helpers';
|
||||
|
||||
export type ExtensionsOptions = {
|
||||
name: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const Extensions = (options: Array<string | ExtensionsOptions>): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => {
|
||||
for (const option of options) {
|
||||
register({ type: 'extension', item: { object, options: asOptions(option) } });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { ColumnBaseOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
|
||||
type Action = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION';
|
||||
|
||||
export type ForeignKeyColumnOptions = ColumnBaseOptions & {
|
||||
onUpdate?: Action;
|
||||
onDelete?: Action;
|
||||
constraintName?: string;
|
||||
unique?: boolean;
|
||||
uniqueConstraintName?: string;
|
||||
};
|
||||
|
||||
export const ForeignKeyColumn = (target: () => object, options: ForeignKeyColumnOptions): PropertyDecorator => {
|
||||
return (object: object, propertyName: string | symbol) => {
|
||||
register({ type: 'foreignKeyColumn', item: { object, propertyName, options, target } });
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Column, ColumnOptions, ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
import { ColumnType } from 'src/sql-tools/types';
|
||||
|
||||
export type GeneratedColumnStrategy = 'uuid' | 'identity';
|
||||
|
||||
export type GenerateColumnOptions = Omit<ColumnOptions, 'type'> & {
|
||||
strategy?: GeneratedColumnStrategy;
|
||||
};
|
||||
|
||||
export const GeneratedColumn = ({ strategy = 'uuid', ...options }: GenerateColumnOptions): PropertyDecorator => {
|
||||
let columnType: ColumnType | undefined;
|
||||
let columnDefault: ColumnValue | undefined;
|
||||
|
||||
switch (strategy) {
|
||||
case 'uuid': {
|
||||
columnType = 'uuid';
|
||||
columnDefault = () => 'uuid_generate_v4()';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'identity': {
|
||||
columnType = 'integer';
|
||||
options.identity = true;
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`Unsupported strategy for @GeneratedColumn ${strategy}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Column({
|
||||
type: columnType,
|
||||
default: columnDefault,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
12
server/src/sql-tools/from-code/decorators/index.decorator.ts
Normal file
12
server/src/sql-tools/from-code/decorators/index.decorator.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { ColumnIndexOptions } from 'src/sql-tools/from-code/decorators/column-index.decorator';
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asOptions } from 'src/sql-tools/helpers';
|
||||
|
||||
export type IndexOptions = ColumnIndexOptions & {
|
||||
columns?: string[];
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const Index = (options: string | IndexOptions = {}): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'index', item: { object, options: asOptions(options) } });
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
|
||||
export const PrimaryColumn = (options: Omit<ColumnOptions, 'primary'> = {}) => Column({ ...options, primary: true });
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { GenerateColumnOptions, GeneratedColumn } from 'src/sql-tools/from-code/decorators/generated-column.decorator';
|
||||
|
||||
export const PrimaryGeneratedColumn = (options: Omit<GenerateColumnOptions, 'primary'> = {}) =>
|
||||
GeneratedColumn({ ...options, primary: true });
|
||||
14
server/src/sql-tools/from-code/decorators/table.decorator.ts
Normal file
14
server/src/sql-tools/from-code/decorators/table.decorator.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asOptions } from 'src/sql-tools/helpers';
|
||||
|
||||
export type TableOptions = {
|
||||
name?: string;
|
||||
primaryConstraintName?: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
|
||||
/** Table comments here */
|
||||
export const Table = (options: string | TableOptions = {}): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'table', item: { object, options: asOptions(options) } });
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { Trigger, TriggerOptions } from 'src/sql-tools/from-code/decorators/trigger.decorator';
|
||||
import { DatabaseFunction } from 'src/sql-tools/types';
|
||||
|
||||
export type TriggerFunctionOptions = Omit<TriggerOptions, 'functionName'> & { function: DatabaseFunction };
|
||||
export const TriggerFunction = (options: TriggerFunctionOptions) =>
|
||||
Trigger({ ...options, functionName: options.function.name });
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { TriggerAction, TriggerScope, TriggerTiming } from 'src/sql-tools/types';
|
||||
|
||||
export type TriggerOptions = {
|
||||
name?: string;
|
||||
timing: TriggerTiming;
|
||||
actions: TriggerAction[];
|
||||
scope: TriggerScope;
|
||||
functionName: string;
|
||||
referencingNewTableAs?: string;
|
||||
referencingOldTableAs?: string;
|
||||
when?: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
|
||||
export const Trigger = (options: TriggerOptions): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'trigger', item: { object, options } });
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { register } from 'src/sql-tools/from-code/register';
|
||||
|
||||
export type UniqueOptions = {
|
||||
name?: string;
|
||||
columns: string[];
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const Unique = (options: UniqueOptions): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'uniqueConstraint', item: { object, options } });
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
|
||||
export const UpdateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
|
||||
return Column({
|
||||
type: 'timestamp with time zone',
|
||||
default: () => 'now()',
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
@ -1,16 +1,21 @@
|
|||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { reset, schemaFromDecorators } from 'src/sql-tools/schema-from-decorators';
|
||||
import { reset, schemaFromCode } from 'src/sql-tools/from-code';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('schemaDiff', () => {
|
||||
describe(schemaFromCode.name, () => {
|
||||
beforeEach(() => {
|
||||
reset();
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(schemaFromDecorators()).toEqual({
|
||||
name: 'public',
|
||||
expect(schemaFromCode()).toEqual({
|
||||
name: 'postgres',
|
||||
schemaName: 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
tables: [],
|
||||
warnings: [],
|
||||
});
|
||||
|
|
@ -24,7 +29,7 @@ describe('schemaDiff', () => {
|
|||
const module = await import(filePath);
|
||||
expect(module.description).toBeDefined();
|
||||
expect(module.schema).toBeDefined();
|
||||
expect(schemaFromDecorators(), module.description).toEqual(module.schema);
|
||||
expect(schemaFromCode(), module.description).toEqual(module.schema);
|
||||
});
|
||||
}
|
||||
});
|
||||
69
server/src/sql-tools/from-code/index.ts
Normal file
69
server/src/sql-tools/from-code/index.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import 'reflect-metadata';
|
||||
import { processCheckConstraints } from 'src/sql-tools/from-code/processors/check-constraint.processor';
|
||||
import { processColumnIndexes } from 'src/sql-tools/from-code/processors/column-index.processor';
|
||||
import { processColumns } from 'src/sql-tools/from-code/processors/column.processor';
|
||||
import { processConfigurationParameters } from 'src/sql-tools/from-code/processors/configuration-parameter.processor';
|
||||
import { processDatabases } from 'src/sql-tools/from-code/processors/database.processor';
|
||||
import { processEnums } from 'src/sql-tools/from-code/processors/enum.processor';
|
||||
import { processExtensions } from 'src/sql-tools/from-code/processors/extension.processor';
|
||||
import { processForeignKeyConstraints } from 'src/sql-tools/from-code/processors/foreign-key-constriant.processor';
|
||||
import { processFunctions } from 'src/sql-tools/from-code/processors/function.processor';
|
||||
import { processIndexes } from 'src/sql-tools/from-code/processors/index.processor';
|
||||
import { processPrimaryKeyConstraints } from 'src/sql-tools/from-code/processors/primary-key-contraint.processor';
|
||||
import { processTables } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { processTriggers } from 'src/sql-tools/from-code/processors/trigger.processor';
|
||||
import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
|
||||
import { processUniqueConstraints } from 'src/sql-tools/from-code/processors/unique-constraint.processor';
|
||||
import { getRegisteredItems, resetRegisteredItems } from 'src/sql-tools/from-code/register';
|
||||
import { DatabaseSchema } from 'src/sql-tools/types';
|
||||
|
||||
let initialized = false;
|
||||
let schema: DatabaseSchema;
|
||||
|
||||
export const reset = () => {
|
||||
initialized = false;
|
||||
resetRegisteredItems();
|
||||
};
|
||||
|
||||
const processors: Processor[] = [
|
||||
processDatabases,
|
||||
processConfigurationParameters,
|
||||
processEnums,
|
||||
processExtensions,
|
||||
processFunctions,
|
||||
processTables,
|
||||
processColumns,
|
||||
processUniqueConstraints,
|
||||
processCheckConstraints,
|
||||
processPrimaryKeyConstraints,
|
||||
processIndexes,
|
||||
processColumnIndexes,
|
||||
processForeignKeyConstraints,
|
||||
processTriggers,
|
||||
];
|
||||
|
||||
export const schemaFromCode = () => {
|
||||
if (!initialized) {
|
||||
const builder: SchemaBuilder = {
|
||||
name: 'postgres',
|
||||
schemaName: 'public',
|
||||
tables: [],
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const items = getRegisteredItems();
|
||||
|
||||
for (const processor of processors) {
|
||||
processor(builder, items);
|
||||
}
|
||||
|
||||
schema = { ...builder, tables: builder.tables.map(({ metadata: _, ...table }) => table) };
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
return schema;
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asCheckConstraintName } from 'src/sql-tools/helpers';
|
||||
import { DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
|
||||
export const processCheckConstraints: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'checkConstraint')) {
|
||||
const table = resolveTable(builder, object);
|
||||
if (!table) {
|
||||
onMissingTable(builder, '@Check', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor';
|
||||
import { onMissingTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asIndexName } from 'src/sql-tools/helpers';
|
||||
|
||||
export const processColumnIndexes: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { object, propertyName, options },
|
||||
} of items.filter((item) => item.type === 'columnIndex')) {
|
||||
const { table, column } = resolveColumn(builder, object, propertyName);
|
||||
if (!table) {
|
||||
onMissingTable(builder, '@ColumnIndex', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!column) {
|
||||
onMissingColumn(builder, `@ColumnIndex`, object, propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
103
server/src/sql-tools/from-code/processors/column.processor.ts
Normal file
103
server/src/sql-tools/from-code/processors/column.processor.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asMetadataKey, asUniqueConstraintName, fromColumnValue } from 'src/sql-tools/helpers';
|
||||
import { DatabaseColumn, DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
|
||||
export const processColumns: Processor = (builder, items) => {
|
||||
for (const {
|
||||
type,
|
||||
item: { object, propertyName, options },
|
||||
} of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) {
|
||||
const table = resolveTable(builder, object.constructor);
|
||||
if (!table) {
|
||||
onMissingTable(builder, type === 'column' ? '@Column' : '@ForeignKeyColumn', object, propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const columnName = options.name ?? String(propertyName);
|
||||
const existingColumn = table.columns.find((column) => column.name === columnName);
|
||||
if (existingColumn) {
|
||||
// TODO log warnings if column name is not unique
|
||||
continue;
|
||||
}
|
||||
|
||||
const tableName = table.name;
|
||||
|
||||
let defaultValue = fromColumnValue(options.default);
|
||||
let nullable = options.nullable ?? false;
|
||||
|
||||
// map `{ default: null }` to `{ nullable: true }`
|
||||
if (defaultValue === null) {
|
||||
nullable = true;
|
||||
defaultValue = undefined;
|
||||
}
|
||||
|
||||
const isEnum = !!(options as ColumnOptions).enum;
|
||||
|
||||
const column: DatabaseColumn = {
|
||||
name: columnName,
|
||||
tableName,
|
||||
primary: options.primary ?? false,
|
||||
default: defaultValue,
|
||||
nullable,
|
||||
isArray: (options as ColumnOptions).array ?? false,
|
||||
length: options.length,
|
||||
type: isEnum ? 'enum' : options.type || 'character varying',
|
||||
enumName: isEnum ? (options as ColumnOptions).enum!.name : undefined,
|
||||
comment: options.comment,
|
||||
storage: options.storage,
|
||||
identity: options.identity,
|
||||
synchronize: options.synchronize ?? true,
|
||||
};
|
||||
|
||||
writeMetadata(object, propertyName, { name: column.name, options });
|
||||
|
||||
table.columns.push(column);
|
||||
|
||||
if (type === 'column' && !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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
type ColumnMetadata = { name: string; options: ColumnOptions };
|
||||
|
||||
export const resolveColumn = (builder: SchemaBuilder, object: object, propertyName: string | symbol) => {
|
||||
const table = resolveTable(builder, object.constructor);
|
||||
if (!table) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const metadata = readMetadata(object, propertyName);
|
||||
if (!metadata) {
|
||||
return { table };
|
||||
}
|
||||
|
||||
const column = table.columns.find((column) => column.name === metadata.name);
|
||||
return { table, column };
|
||||
};
|
||||
|
||||
export const onMissingColumn = (
|
||||
builder: SchemaBuilder,
|
||||
context: string,
|
||||
object: object,
|
||||
propertyName?: symbol | string,
|
||||
) => {
|
||||
const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : '');
|
||||
builder.warnings.push(`[${context}] Unable to find column (${label})`);
|
||||
};
|
||||
|
||||
const METADATA_KEY = asMetadataKey('table-metadata');
|
||||
|
||||
const writeMetadata = (object: object, propertyName: symbol | string, metadata: ColumnMetadata) =>
|
||||
Reflect.defineMetadata(METADATA_KEY, metadata, object, propertyName);
|
||||
|
||||
const readMetadata = (object: object, propertyName: symbol | string): ColumnMetadata | undefined =>
|
||||
Reflect.getMetadata(METADATA_KEY, object, propertyName);
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { fromColumnValue } from 'src/sql-tools/helpers';
|
||||
|
||||
export const processConfigurationParameters: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { options },
|
||||
} of items.filter((item) => item.type === 'configurationParameter')) {
|
||||
builder.parameters.push({
|
||||
databaseName: builder.name,
|
||||
name: options.name,
|
||||
value: fromColumnValue(options.value),
|
||||
scope: options.scope,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asSnakeCase } from 'src/sql-tools/helpers';
|
||||
|
||||
export const processDatabases: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'database')) {
|
||||
builder.name = options.name || asSnakeCase(object.name);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
|
||||
export const processEnums: Processor = (builder, items) => {
|
||||
for (const { item } of items.filter((item) => item.type === 'enum')) {
|
||||
// TODO log warnings if enum name is not unique
|
||||
builder.enums.push(item);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
|
||||
export const processExtensions: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { options },
|
||||
} of items.filter((item) => item.type === 'extension')) {
|
||||
builder.extensions.push({
|
||||
name: options.name,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor';
|
||||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asForeignKeyConstraintName, asRelationKeyConstraintName } from 'src/sql-tools/helpers';
|
||||
import { DatabaseActionType, DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
|
||||
export const processForeignKeyConstraints: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { object, propertyName, options, target },
|
||||
} of items.filter((item) => item.type === 'foreignKeyColumn')) {
|
||||
const { table, column } = resolveColumn(builder, object, propertyName);
|
||||
if (!table) {
|
||||
onMissingTable(builder, '@ForeignKeyColumn', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!column) {
|
||||
// should be impossible since they are pre-created in `column.processor.ts`
|
||||
onMissingColumn(builder, '@ForeignKeyColumn', object, propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const referenceTable = resolveTable(builder, target());
|
||||
if (!referenceTable) {
|
||||
onMissingTable(builder, '@ForeignKeyColumn', object, propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const columnNames = [column.name];
|
||||
const referenceColumns = referenceTable.columns.filter((column) => column.primary);
|
||||
|
||||
// infer FK column type from reference table
|
||||
if (referenceColumns.length === 1) {
|
||||
column.type = referenceColumns[0].type;
|
||||
}
|
||||
|
||||
table.constraints.push({
|
||||
name: options.constraintName || asForeignKeyConstraintName(table.name, columnNames),
|
||||
tableName: table.name,
|
||||
columnNames,
|
||||
type: DatabaseConstraintType.FOREIGN_KEY,
|
||||
referenceTableName: referenceTable.name,
|
||||
referenceColumnNames: referenceColumns.map((column) => column.name),
|
||||
onUpdate: options.onUpdate as DatabaseActionType,
|
||||
onDelete: options.onDelete as DatabaseActionType,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
|
||||
if (options.unique) {
|
||||
table.constraints.push({
|
||||
name: options.uniqueConstraintName || asRelationKeyConstraintName(table.name, columnNames),
|
||||
tableName: table.name,
|
||||
columnNames,
|
||||
type: DatabaseConstraintType.UNIQUE,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
|
||||
export const processFunctions: Processor = (builder, items) => {
|
||||
for (const { item } of items.filter((item) => item.type === 'function')) {
|
||||
// TODO log warnings if function name is not unique
|
||||
builder.functions.push(item);
|
||||
}
|
||||
};
|
||||
27
server/src/sql-tools/from-code/processors/index.processor.ts
Normal file
27
server/src/sql-tools/from-code/processors/index.processor.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asIndexName } from 'src/sql-tools/helpers';
|
||||
|
||||
export const processIndexes: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'index')) {
|
||||
const table = resolveTable(builder, object);
|
||||
if (!table) {
|
||||
onMissingTable(builder, '@Check', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
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,
|
||||
with: options.with,
|
||||
where: options.where,
|
||||
columnNames: options.columns,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asPrimaryKeyConstraintName } from 'src/sql-tools/helpers';
|
||||
import { DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
|
||||
export const processPrimaryKeyConstraints: Processor = (builder) => {
|
||||
for (const table of builder.tables) {
|
||||
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.metadata.options.primaryConstraintName || asPrimaryKeyConstraintName(table.name, columnNames),
|
||||
tableName: table.name,
|
||||
columnNames,
|
||||
synchronize: table.metadata.options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
51
server/src/sql-tools/from-code/processors/table.processor.ts
Normal file
51
server/src/sql-tools/from-code/processors/table.processor.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator';
|
||||
import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asMetadataKey, asSnakeCase } from 'src/sql-tools/helpers';
|
||||
|
||||
export const processTables: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { options, object },
|
||||
} of items.filter((item) => item.type === 'table')) {
|
||||
const tableName = options.name || asSnakeCase(object.name);
|
||||
|
||||
writeMetadata(object, { name: tableName, options });
|
||||
|
||||
builder.tables.push({
|
||||
name: tableName,
|
||||
columns: [],
|
||||
constraints: [],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
synchronize: options.synchronize ?? true,
|
||||
metadata: { options, object },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveTable = (builder: SchemaBuilder, object: object) => {
|
||||
const metadata = readMetadata(object);
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
return builder.tables.find((table) => table.name === metadata.name);
|
||||
};
|
||||
|
||||
export const onMissingTable = (
|
||||
builder: SchemaBuilder,
|
||||
context: string,
|
||||
object: object,
|
||||
propertyName?: symbol | string,
|
||||
) => {
|
||||
const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : '');
|
||||
builder.warnings.push(`[${context}] Unable to find table (${label})`);
|
||||
};
|
||||
|
||||
const METADATA_KEY = asMetadataKey('table-metadata');
|
||||
|
||||
type TableMetadata = { name: string; options: TableOptions };
|
||||
|
||||
const readMetadata = (object: object): TableMetadata | undefined => Reflect.getMetadata(METADATA_KEY, object);
|
||||
|
||||
const writeMetadata = (object: object, metadata: TableMetadata): void =>
|
||||
Reflect.defineMetadata(METADATA_KEY, metadata, object);
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asTriggerName } from 'src/sql-tools/helpers';
|
||||
|
||||
export const processTriggers: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'trigger')) {
|
||||
const table = resolveTable(builder, object);
|
||||
if (!table) {
|
||||
onMissingTable(builder, '@Trigger', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
table.triggers.push({
|
||||
name: options.name || asTriggerName(table.name, options),
|
||||
tableName: table.name,
|
||||
timing: options.timing,
|
||||
actions: options.actions,
|
||||
when: options.when,
|
||||
scope: options.scope,
|
||||
referencingNewTableAs: options.referencingNewTableAs,
|
||||
referencingOldTableAs: options.referencingOldTableAs,
|
||||
functionName: options.functionName,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
||||
9
server/src/sql-tools/from-code/processors/type.ts
Normal file
9
server/src/sql-tools/from-code/processors/type.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator';
|
||||
import { RegisterItem } from 'src/sql-tools/from-code/register-item';
|
||||
import { DatabaseSchema, DatabaseTable } from 'src/sql-tools/types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
export type TableWithMetadata = DatabaseTable & { metadata: { options: TableOptions; object: Function } };
|
||||
export type SchemaBuilder = Omit<DatabaseSchema, 'tables'> & { tables: TableWithMetadata[] };
|
||||
|
||||
export type Processor = (builder: SchemaBuilder, items: RegisterItem[]) => void;
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asUniqueConstraintName } from 'src/sql-tools/helpers';
|
||||
import { DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
|
||||
export const processUniqueConstraints: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'uniqueConstraint')) {
|
||||
const table = resolveTable(builder, object);
|
||||
if (!table) {
|
||||
onMissingTable(builder, '@Unique', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
20
server/src/sql-tools/from-code/register-enum.ts
Normal file
20
server/src/sql-tools/from-code/register-enum.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { DatabaseEnum } from 'src/sql-tools/types';
|
||||
|
||||
export type EnumOptions = {
|
||||
name: string;
|
||||
values: string[];
|
||||
synchronize?: boolean;
|
||||
};
|
||||
|
||||
export const registerEnum = (options: EnumOptions) => {
|
||||
const item: DatabaseEnum = {
|
||||
name: options.name,
|
||||
values: options.values,
|
||||
synchronize: options.synchronize ?? true,
|
||||
};
|
||||
|
||||
register({ type: 'enum', item });
|
||||
|
||||
return item;
|
||||
};
|
||||
29
server/src/sql-tools/from-code/register-function.ts
Normal file
29
server/src/sql-tools/from-code/register-function.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asFunctionExpression } from 'src/sql-tools/helpers';
|
||||
import { ColumnType, DatabaseFunction } from 'src/sql-tools/types';
|
||||
|
||||
export type FunctionOptions = {
|
||||
name: string;
|
||||
arguments?: string[];
|
||||
returnType: ColumnType | string;
|
||||
language?: 'SQL' | 'PLPGSQL';
|
||||
behavior?: 'immutable' | 'stable' | 'volatile';
|
||||
parallel?: 'safe' | 'unsafe' | 'restricted';
|
||||
strict?: boolean;
|
||||
synchronize?: boolean;
|
||||
} & ({ body: string } | { return: string });
|
||||
|
||||
export const registerFunction = (options: FunctionOptions) => {
|
||||
const name = options.name;
|
||||
const expression = asFunctionExpression(options);
|
||||
|
||||
const item: DatabaseFunction = {
|
||||
name,
|
||||
expression,
|
||||
synchronize: options.synchronize ?? true,
|
||||
};
|
||||
|
||||
register({ type: 'function', item });
|
||||
|
||||
return item;
|
||||
};
|
||||
31
server/src/sql-tools/from-code/register-item.ts
Normal file
31
server/src/sql-tools/from-code/register-item.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { CheckOptions } from 'src/sql-tools/from-code/decorators/check.decorator';
|
||||
import { ColumnIndexOptions } from 'src/sql-tools/from-code/decorators/column-index.decorator';
|
||||
import { ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
import { ConfigurationParameterOptions } from 'src/sql-tools/from-code/decorators/configuration-parameter.decorator';
|
||||
import { DatabaseOptions } from 'src/sql-tools/from-code/decorators/database.decorator';
|
||||
import { ExtensionOptions } from 'src/sql-tools/from-code/decorators/extension.decorator';
|
||||
import { ForeignKeyColumnOptions } from 'src/sql-tools/from-code/decorators/foreign-key-column.decorator';
|
||||
import { IndexOptions } from 'src/sql-tools/from-code/decorators/index.decorator';
|
||||
import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator';
|
||||
import { TriggerOptions } from 'src/sql-tools/from-code/decorators/trigger.decorator';
|
||||
import { UniqueOptions } from 'src/sql-tools/from-code/decorators/unique.decorator';
|
||||
import { DatabaseEnum, DatabaseFunction } from 'src/sql-tools/types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
export type ClassBased<T> = { object: Function } & T;
|
||||
export type PropertyBased<T> = { object: object; propertyName: string | symbol } & T;
|
||||
export type RegisterItem =
|
||||
| { type: 'database'; item: ClassBased<{ options: DatabaseOptions }> }
|
||||
| { 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: 'function'; item: DatabaseFunction }
|
||||
| { type: 'enum'; item: DatabaseEnum }
|
||||
| { type: 'trigger'; item: ClassBased<{ options: TriggerOptions }> }
|
||||
| { type: 'extension'; item: ClassBased<{ options: ExtensionOptions }> }
|
||||
| { type: 'configurationParameter'; item: ClassBased<{ options: ConfigurationParameterOptions }> }
|
||||
| { type: 'foreignKeyColumn'; item: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }> };
|
||||
export type RegisterItemType<T extends RegisterItem['type']> = Extract<RegisterItem, { type: T }>['item'];
|
||||
11
server/src/sql-tools/from-code/register.ts
Normal file
11
server/src/sql-tools/from-code/register.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { RegisterItem } from 'src/sql-tools/from-code/register-item';
|
||||
|
||||
const items: RegisterItem[] = [];
|
||||
|
||||
export const register = (item: RegisterItem) => void items.push(item);
|
||||
|
||||
export const getRegisteredItems = () => items;
|
||||
|
||||
export const resetRegisteredItems = () => {
|
||||
items.length = 0;
|
||||
};
|
||||
|
|
@ -1,16 +1,22 @@
|
|||
import { Kysely, sql } from 'kysely';
|
||||
import { Kysely, QueryResult, sql } from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { Sql } from 'postgres';
|
||||
import { parseTriggerType } from 'src/sql-tools/helpers';
|
||||
import {
|
||||
ColumnType,
|
||||
DatabaseActionType,
|
||||
DatabaseClient,
|
||||
DatabaseColumn,
|
||||
DatabaseColumnType,
|
||||
DatabaseConstraintType,
|
||||
DatabaseEnum,
|
||||
DatabaseExtension,
|
||||
DatabaseFunction,
|
||||
DatabaseParameter,
|
||||
DatabaseSchema,
|
||||
DatabaseTable,
|
||||
LoadSchemaOptions,
|
||||
ParameterScope,
|
||||
PostgresDB,
|
||||
} from 'src/sql-tools/types';
|
||||
|
||||
|
|
@ -28,16 +34,66 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio
|
|||
const schemaName = options.schemaName || 'public';
|
||||
const tablesMap: Record<string, DatabaseTable> = {};
|
||||
|
||||
const [tables, columns, indexes, constraints, enums] = await Promise.all([
|
||||
const [
|
||||
databaseName,
|
||||
tables,
|
||||
columns,
|
||||
indexes,
|
||||
constraints,
|
||||
enums,
|
||||
routines,
|
||||
extensions,
|
||||
triggers,
|
||||
parameters,
|
||||
comments,
|
||||
] = await Promise.all([
|
||||
getDatabaseName(db),
|
||||
getTables(db, schemaName),
|
||||
getTableColumns(db, schemaName),
|
||||
getTableIndexes(db, schemaName),
|
||||
getTableConstraints(db, schemaName),
|
||||
getUserDefinedEnums(db, schemaName),
|
||||
getRoutines(db, schemaName),
|
||||
getExtensions(db),
|
||||
getTriggers(db, schemaName),
|
||||
getParameters(db),
|
||||
getObjectComments(db),
|
||||
]);
|
||||
|
||||
const schemaEnums: DatabaseEnum[] = [];
|
||||
const schemaFunctions: DatabaseFunction[] = [];
|
||||
const schemaExtensions: DatabaseExtension[] = [];
|
||||
const schemaParameters: DatabaseParameter[] = [];
|
||||
|
||||
const enumMap = Object.fromEntries(enums.map((e) => [e.name, e.values]));
|
||||
|
||||
for (const { name } of extensions) {
|
||||
schemaExtensions.push({ name, synchronize: true });
|
||||
}
|
||||
|
||||
for (const { name, values } of enums) {
|
||||
schemaEnums.push({ name, values, synchronize: true });
|
||||
}
|
||||
|
||||
for (const parameter of parameters) {
|
||||
schemaParameters.push({
|
||||
name: parameter.name,
|
||||
value: parameter.value,
|
||||
databaseName,
|
||||
scope: parameter.scope as ParameterScope,
|
||||
synchronize: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const { name, expression } of routines) {
|
||||
schemaFunctions.push({
|
||||
name,
|
||||
// TODO read expression from the overrides table
|
||||
expression,
|
||||
synchronize: true,
|
||||
});
|
||||
}
|
||||
|
||||
// add tables
|
||||
for (const table of tables) {
|
||||
const tableName = table.table_name;
|
||||
|
|
@ -49,6 +105,7 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio
|
|||
name: table.table_name,
|
||||
columns: [],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
constraints: [],
|
||||
synchronize: true,
|
||||
};
|
||||
|
|
@ -64,13 +121,14 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio
|
|||
const columnName = column.column_name;
|
||||
|
||||
const item: DatabaseColumn = {
|
||||
type: column.data_type as DatabaseColumnType,
|
||||
type: column.data_type as ColumnType,
|
||||
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,
|
||||
length: column.character_maximum_length ?? undefined,
|
||||
default: column.column_default ?? undefined,
|
||||
synchronize: true,
|
||||
};
|
||||
|
|
@ -84,7 +142,7 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio
|
|||
warn(`Unable to find type for ${columnLabel} (ARRAY)`);
|
||||
continue;
|
||||
}
|
||||
item.type = column.array_type as DatabaseColumnType;
|
||||
item.type = column.array_type as ColumnType;
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +155,6 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio
|
|||
|
||||
item.type = 'enum';
|
||||
item.enumName = column.udt_name;
|
||||
item.enumValues = enumMap[column.udt_name];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -201,10 +258,50 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio
|
|||
}
|
||||
}
|
||||
|
||||
// add triggers to tables
|
||||
for (const trigger of triggers) {
|
||||
const table = tablesMap[trigger.table_name];
|
||||
if (!table) {
|
||||
continue;
|
||||
}
|
||||
|
||||
table.triggers.push({
|
||||
name: trigger.name,
|
||||
tableName: trigger.table_name,
|
||||
functionName: trigger.function_name,
|
||||
referencingNewTableAs: trigger.referencing_new_table_as ?? undefined,
|
||||
referencingOldTableAs: trigger.referencing_old_table_as ?? undefined,
|
||||
when: trigger.when_expression,
|
||||
synchronize: true,
|
||||
...parseTriggerType(trigger.type),
|
||||
});
|
||||
}
|
||||
|
||||
for (const comment of comments) {
|
||||
if (comment.object_type === 'r') {
|
||||
const table = tablesMap[comment.object_name];
|
||||
if (!table) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (comment.column_name) {
|
||||
const column = table.columns.find(({ name }) => name === comment.column_name);
|
||||
if (column) {
|
||||
column.comment = comment.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.destroy();
|
||||
|
||||
return {
|
||||
name: schemaName,
|
||||
name: databaseName,
|
||||
schemaName,
|
||||
parameters: schemaParameters,
|
||||
functions: schemaFunctions,
|
||||
enums: schemaEnums,
|
||||
extensions: schemaExtensions,
|
||||
tables: Object.values(tablesMap),
|
||||
warnings,
|
||||
};
|
||||
|
|
@ -237,6 +334,11 @@ const asDatabaseAction = (action: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
const getDatabaseName = async (db: DatabaseClient) => {
|
||||
const result = (await sql`SELECT current_database() as name`.execute(db)) as QueryResult<{ name: string }>;
|
||||
return result.rows[0].name;
|
||||
};
|
||||
|
||||
const getTables = (db: DatabaseClient, schemaName: string) => {
|
||||
return db
|
||||
.selectFrom('information_schema.tables')
|
||||
|
|
@ -246,27 +348,6 @@ const getTables = (db: DatabaseClient, schemaName: string) => {
|
|||
.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')
|
||||
|
|
@ -290,6 +371,7 @@ const getTableColumns = (db: DatabaseClient, schemaName: string) => {
|
|||
'c.data_type',
|
||||
'c.column_default',
|
||||
'c.is_nullable',
|
||||
'c.character_maximum_length',
|
||||
|
||||
// number types
|
||||
'c.numeric_precision',
|
||||
|
|
@ -392,3 +474,103 @@ const getTableConstraints = (db: DatabaseClient, schemaName: string) => {
|
|||
.where('pg_namespace.nspname', '=', schemaName)
|
||||
.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 getRoutines = async (db: DatabaseClient, schemaName: string) => {
|
||||
return db
|
||||
.selectFrom('pg_proc as p')
|
||||
.innerJoin('pg_namespace', 'pg_namespace.oid', 'p.pronamespace')
|
||||
.leftJoin('pg_depend as d', (join) => join.onRef('d.objid', '=', 'p.oid').on('d.deptype', '=', sql.lit('e')))
|
||||
.where('d.objid', 'is', sql.lit(null))
|
||||
.where('p.prokind', '=', sql.lit('f'))
|
||||
.where('pg_namespace.nspname', '=', schemaName)
|
||||
.select((eb) => [
|
||||
'p.proname as name',
|
||||
eb.fn<string>('pg_get_function_identity_arguments', ['p.oid']).as('arguments'),
|
||||
eb.fn<string>('pg_get_functiondef', ['p.oid']).as('expression'),
|
||||
])
|
||||
.execute();
|
||||
};
|
||||
|
||||
const getExtensions = async (db: DatabaseClient) => {
|
||||
return (
|
||||
db
|
||||
.selectFrom('pg_catalog.pg_extension')
|
||||
// .innerJoin('pg_namespace', 'pg_namespace.oid', 'pg_catalog.pg_extension.extnamespace')
|
||||
// .where('pg_namespace.nspname', '=', schemaName)
|
||||
.select(['extname as name', 'extversion as version'])
|
||||
.execute()
|
||||
);
|
||||
};
|
||||
|
||||
const getTriggers = async (db: Kysely<PostgresDB>, schemaName: string) => {
|
||||
return db
|
||||
.selectFrom('pg_trigger as t')
|
||||
.innerJoin('pg_proc as p', 't.tgfoid', 'p.oid')
|
||||
.innerJoin('pg_namespace as n', 'p.pronamespace', 'n.oid')
|
||||
.innerJoin('pg_class as c', 't.tgrelid', 'c.oid')
|
||||
.select((eb) => [
|
||||
't.tgname as name',
|
||||
't.tgenabled as enabled',
|
||||
't.tgtype as type',
|
||||
't.tgconstraint as _constraint',
|
||||
't.tgdeferrable as is_deferrable',
|
||||
't.tginitdeferred as is_initially_deferred',
|
||||
't.tgargs as arguments',
|
||||
't.tgoldtable as referencing_old_table_as',
|
||||
't.tgnewtable as referencing_new_table_as',
|
||||
eb.fn<string>('pg_get_expr', ['t.tgqual', 't.tgrelid']).as('when_expression'),
|
||||
'p.proname as function_name',
|
||||
'c.relname as table_name',
|
||||
])
|
||||
.where('t.tgisinternal', '=', false) // Exclude internal system triggers
|
||||
.where('n.nspname', '=', schemaName)
|
||||
.execute();
|
||||
};
|
||||
|
||||
const getParameters = async (db: Kysely<PostgresDB>) => {
|
||||
return db
|
||||
.selectFrom('pg_settings')
|
||||
.where('source', 'in', [sql.lit('database'), sql.lit('user')])
|
||||
.select(['name', 'setting as value', 'source as scope'])
|
||||
.execute();
|
||||
};
|
||||
|
||||
const getObjectComments = async (db: Kysely<PostgresDB>) => {
|
||||
return db
|
||||
.selectFrom('pg_description as d')
|
||||
.innerJoin('pg_class as c', 'd.objoid', 'c.oid')
|
||||
.leftJoin('pg_attribute as a', (join) =>
|
||||
join.onRef('a.attrelid', '=', 'c.oid').onRef('a.attnum', '=', 'd.objsubid'),
|
||||
)
|
||||
.select([
|
||||
'c.relname as object_name',
|
||||
'c.relkind as object_type',
|
||||
'd.description as value',
|
||||
'a.attname as column_name',
|
||||
])
|
||||
.where('d.description', 'is not', null)
|
||||
.orderBy('object_type')
|
||||
.orderBy('object_name')
|
||||
.execute();
|
||||
};
|
||||
268
server/src/sql-tools/helpers.ts
Normal file
268
server/src/sql-tools/helpers.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
import { TriggerOptions } from 'src/sql-tools/from-code/decorators/trigger.decorator';
|
||||
import { FunctionOptions } from 'src/sql-tools/from-code/register-function';
|
||||
import {
|
||||
Comparer,
|
||||
DatabaseColumn,
|
||||
DiffOptions,
|
||||
SchemaDiff,
|
||||
TriggerAction,
|
||||
TriggerScope,
|
||||
TriggerTiming,
|
||||
} from 'src/sql-tools/types';
|
||||
|
||||
export const asMetadataKey = (name: string) => `sql-tools:${name}`;
|
||||
|
||||
export const asSnakeCase = (name: string): string => name.replaceAll(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
|
||||
// match TypeORM
|
||||
export const asKey = (prefix: string, tableName: string, values: string[]) =>
|
||||
(prefix + sha1(`${tableName}_${values.toSorted().join('_')}`)).slice(0, 30);
|
||||
export const asPrimaryKeyConstraintName = (table: string, columns: string[]) => asKey('PK_', table, columns);
|
||||
export const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, columns);
|
||||
export const asTriggerName = (table: string, trigger: TriggerOptions) =>
|
||||
asKey('TR_', table, [...trigger.actions, trigger.scope, trigger.timing, trigger.functionName]);
|
||||
export const asRelationKeyConstraintName = (table: string, columns: string[]) => asKey('REL_', table, columns);
|
||||
export const asUniqueConstraintName = (table: string, columns: string[]) => asKey('UQ_', table, columns);
|
||||
export const asCheckConstraintName = (table: string, expression: string) => asKey('CHK_', table, [expression]);
|
||||
export 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);
|
||||
};
|
||||
|
||||
export const asOptions = <T extends { name?: string }>(options: string | T): T => {
|
||||
if (typeof options === 'string') {
|
||||
return { name: options } as T;
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
export const asFunctionExpression = (options: FunctionOptions) => {
|
||||
const name = options.name;
|
||||
const sql: string[] = [
|
||||
`CREATE OR REPLACE FUNCTION ${name}(${(options.arguments || []).join(', ')})`,
|
||||
`RETURNS ${options.returnType}`,
|
||||
];
|
||||
|
||||
const flags = [
|
||||
options.parallel ? `PARALLEL ${options.parallel.toUpperCase()}` : undefined,
|
||||
options.strict ? 'STRICT' : undefined,
|
||||
options.behavior ? options.behavior.toUpperCase() : undefined,
|
||||
`LANGUAGE ${options.language ?? 'SQL'}`,
|
||||
].filter((x) => x !== undefined);
|
||||
|
||||
if (flags.length > 0) {
|
||||
sql.push(flags.join(' '));
|
||||
}
|
||||
|
||||
if ('return' in options) {
|
||||
sql.push(` RETURN ${options.return}`);
|
||||
}
|
||||
|
||||
if ('body' in options) {
|
||||
sql.push(
|
||||
//
|
||||
`AS $$`,
|
||||
' ' + options.body.trim(),
|
||||
`$$;`,
|
||||
);
|
||||
}
|
||||
|
||||
return sql.join('\n ').trim();
|
||||
};
|
||||
|
||||
export const sha1 = (value: string) => createHash('sha1').update(value).digest('hex');
|
||||
export const hasMask = (input: number, mask: number) => (input & mask) === mask;
|
||||
|
||||
export const parseTriggerType = (type: number) => {
|
||||
// eslint-disable-next-line unicorn/prefer-math-trunc
|
||||
const scope: TriggerScope = hasMask(type, 1 << 0) ? 'row' : 'statement';
|
||||
|
||||
let timing: TriggerTiming = 'after';
|
||||
const timingMasks: Array<{ mask: number; value: TriggerTiming }> = [
|
||||
{ mask: 1 << 1, value: 'before' },
|
||||
{ mask: 1 << 6, value: 'instead of' },
|
||||
];
|
||||
|
||||
for (const { mask, value } of timingMasks) {
|
||||
if (hasMask(type, mask)) {
|
||||
timing = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const actions: TriggerAction[] = [];
|
||||
const actionMasks: Array<{ mask: number; value: TriggerAction }> = [
|
||||
{ mask: 1 << 2, value: 'insert' },
|
||||
{ mask: 1 << 3, value: 'delete' },
|
||||
{ mask: 1 << 4, value: 'update' },
|
||||
{ mask: 1 << 5, value: 'truncate' },
|
||||
];
|
||||
|
||||
for (const { mask, value } of actionMasks) {
|
||||
if (hasMask(type, mask)) {
|
||||
actions.push(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (actions.length === 0) {
|
||||
throw new Error(`Unable to parse trigger type ${type}`);
|
||||
}
|
||||
|
||||
return { actions, timing, scope };
|
||||
};
|
||||
|
||||
export const fromColumnValue = (columnValue?: ColumnValue) => {
|
||||
if (columnValue === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof columnValue === 'function') {
|
||||
return columnValue() as string;
|
||||
}
|
||||
|
||||
const value = columnValue;
|
||||
|
||||
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)}'`;
|
||||
};
|
||||
|
||||
export const setIsEqual = (source: Set<unknown>, target: Set<unknown>) =>
|
||||
source.size === target.size && [...source].every((x) => target.has(x));
|
||||
|
||||
export const haveEqualColumns = (sourceColumns?: string[], targetColumns?: string[]) => {
|
||||
return setIsEqual(new Set(sourceColumns ?? []), new Set(targetColumns ?? []));
|
||||
};
|
||||
|
||||
export const compare = <T extends { name: string; synchronize: boolean }>(
|
||||
sources: T[],
|
||||
targets: T[],
|
||||
options: DiffOptions | undefined,
|
||||
comparer: Comparer<T>,
|
||||
) => {
|
||||
options = options || {};
|
||||
const sourceMap = Object.fromEntries(sources.map((table) => [table.name, table]));
|
||||
const targetMap = Object.fromEntries(targets.map((table) => [table.name, table]));
|
||||
const items: SchemaDiff[] = [];
|
||||
|
||||
const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]);
|
||||
for (const key of keys) {
|
||||
const source = sourceMap[key];
|
||||
const target = targetMap[key];
|
||||
|
||||
if (isIgnored(source, target, options)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSynchronizeDisabled(source, target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source && !target) {
|
||||
items.push(...comparer.onMissing(source));
|
||||
} else if (!source && target) {
|
||||
items.push(...comparer.onExtra(target));
|
||||
} else {
|
||||
items.push(...comparer.onCompare(source, target));
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const isIgnored = (
|
||||
source: { synchronize?: boolean } | undefined,
|
||||
target: { synchronize?: boolean } | undefined,
|
||||
options: DiffOptions,
|
||||
) => {
|
||||
return (options.ignoreExtra && !source) || (options.ignoreMissing && !target);
|
||||
};
|
||||
|
||||
const isSynchronizeDisabled = (source?: { synchronize?: boolean }, target?: { synchronize?: boolean }) => {
|
||||
return source?.synchronize === false || target?.synchronize === false;
|
||||
};
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export const getColumnType = (column: DatabaseColumn) => {
|
||||
let type = column.enumName || column.type;
|
||||
if (column.isArray) {
|
||||
type += `[${column.length ?? ''}]`;
|
||||
} else if (column.length !== undefined) {
|
||||
type += `(${column.length})`;
|
||||
}
|
||||
|
||||
return type;
|
||||
};
|
||||
|
||||
const withTypeCast = (value: string, type: string) => {
|
||||
if (!value.startsWith(`'`)) {
|
||||
value = `'${value}'`;
|
||||
}
|
||||
return `${value}::${type}`;
|
||||
};
|
||||
|
||||
export const getColumnModifiers = (column: DatabaseColumn) => {
|
||||
const modifiers: string[] = [];
|
||||
|
||||
if (!column.nullable) {
|
||||
modifiers.push('NOT NULL');
|
||||
}
|
||||
|
||||
if (column.default) {
|
||||
modifiers.push(`DEFAULT ${column.default}`);
|
||||
}
|
||||
if (column.identity) {
|
||||
modifiers.push(`GENERATED ALWAYS AS IDENTITY`);
|
||||
}
|
||||
|
||||
return modifiers.length === 0 ? '' : ' ' + modifiers.join(' ');
|
||||
};
|
||||
|
||||
export const asColumnComment = (tableName: string, columnName: string, comment: string): string => {
|
||||
return `COMMENT ON COLUMN "${tableName}"."${columnName}" IS '${comment}';`;
|
||||
};
|
||||
|
||||
export const asColumnList = (columns: string[]) => columns.map((column) => `"${column}"`).join(', ');
|
||||
|
|
@ -1,6 +1,28 @@
|
|||
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 { schemaDiff } from 'src/sql-tools/diff';
|
||||
export { schemaFromCode } from 'src/sql-tools/from-code';
|
||||
export * from 'src/sql-tools/from-code/decorators/after-delete.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/before-update.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/check.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/column-index.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/configuration-parameter.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/create-date-column.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/database.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/delete-date-column.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/extension.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/extensions.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/foreign-key-column.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/generated-column.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/index.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/primary-column.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/primary-generated-column.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/table.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/trigger-function.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/trigger.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/unique.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/update-date-column.decorator';
|
||||
export * from 'src/sql-tools/from-code/register-enum';
|
||||
export * from 'src/sql-tools/from-code/register-function';
|
||||
export { schemaFromDatabase } from 'src/sql-tools/from-database';
|
||||
export { schemaDiffToSql } from 'src/sql-tools/to-sql';
|
||||
export * from 'src/sql-tools/types';
|
||||
|
|
|
|||
|
|
@ -1,473 +0,0 @@
|
|||
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`]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
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}";`;
|
||||
};
|
||||
|
|
@ -1,449 +0,0 @@
|
|||
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 },
|
||||
];
|
||||
};
|
||||
|
|
@ -1,443 +0,0 @@
|
|||
/* 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
21
server/src/sql-tools/to-sql/index.spec.ts
Normal file
21
server/src/sql-tools/to-sql/index.spec.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { schemaDiffToSql } from 'src/sql-tools';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe(schemaDiffToSql.name, () => {
|
||||
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`]);
|
||||
});
|
||||
});
|
||||
});
|
||||
59
server/src/sql-tools/to-sql/index.ts
Normal file
59
server/src/sql-tools/to-sql/index.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { transformColumns } from 'src/sql-tools/to-sql/transformers/column.transformer';
|
||||
import { transformConstraints } from 'src/sql-tools/to-sql/transformers/constraint.transformer';
|
||||
import { transformEnums } from 'src/sql-tools/to-sql/transformers/enum.transformer';
|
||||
import { transformExtensions } from 'src/sql-tools/to-sql/transformers/extension.transformer';
|
||||
import { transformFunctions } from 'src/sql-tools/to-sql/transformers/function.transformer';
|
||||
import { transformIndexes } from 'src/sql-tools/to-sql/transformers/index.transformer';
|
||||
import { transformParameters } from 'src/sql-tools/to-sql/transformers/parameter.transformer';
|
||||
import { transformTables } from 'src/sql-tools/to-sql/transformers/table.transformer';
|
||||
import { transformTriggers } from 'src/sql-tools/to-sql/transformers/trigger.transformer';
|
||||
import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types';
|
||||
import { SchemaDiff, SchemaDiffToSqlOptions } from 'src/sql-tools/types';
|
||||
|
||||
/**
|
||||
* Convert schema diffs into SQL statements
|
||||
*/
|
||||
export const schemaDiffToSql = (items: SchemaDiff[], options: SchemaDiffToSqlOptions = {}): string[] => {
|
||||
return items.flatMap((item) => asSql(item).map((result) => result + withComments(options.comments, item)));
|
||||
};
|
||||
|
||||
const transformers: SqlTransformer[] = [
|
||||
transformColumns,
|
||||
transformConstraints,
|
||||
transformEnums,
|
||||
transformExtensions,
|
||||
transformFunctions,
|
||||
transformIndexes,
|
||||
transformParameters,
|
||||
transformTables,
|
||||
transformTriggers,
|
||||
];
|
||||
|
||||
const asSql = (item: SchemaDiff): string[] => {
|
||||
for (const transform of transformers) {
|
||||
const result = transform(item);
|
||||
if (!result) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return asArray(result);
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled schema diff type: ${item.type}`);
|
||||
};
|
||||
|
||||
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];
|
||||
};
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { transformColumns } from 'src/sql-tools/to-sql/transformers/column.transformer';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe(transformColumns.name, () => {
|
||||
describe('column.add', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformColumns({
|
||||
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(
|
||||
transformColumns({
|
||||
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(
|
||||
transformColumns({
|
||||
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(
|
||||
transformColumns({
|
||||
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(
|
||||
transformColumns({
|
||||
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(
|
||||
transformColumns({
|
||||
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(
|
||||
transformColumns({
|
||||
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(
|
||||
transformColumns({
|
||||
type: 'column.drop',
|
||||
tableName: 'table1',
|
||||
columnName: 'column1',
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual(`ALTER TABLE "table1" DROP COLUMN "column1";`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { asColumnComment, getColumnModifiers, getColumnType } from 'src/sql-tools/helpers';
|
||||
import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types';
|
||||
import { ColumnChanges, DatabaseColumn, SchemaDiff } from 'src/sql-tools/types';
|
||||
|
||||
export const transformColumns: SqlTransformer = (item: SchemaDiff) => {
|
||||
switch (item.type) {
|
||||
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);
|
||||
}
|
||||
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const asColumnAdd = (column: DatabaseColumn): string => {
|
||||
return (
|
||||
`ALTER TABLE "${column.tableName}" ADD "${column.name}" ${getColumnType(column)}` + getColumnModifiers(column) + ';'
|
||||
);
|
||||
};
|
||||
|
||||
const asColumnDrop = (tableName: string, columnName: string): string => {
|
||||
return `ALTER TABLE "${tableName}" DROP COLUMN "${columnName}";`;
|
||||
};
|
||||
|
||||
export const asColumnAlter = (tableName: string, columnName: string, changes: ColumnChanges): 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};`);
|
||||
}
|
||||
|
||||
if (changes.storage !== undefined) {
|
||||
items.push(`${base} SET STORAGE ${changes.storage.toUpperCase()};`);
|
||||
}
|
||||
|
||||
if (changes.comment !== undefined) {
|
||||
items.push(asColumnComment(tableName, columnName, changes.comment));
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { transformConstraints } from 'src/sql-tools/to-sql/transformers/constraint.transformer';
|
||||
import { DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe(transformConstraints.name, () => {
|
||||
describe('constraint.add', () => {
|
||||
describe('primary keys', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformConstraints({
|
||||
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(
|
||||
transformConstraints({
|
||||
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(
|
||||
transformConstraints({
|
||||
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(
|
||||
transformConstraints({
|
||||
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(
|
||||
transformConstraints({
|
||||
type: 'constraint.drop',
|
||||
tableName: 'table1',
|
||||
constraintName: 'PK_test',
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual(`ALTER TABLE "table1" DROP CONSTRAINT "PK_test";`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { asColumnList } from 'src/sql-tools/helpers';
|
||||
import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types';
|
||||
import { DatabaseActionType, DatabaseConstraint, DatabaseConstraintType, SchemaDiff } from 'src/sql-tools/types';
|
||||
|
||||
export const transformConstraints: SqlTransformer = (item: SchemaDiff) => {
|
||||
switch (item.type) {
|
||||
case 'constraint.add': {
|
||||
return asConstraintAdd(item.constraint);
|
||||
}
|
||||
|
||||
case 'constraint.drop': {
|
||||
return asConstraintDrop(item.tableName, item.constraintName);
|
||||
}
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const withAction = (constraint: { onDelete?: DatabaseActionType; onUpdate?: DatabaseActionType }) =>
|
||||
` ON UPDATE ${constraint.onUpdate ?? DatabaseActionType.NO_ACTION} ON DELETE ${constraint.onDelete ?? DatabaseActionType.NO_ACTION}`;
|
||||
|
||||
export 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 [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const asConstraintDrop = (tableName: string, constraintName: string): string => {
|
||||
return `ALTER TABLE "${tableName}" DROP CONSTRAINT "${constraintName}";`;
|
||||
};
|
||||
26
server/src/sql-tools/to-sql/transformers/enum.transformer.ts
Normal file
26
server/src/sql-tools/to-sql/transformers/enum.transformer.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types';
|
||||
import { DatabaseEnum, SchemaDiff } from 'src/sql-tools/types';
|
||||
|
||||
export const transformEnums: SqlTransformer = (item: SchemaDiff) => {
|
||||
switch (item.type) {
|
||||
case 'enum.create': {
|
||||
return asEnumCreate(item.enum);
|
||||
}
|
||||
|
||||
case 'enum.drop': {
|
||||
return asEnumDrop(item.enumName);
|
||||
}
|
||||
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const asEnumCreate = ({ name, values }: DatabaseEnum): string => {
|
||||
return `CREATE TYPE "${name}" AS ENUM (${values.map((value) => `'${value}'`)});`;
|
||||
};
|
||||
|
||||
const asEnumDrop = (enumName: string): string => {
|
||||
return `DROP TYPE "${enumName}";`;
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { transformExtensions } from 'src/sql-tools/to-sql/transformers/extension.transformer';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe(transformExtensions.name, () => {
|
||||
describe('extension.drop', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformExtensions({
|
||||
type: 'extension.drop',
|
||||
extensionName: 'cube',
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual(`DROP EXTENSION "cube";`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extension.create', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformExtensions({
|
||||
type: 'extension.create',
|
||||
extension: {
|
||||
name: 'cube',
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual(`CREATE EXTENSION IF NOT EXISTS "cube";`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types';
|
||||
import { DatabaseExtension, SchemaDiff } from 'src/sql-tools/types';
|
||||
|
||||
export const transformExtensions: SqlTransformer = (item: SchemaDiff) => {
|
||||
switch (item.type) {
|
||||
case 'extension.create': {
|
||||
return asExtensionCreate(item.extension);
|
||||
}
|
||||
|
||||
case 'extension.drop': {
|
||||
return asExtensionDrop(item.extensionName);
|
||||
}
|
||||
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const asExtensionCreate = (extension: DatabaseExtension): string => {
|
||||
return `CREATE EXTENSION IF NOT EXISTS "${extension.name}";`;
|
||||
};
|
||||
|
||||
const asExtensionDrop = (extensionName: string): string => {
|
||||
return `DROP EXTENSION "${extensionName}";`;
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { transformFunctions } from 'src/sql-tools/to-sql/transformers/function.transformer';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe(transformFunctions.name, () => {
|
||||
describe('function.drop', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformFunctions({
|
||||
type: 'function.drop',
|
||||
functionName: 'test_func',
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual(`DROP FUNCTION test_func;`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types';
|
||||
import { DatabaseFunction, SchemaDiff } from 'src/sql-tools/types';
|
||||
|
||||
export const transformFunctions: SqlTransformer = (item: SchemaDiff) => {
|
||||
switch (item.type) {
|
||||
case 'function.create': {
|
||||
return asFunctionCreate(item.function);
|
||||
}
|
||||
|
||||
case 'function.drop': {
|
||||
return asFunctionDrop(item.functionName);
|
||||
}
|
||||
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const asFunctionCreate = (func: DatabaseFunction): string => {
|
||||
return func.expression;
|
||||
};
|
||||
|
||||
const asFunctionDrop = (functionName: string): string => {
|
||||
return `DROP FUNCTION ${functionName};`;
|
||||
};
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { transformIndexes } from 'src/sql-tools/to-sql/transformers/index.transformer';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe(transformIndexes.name, () => {
|
||||
describe('index.create', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformIndexes({
|
||||
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(
|
||||
transformIndexes({
|
||||
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(
|
||||
transformIndexes({
|
||||
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(
|
||||
transformIndexes({
|
||||
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(
|
||||
transformIndexes({
|
||||
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(
|
||||
transformIndexes({
|
||||
type: 'index.drop',
|
||||
indexName: 'IDX_test',
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual(`DROP INDEX "IDX_test";`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { asColumnList } from 'src/sql-tools/helpers';
|
||||
import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types';
|
||||
import { DatabaseIndex, SchemaDiff } from 'src/sql-tools/types';
|
||||
|
||||
export const transformIndexes: SqlTransformer = (item: SchemaDiff) => {
|
||||
switch (item.type) {
|
||||
case 'index.create': {
|
||||
return asIndexCreate(item.index);
|
||||
}
|
||||
|
||||
case 'index.drop': {
|
||||
return asIndexDrop(item.indexName);
|
||||
}
|
||||
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export 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.with) {
|
||||
sql += ` WITH (${index.with})`;
|
||||
}
|
||||
|
||||
if (index.where) {
|
||||
sql += ` WHERE ${index.where}`;
|
||||
}
|
||||
|
||||
return sql;
|
||||
};
|
||||
|
||||
export const asIndexDrop = (indexName: string): string => {
|
||||
return `DROP INDEX "${indexName}";`;
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types';
|
||||
import { DatabaseParameter, SchemaDiff } from 'src/sql-tools/types';
|
||||
|
||||
export const transformParameters: SqlTransformer = (item: SchemaDiff) => {
|
||||
switch (item.type) {
|
||||
case 'parameter.set': {
|
||||
return asParameterSet(item.parameter);
|
||||
}
|
||||
|
||||
case 'parameter.reset': {
|
||||
return asParameterReset(item.databaseName, item.parameterName);
|
||||
}
|
||||
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const asParameterSet = (parameter: DatabaseParameter): string => {
|
||||
let sql = '';
|
||||
if (parameter.scope === 'database') {
|
||||
sql += `ALTER DATABASE "${parameter.databaseName}" `;
|
||||
}
|
||||
|
||||
sql += `SET ${parameter.name} TO ${parameter.value}`;
|
||||
|
||||
return sql;
|
||||
};
|
||||
|
||||
const asParameterReset = (databaseName: string, parameterName: string): string => {
|
||||
return `ALTER DATABASE "${databaseName}" RESET "${parameterName}"`;
|
||||
};
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import { transformTables } from 'src/sql-tools/to-sql/transformers/table.transformer';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe(transformTables.name, () => {
|
||||
describe('table.drop', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformTables({
|
||||
type: 'table.drop',
|
||||
tableName: 'table1',
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual(`DROP TABLE "table1";`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('table.create', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformTables({
|
||||
type: 'table.create',
|
||||
table: {
|
||||
name: 'table1',
|
||||
columns: [
|
||||
{
|
||||
tableName: 'table1',
|
||||
name: 'column1',
|
||||
type: 'character varying',
|
||||
nullable: true,
|
||||
isArray: false,
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
constraints: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual([`CREATE TABLE "table1" ("column1" character varying);`]);
|
||||
});
|
||||
|
||||
it('should handle a non-nullable column', () => {
|
||||
expect(
|
||||
transformTables({
|
||||
type: 'table.create',
|
||||
table: {
|
||||
name: 'table1',
|
||||
columns: [
|
||||
{
|
||||
tableName: 'table1',
|
||||
name: 'column1',
|
||||
type: 'character varying',
|
||||
isArray: false,
|
||||
nullable: false,
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
constraints: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual([`CREATE TABLE "table1" ("column1" character varying NOT NULL);`]);
|
||||
});
|
||||
|
||||
it('should handle a default value', () => {
|
||||
expect(
|
||||
transformTables({
|
||||
type: 'table.create',
|
||||
table: {
|
||||
name: 'table1',
|
||||
columns: [
|
||||
{
|
||||
tableName: 'table1',
|
||||
name: 'column1',
|
||||
type: 'character varying',
|
||||
isArray: false,
|
||||
nullable: true,
|
||||
default: 'uuid_generate_v4()',
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
constraints: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual([`CREATE TABLE "table1" ("column1" character varying DEFAULT uuid_generate_v4());`]);
|
||||
});
|
||||
|
||||
it('should handle a string with a fixed length', () => {
|
||||
expect(
|
||||
transformTables({
|
||||
type: 'table.create',
|
||||
table: {
|
||||
name: 'table1',
|
||||
columns: [
|
||||
{
|
||||
tableName: 'table1',
|
||||
name: 'column1',
|
||||
type: 'character varying',
|
||||
length: 2,
|
||||
isArray: false,
|
||||
nullable: true,
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
constraints: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual([`CREATE TABLE "table1" ("column1" character varying(2));`]);
|
||||
});
|
||||
|
||||
it('should handle an array type', () => {
|
||||
expect(
|
||||
transformTables({
|
||||
type: 'table.create',
|
||||
table: {
|
||||
name: 'table1',
|
||||
columns: [
|
||||
{
|
||||
tableName: 'table1',
|
||||
name: 'column1',
|
||||
type: 'character varying',
|
||||
isArray: true,
|
||||
nullable: true,
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
constraints: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual([`CREATE TABLE "table1" ("column1" character varying[]);`]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { asColumnComment, getColumnModifiers, getColumnType } from 'src/sql-tools/helpers';
|
||||
import { asColumnAlter } from 'src/sql-tools/to-sql/transformers/column.transformer';
|
||||
import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types';
|
||||
import { DatabaseTable, SchemaDiff } from 'src/sql-tools/types';
|
||||
|
||||
export const transformTables: SqlTransformer = (item: SchemaDiff) => {
|
||||
switch (item.type) {
|
||||
case 'table.create': {
|
||||
return asTableCreate(item.table);
|
||||
}
|
||||
|
||||
case 'table.drop': {
|
||||
return asTableDrop(item.tableName);
|
||||
}
|
||||
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const asTableCreate = (table: DatabaseTable): string[] => {
|
||||
const tableName = table.name;
|
||||
const columnsTypes = table.columns
|
||||
.map((column) => `"${column.name}" ${getColumnType(column)}` + getColumnModifiers(column))
|
||||
.join(', ');
|
||||
const items = [`CREATE TABLE "${tableName}" (${columnsTypes});`];
|
||||
|
||||
for (const column of table.columns) {
|
||||
if (column.comment) {
|
||||
items.push(asColumnComment(tableName, column.name, column.comment));
|
||||
}
|
||||
|
||||
if (column.storage) {
|
||||
items.push(...asColumnAlter(tableName, column.name, { storage: column.storage }));
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const asTableDrop = (tableName: string): string => {
|
||||
return `DROP TABLE "${tableName}";`;
|
||||
};
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { transformTriggers } from 'src/sql-tools/to-sql/transformers/trigger.transformer';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe(transformTriggers.name, () => {
|
||||
describe('trigger.create', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformTriggers({
|
||||
type: 'trigger.create',
|
||||
trigger: {
|
||||
name: 'trigger1',
|
||||
tableName: 'table1',
|
||||
timing: 'before',
|
||||
actions: ['update'],
|
||||
scope: 'row',
|
||||
functionName: 'function1',
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual(
|
||||
`CREATE OR REPLACE TRIGGER "trigger1"
|
||||
BEFORE UPDATE ON "table1"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION function1();`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should work with multiple actions', () => {
|
||||
expect(
|
||||
transformTriggers({
|
||||
type: 'trigger.create',
|
||||
trigger: {
|
||||
name: 'trigger1',
|
||||
tableName: 'table1',
|
||||
timing: 'before',
|
||||
actions: ['update', 'delete'],
|
||||
scope: 'row',
|
||||
functionName: 'function1',
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual(
|
||||
`CREATE OR REPLACE TRIGGER "trigger1"
|
||||
BEFORE UPDATE OR DELETE ON "table1"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION function1();`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should work with old/new reference table aliases', () => {
|
||||
expect(
|
||||
transformTriggers({
|
||||
type: 'trigger.create',
|
||||
trigger: {
|
||||
name: 'trigger1',
|
||||
tableName: 'table1',
|
||||
timing: 'before',
|
||||
actions: ['update'],
|
||||
referencingNewTableAs: 'new',
|
||||
referencingOldTableAs: 'old',
|
||||
scope: 'row',
|
||||
functionName: 'function1',
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual(
|
||||
`CREATE OR REPLACE TRIGGER "trigger1"
|
||||
BEFORE UPDATE ON "table1"
|
||||
REFERENCING OLD TABLE AS "old" NEW TABLE AS "new"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION function1();`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger.drop', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformTriggers({
|
||||
type: 'trigger.drop',
|
||||
tableName: 'table1',
|
||||
triggerName: 'trigger1',
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual(`DROP TRIGGER "trigger1" ON "table1";`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types';
|
||||
import { DatabaseTrigger, SchemaDiff } from 'src/sql-tools/types';
|
||||
|
||||
export const transformTriggers: SqlTransformer = (item: SchemaDiff) => {
|
||||
switch (item.type) {
|
||||
case 'trigger.create': {
|
||||
return asTriggerCreate(item.trigger);
|
||||
}
|
||||
|
||||
case 'trigger.drop': {
|
||||
return asTriggerDrop(item.tableName, item.triggerName);
|
||||
}
|
||||
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const asTriggerCreate = (trigger: DatabaseTrigger): string => {
|
||||
const sql: string[] = [
|
||||
`CREATE OR REPLACE TRIGGER "${trigger.name}"`,
|
||||
`${trigger.timing.toUpperCase()} ${trigger.actions.map((action) => action.toUpperCase()).join(' OR ')} ON "${trigger.tableName}"`,
|
||||
];
|
||||
|
||||
if (trigger.referencingOldTableAs || trigger.referencingNewTableAs) {
|
||||
let statement = `REFERENCING`;
|
||||
if (trigger.referencingOldTableAs) {
|
||||
statement += ` OLD TABLE AS "${trigger.referencingOldTableAs}"`;
|
||||
}
|
||||
if (trigger.referencingNewTableAs) {
|
||||
statement += ` NEW TABLE AS "${trigger.referencingNewTableAs}"`;
|
||||
}
|
||||
sql.push(statement);
|
||||
}
|
||||
|
||||
if (trigger.scope) {
|
||||
sql.push(`FOR EACH ${trigger.scope.toUpperCase()}`);
|
||||
}
|
||||
|
||||
if (trigger.when) {
|
||||
sql.push(`WHEN (${trigger.when})`);
|
||||
}
|
||||
|
||||
sql.push(`EXECUTE FUNCTION ${trigger.functionName}();`);
|
||||
|
||||
return sql.join('\n ');
|
||||
};
|
||||
|
||||
export const asTriggerDrop = (tableName: string, triggerName: string): string => {
|
||||
return `DROP TRIGGER "${triggerName}" ON "${tableName}";`;
|
||||
};
|
||||
3
server/src/sql-tools/to-sql/transformers/types.ts
Normal file
3
server/src/sql-tools/to-sql/transformers/types.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { SchemaDiff } from 'src/sql-tools/types';
|
||||
|
||||
export type SqlTransformer = (item: SchemaDiff) => string | string[] | false;
|
||||
|
|
@ -55,6 +55,42 @@ export type PostgresDB = {
|
|||
conindid: number;
|
||||
};
|
||||
|
||||
pg_description: {
|
||||
objoid: string;
|
||||
classoid: string;
|
||||
objsubid: number;
|
||||
description: string;
|
||||
};
|
||||
|
||||
pg_trigger: {
|
||||
oid: string;
|
||||
tgisinternal: boolean;
|
||||
tginitdeferred: boolean;
|
||||
tgdeferrable: boolean;
|
||||
tgrelid: string;
|
||||
tgfoid: string;
|
||||
tgname: string;
|
||||
tgenabled: string;
|
||||
tgtype: number;
|
||||
tgconstraint: string;
|
||||
tgdeferred: boolean;
|
||||
tgargs: Buffer;
|
||||
tgoldtable: string;
|
||||
tgnewtable: string;
|
||||
tgqual: string;
|
||||
};
|
||||
|
||||
'pg_catalog.pg_extension': {
|
||||
oid: string;
|
||||
extname: string;
|
||||
extowner: string;
|
||||
extnamespace: string;
|
||||
extrelocatable: boolean;
|
||||
extversion: string;
|
||||
extconfig: string[];
|
||||
extcondition: string[];
|
||||
};
|
||||
|
||||
pg_enum: {
|
||||
oid: string;
|
||||
enumtypid: string;
|
||||
|
|
@ -99,6 +135,38 @@ export type PostgresDB = {
|
|||
typarray: string;
|
||||
};
|
||||
|
||||
pg_depend: {
|
||||
objid: string;
|
||||
deptype: string;
|
||||
};
|
||||
|
||||
pg_proc: {
|
||||
oid: string;
|
||||
proname: string;
|
||||
pronamespace: string;
|
||||
prokind: string;
|
||||
};
|
||||
|
||||
pg_settings: {
|
||||
name: string;
|
||||
setting: string;
|
||||
unit: string | null;
|
||||
category: string;
|
||||
short_desc: string | null;
|
||||
extra_desc: string | null;
|
||||
context: string;
|
||||
vartype: string;
|
||||
source: string;
|
||||
min_val: string | null;
|
||||
max_val: string | null;
|
||||
enumvals: string[] | null;
|
||||
boot_val: string | null;
|
||||
reset_val: string | null;
|
||||
sourcefile: string | null;
|
||||
sourceline: number | null;
|
||||
pending_restart: PostgresYesOrNo;
|
||||
};
|
||||
|
||||
'information_schema.tables': {
|
||||
table_catalog: string;
|
||||
table_schema: string;
|
||||
|
|
@ -142,12 +210,31 @@ export type PostgresDB = {
|
|||
collection_type_identifier: string;
|
||||
data_type: string;
|
||||
};
|
||||
|
||||
'information_schema.routines': {
|
||||
specific_catalog: string;
|
||||
specific_schema: string;
|
||||
specific_name: string;
|
||||
routine_catalog: string;
|
||||
routine_schema: string;
|
||||
routine_name: string;
|
||||
routine_type: string;
|
||||
data_type: string;
|
||||
type_udt_catalog: string;
|
||||
type_udt_schema: string;
|
||||
type_udt_name: string;
|
||||
dtd_identifier: string;
|
||||
routine_body: string;
|
||||
routine_definition: string;
|
||||
external_name: string;
|
||||
external_language: string;
|
||||
is_deterministic: PostgresYesOrNo;
|
||||
security_type: string;
|
||||
};
|
||||
};
|
||||
|
||||
type PostgresYesOrNo = 'YES' | 'NO';
|
||||
|
||||
export type ColumnDefaultValue = null | boolean | string | number | object | Date | (() => string);
|
||||
|
||||
export type DatabaseClient = Kysely<PostgresDB>;
|
||||
|
||||
export enum DatabaseConstraintType {
|
||||
|
|
@ -165,7 +252,9 @@ export enum DatabaseActionType {
|
|||
SET_DEFAULT = 'SET DEFAULT',
|
||||
}
|
||||
|
||||
export type DatabaseColumnType =
|
||||
export type ColumnStorage = 'default' | 'external' | 'extended' | 'main' | 'default';
|
||||
|
||||
export type ColumnType =
|
||||
| 'bigint'
|
||||
| 'boolean'
|
||||
| 'bytea'
|
||||
|
|
@ -188,71 +277,63 @@ export type DatabaseColumnType =
|
|||
| '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;
|
||||
schemaName: string;
|
||||
functions: DatabaseFunction[];
|
||||
enums: DatabaseEnum[];
|
||||
tables: DatabaseTable[];
|
||||
extensions: DatabaseExtension[];
|
||||
parameters: DatabaseParameter[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type SchemaDiffOptions = {
|
||||
tables?: DiffOptions;
|
||||
functions?: DiffOptions;
|
||||
enums?: DiffOptions;
|
||||
extension?: DiffOptions;
|
||||
parameters?: DiffOptions;
|
||||
};
|
||||
|
||||
export type DiffOptions = {
|
||||
ignoreExtra?: boolean;
|
||||
ignoreMissing?: boolean;
|
||||
};
|
||||
|
||||
export type DatabaseParameter = {
|
||||
name: string;
|
||||
databaseName: string;
|
||||
value: string | number | null | undefined;
|
||||
scope: ParameterScope;
|
||||
synchronize: boolean;
|
||||
};
|
||||
|
||||
export type ParameterScope = 'database' | 'user';
|
||||
|
||||
export type DatabaseEnum = {
|
||||
name: string;
|
||||
values: string[];
|
||||
synchronize: boolean;
|
||||
};
|
||||
|
||||
export type DatabaseFunction = {
|
||||
name: string;
|
||||
expression: string;
|
||||
synchronize: boolean;
|
||||
};
|
||||
|
||||
export type DatabaseExtension = {
|
||||
name: string;
|
||||
synchronize: boolean;
|
||||
};
|
||||
|
||||
export type DatabaseTable = {
|
||||
name: string;
|
||||
columns: DatabaseColumn[];
|
||||
indexes: DatabaseIndex[];
|
||||
constraints: DatabaseConstraint[];
|
||||
triggers: DatabaseTrigger[];
|
||||
synchronize: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -266,17 +347,19 @@ export type DatabaseColumn = {
|
|||
primary?: boolean;
|
||||
name: string;
|
||||
tableName: string;
|
||||
comment?: string;
|
||||
|
||||
type: DatabaseColumnType;
|
||||
type: ColumnType;
|
||||
nullable: boolean;
|
||||
isArray: boolean;
|
||||
synchronize: boolean;
|
||||
|
||||
default?: string;
|
||||
length?: number;
|
||||
storage?: ColumnStorage;
|
||||
identity?: boolean;
|
||||
|
||||
// enum values
|
||||
enumValues?: string[];
|
||||
enumName?: string;
|
||||
|
||||
// numeric types
|
||||
|
|
@ -284,9 +367,11 @@ export type DatabaseColumn = {
|
|||
numericScale?: number;
|
||||
};
|
||||
|
||||
export type DatabaseColumnChanges = {
|
||||
export type ColumnChanges = {
|
||||
nullable?: boolean;
|
||||
default?: string;
|
||||
comment?: string;
|
||||
storage?: ColumnStorage;
|
||||
};
|
||||
|
||||
type ColumBasedConstraint = {
|
||||
|
|
@ -322,6 +407,22 @@ export type DatabaseCheckConstraint = {
|
|||
synchronize: boolean;
|
||||
};
|
||||
|
||||
export type DatabaseTrigger = {
|
||||
name: string;
|
||||
tableName: string;
|
||||
timing: TriggerTiming;
|
||||
actions: TriggerAction[];
|
||||
scope: TriggerScope;
|
||||
referencingNewTableAs?: string;
|
||||
referencingOldTableAs?: string;
|
||||
when?: string;
|
||||
functionName: string;
|
||||
synchronize: boolean;
|
||||
};
|
||||
export type TriggerTiming = 'before' | 'after' | 'instead of';
|
||||
export type TriggerAction = 'insert' | 'update' | 'delete' | 'truncate';
|
||||
export type TriggerScope = 'row' | 'statement';
|
||||
|
||||
export type DatabaseIndex = {
|
||||
name: string;
|
||||
tableName: string;
|
||||
|
|
@ -329,6 +430,7 @@ export type DatabaseIndex = {
|
|||
expression?: string;
|
||||
unique: boolean;
|
||||
using?: string;
|
||||
with?: string;
|
||||
where?: string;
|
||||
synchronize: boolean;
|
||||
};
|
||||
|
|
@ -342,22 +444,35 @@ export type SchemaDiffToSqlOptions = {
|
|||
};
|
||||
|
||||
export type SchemaDiff = { reason: string } & (
|
||||
| { type: 'table.create'; tableName: string; columns: DatabaseColumn[] }
|
||||
| { type: 'extension.create'; extension: DatabaseExtension }
|
||||
| { type: 'extension.drop'; extensionName: string }
|
||||
| { type: 'function.create'; function: DatabaseFunction }
|
||||
| { type: 'function.drop'; functionName: string }
|
||||
| { type: 'table.create'; table: DatabaseTable }
|
||||
| { type: 'table.drop'; tableName: string }
|
||||
| { type: 'column.add'; column: DatabaseColumn }
|
||||
| { type: 'column.alter'; tableName: string; columnName: string; changes: DatabaseColumnChanges }
|
||||
| { type: 'column.alter'; tableName: string; columnName: string; changes: ColumnChanges }
|
||||
| { 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: 'trigger.create'; trigger: DatabaseTrigger }
|
||||
| { type: 'trigger.drop'; tableName: string; triggerName: string }
|
||||
| { type: 'parameter.set'; parameter: DatabaseParameter }
|
||||
| { type: 'parameter.reset'; databaseName: string; parameterName: string }
|
||||
| { type: 'enum.create'; enum: DatabaseEnum }
|
||||
| { type: 'enum.drop'; enumName: 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;
|
||||
export type CompareFunction<T> = (source: T, target: T) => SchemaDiff[];
|
||||
export type Comparer<T> = {
|
||||
onMissing: (source: T) => SchemaDiff[];
|
||||
onExtra: (target: T) => SchemaDiff[];
|
||||
onCompare: CompareFunction<T>;
|
||||
};
|
||||
|
||||
export enum Reason {
|
||||
MissingInSource = 'missing in source',
|
||||
MissingInTarget = 'missing in target',
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue