feat: extension, triggers, functions, comments, parameters management in sql-tools (#17269)

feat: sql-tools extension, triggers, functions, comments, parameters
This commit is contained in:
Jason Rasmussen
2025-04-07 15:12:12 -04:00
committed by GitHub
parent 51c2c60231
commit e7a5b96ed0
170 changed files with 5205 additions and 2295 deletions

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

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

View File

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

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

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

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

View File

@@ -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([]);
});
});
});

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

View File

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

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

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

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

View File

@@ -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([]);
});
});
});

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

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

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

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

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

View File

@@ -0,0 +1,665 @@
import { schemaDiff } from 'src/sql-tools/diff';
import {
ColumnType,
DatabaseActionType,
DatabaseColumn,
DatabaseConstraint,
DatabaseConstraintType,
DatabaseIndex,
DatabaseSchema,
DatabaseTable,
} from 'src/sql-tools/types';
import { describe, expect, it } from 'vitest';
const fromColumn = (column: Partial<Omit<DatabaseColumn, 'tableName'>>): DatabaseSchema => {
const tableName = 'table1';
return {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: tableName,
columns: [
{
name: 'column1',
synchronize: true,
isArray: false,
type: 'character varying',
nullable: false,
...column,
tableName,
},
],
indexes: [],
triggers: [],
constraints: [],
synchronize: true,
},
],
warnings: [],
};
};
const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => {
const tableName = constraint?.tableName || 'table1';
return {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: tableName,
columns: [
{
name: 'column1',
synchronize: true,
isArray: false,
type: 'character varying',
nullable: false,
tableName,
},
],
indexes: [],
triggers: [],
constraints: constraint ? [constraint] : [],
synchronize: true,
},
],
warnings: [],
};
};
const fromIndex = (index?: DatabaseIndex): DatabaseSchema => {
const tableName = index?.tableName || 'table1';
return {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: tableName,
columns: [
{
name: 'column1',
synchronize: true,
isArray: false,
type: 'character varying',
nullable: false,
tableName,
},
],
indexes: index ? [index] : [],
constraints: [],
triggers: [],
synchronize: true,
},
],
warnings: [],
};
};
const newSchema = (schema: {
name?: string;
tables: Array<{
name: string;
columns?: Array<{
name: string;
type?: ColumnType;
nullable?: boolean;
isArray?: boolean;
}>;
indexes?: DatabaseIndex[];
constraints?: DatabaseConstraint[];
}>;
}): DatabaseSchema => {
const tables: DatabaseTable[] = [];
for (const table of schema.tables || []) {
const tableName = table.name;
const columns: DatabaseColumn[] = [];
for (const column of table.columns || []) {
const columnName = column.name;
columns.push({
tableName,
name: columnName,
type: column.type || 'character varying',
isArray: column.isArray ?? false,
nullable: column.nullable ?? false,
synchronize: true,
});
}
tables.push({
name: tableName,
columns,
indexes: table.indexes ?? [],
constraints: table.constraints ?? [],
triggers: [],
synchronize: true,
});
}
return {
name: 'immich',
schemaName: schema?.name || 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables,
warnings: [],
};
};
describe('schemaDiff', () => {
it('should work', () => {
const diff = schemaDiff(newSchema({ tables: [] }), newSchema({ tables: [] }));
expect(diff.items).toEqual([]);
});
describe('table', () => {
describe('table.create', () => {
it('should find a missing table', () => {
const column: DatabaseColumn = {
type: 'character varying',
tableName: 'table1',
name: 'column1',
isArray: false,
nullable: false,
synchronize: true,
};
const diff = schemaDiff(
newSchema({ tables: [{ name: 'table1', columns: [column] }] }),
newSchema({ tables: [] }),
);
expect(diff.items).toHaveLength(1);
expect(diff.items[0]).toEqual({
type: 'table.create',
table: {
name: 'table1',
columns: [column],
constraints: [],
indexes: [],
triggers: [],
synchronize: true,
},
reason: 'missing in target',
});
});
});
describe('table.drop', () => {
it('should find an extra table', () => {
const diff = schemaDiff(
newSchema({ tables: [] }),
newSchema({
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
}),
{ tables: { ignoreExtra: false } },
);
expect(diff.items).toHaveLength(1);
expect(diff.items[0]).toEqual({
type: 'table.drop',
tableName: 'table1',
reason: 'missing in source',
});
});
});
it('should skip identical tables', () => {
const diff = schemaDiff(
newSchema({
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
}),
newSchema({
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
}),
);
expect(diff.items).toEqual([]);
});
});
describe('column', () => {
describe('column.add', () => {
it('should find a new column', () => {
const diff = schemaDiff(
newSchema({
tables: [
{
name: 'table1',
columns: [{ name: 'column1' }, { name: 'column2' }],
},
],
}),
newSchema({
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
}),
);
expect(diff.items).toEqual([
{
type: 'column.add',
column: {
tableName: 'table1',
isArray: false,
name: 'column2',
nullable: false,
type: 'character varying',
synchronize: true,
},
reason: 'missing in target',
},
]);
});
});
describe('column.drop', () => {
it('should find an extra column', () => {
const diff = schemaDiff(
newSchema({
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
}),
newSchema({
tables: [
{
name: 'table1',
columns: [{ name: 'column1' }, { name: 'column2' }],
},
],
}),
);
expect(diff.items).toEqual([
{
type: 'column.drop',
tableName: 'table1',
columnName: 'column2',
reason: 'missing in source',
},
]);
});
});
describe('nullable', () => {
it('should make a column nullable', () => {
const diff = schemaDiff(
fromColumn({ name: 'column1', nullable: true }),
fromColumn({ name: 'column1', nullable: false }),
);
expect(diff.items).toEqual([
{
type: 'column.alter',
tableName: 'table1',
columnName: 'column1',
changes: {
nullable: true,
},
reason: 'nullable is different (true vs false)',
},
]);
});
it('should make a column non-nullable', () => {
const diff = schemaDiff(
fromColumn({ name: 'column1', nullable: false }),
fromColumn({ name: 'column1', nullable: true }),
);
expect(diff.items).toEqual([
{
type: 'column.alter',
tableName: 'table1',
columnName: 'column1',
changes: {
nullable: false,
},
reason: 'nullable is different (false vs true)',
},
]);
});
});
describe('default', () => {
it('should set a default value to a function', () => {
const diff = schemaDiff(
fromColumn({ name: 'column1', default: 'uuid_generate_v4()' }),
fromColumn({ name: 'column1' }),
);
expect(diff.items).toEqual([
{
type: 'column.alter',
tableName: 'table1',
columnName: 'column1',
changes: {
default: 'uuid_generate_v4()',
},
reason: 'default is different (uuid_generate_v4() vs undefined)',
},
]);
});
it('should ignore explicit casts for strings', () => {
const diff = schemaDiff(
fromColumn({ name: 'column1', type: 'character varying', default: `''` }),
fromColumn({ name: 'column1', type: 'character varying', default: `''::character varying` }),
);
expect(diff.items).toEqual([]);
});
it('should ignore explicit casts for numbers', () => {
const diff = schemaDiff(
fromColumn({ name: 'column1', type: 'bigint', default: `0` }),
fromColumn({ name: 'column1', type: 'bigint', default: `'0'::bigint` }),
);
expect(diff.items).toEqual([]);
});
it('should ignore explicit casts for enums', () => {
const diff = schemaDiff(
fromColumn({ name: 'column1', type: 'enum', enumName: 'enum1', default: `test` }),
fromColumn({ name: 'column1', type: 'enum', enumName: 'enum1', default: `'test'::enum1` }),
);
expect(diff.items).toEqual([]);
});
});
});
describe('constraint', () => {
describe('constraint.add', () => {
it('should detect a new constraint', () => {
const diff = schemaDiff(
fromConstraint({
name: 'PK_test',
type: DatabaseConstraintType.PRIMARY_KEY,
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
}),
fromConstraint(),
);
expect(diff.items).toEqual([
{
type: 'constraint.add',
constraint: {
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_test',
columnNames: ['id'],
tableName: 'table1',
synchronize: true,
},
reason: 'missing in target',
},
]);
});
});
describe('constraint.drop', () => {
it('should detect an extra constraint', () => {
const diff = schemaDiff(
fromConstraint(),
fromConstraint({
name: 'PK_test',
type: DatabaseConstraintType.PRIMARY_KEY,
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
}),
);
expect(diff.items).toEqual([
{
type: 'constraint.drop',
tableName: 'table1',
constraintName: 'PK_test',
reason: 'missing in source',
},
]);
});
});
describe('primary key', () => {
it('should skip identical primary key constraints', () => {
const constraint: DatabaseConstraint = {
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_test',
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
};
const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint }));
expect(diff.items).toEqual([]);
});
});
describe('foreign key', () => {
it('should skip identical foreign key constraints', () => {
const constraint: DatabaseConstraint = {
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_test',
tableName: 'table1',
columnNames: ['parentId'],
referenceTableName: 'table2',
referenceColumnNames: ['id'],
synchronize: true,
};
const diff = schemaDiff(fromConstraint(constraint), fromConstraint(constraint));
expect(diff.items).toEqual([]);
});
it('should drop and recreate when the column changes', () => {
const constraint: DatabaseConstraint = {
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_test',
tableName: 'table1',
columnNames: ['parentId'],
referenceTableName: 'table2',
referenceColumnNames: ['id'],
synchronize: true,
};
const diff = schemaDiff(
fromConstraint(constraint),
fromConstraint({ ...constraint, columnNames: ['parentId2'] }),
);
expect(diff.items).toEqual([
{
constraintName: 'FK_test',
reason: 'columns are different (parentId vs parentId2)',
tableName: 'table1',
type: 'constraint.drop',
},
{
constraint: {
columnNames: ['parentId'],
name: 'FK_test',
referenceColumnNames: ['id'],
referenceTableName: 'table2',
synchronize: true,
tableName: 'table1',
type: 'foreign-key',
},
reason: 'columns are different (parentId vs parentId2)',
type: 'constraint.add',
},
]);
});
it('should drop and recreate when the ON DELETE action changes', () => {
const constraint: DatabaseConstraint = {
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_test',
tableName: 'table1',
columnNames: ['parentId'],
referenceTableName: 'table2',
referenceColumnNames: ['id'],
onDelete: DatabaseActionType.CASCADE,
synchronize: true,
};
const diff = schemaDiff(fromConstraint(constraint), fromConstraint({ ...constraint, onDelete: undefined }));
expect(diff.items).toEqual([
{
constraintName: 'FK_test',
reason: 'ON DELETE action is different (CASCADE vs NO ACTION)',
tableName: 'table1',
type: 'constraint.drop',
},
{
constraint: {
columnNames: ['parentId'],
name: 'FK_test',
referenceColumnNames: ['id'],
referenceTableName: 'table2',
onDelete: DatabaseActionType.CASCADE,
synchronize: true,
tableName: 'table1',
type: 'foreign-key',
},
reason: 'ON DELETE action is different (CASCADE vs NO ACTION)',
type: 'constraint.add',
},
]);
});
});
describe('unique', () => {
it('should skip identical unique constraints', () => {
const constraint: DatabaseConstraint = {
type: DatabaseConstraintType.UNIQUE,
name: 'UQ_test',
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
};
const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint }));
expect(diff.items).toEqual([]);
});
});
describe('check', () => {
it('should skip identical check constraints', () => {
const constraint: DatabaseConstraint = {
type: DatabaseConstraintType.CHECK,
name: 'CHK_test',
tableName: 'table1',
expression: 'column1 > 0',
synchronize: true,
};
const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint }));
expect(diff.items).toEqual([]);
});
});
});
describe('index', () => {
describe('index.create', () => {
it('should detect a new index', () => {
const diff = schemaDiff(
fromIndex({
name: 'IDX_test',
tableName: 'table1',
columnNames: ['id'],
unique: false,
synchronize: true,
}),
fromIndex(),
);
expect(diff.items).toEqual([
{
type: 'index.create',
index: {
name: 'IDX_test',
columnNames: ['id'],
tableName: 'table1',
unique: false,
synchronize: true,
},
reason: 'missing in target',
},
]);
});
});
describe('index.drop', () => {
it('should detect an extra index', () => {
const diff = schemaDiff(
fromIndex(),
fromIndex({
name: 'IDX_test',
unique: true,
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
}),
);
expect(diff.items).toEqual([
{
type: 'index.drop',
indexName: 'IDX_test',
reason: 'missing in source',
},
]);
});
});
it('should recreate the index if unique changes', () => {
const index: DatabaseIndex = {
name: 'IDX_test',
tableName: 'table1',
columnNames: ['id'],
unique: true,
synchronize: true,
};
const diff = schemaDiff(fromIndex(index), fromIndex({ ...index, unique: false }));
expect(diff.items).toEqual([
{
type: 'index.drop',
indexName: 'IDX_test',
reason: 'uniqueness is different (true vs false)',
},
{
type: 'index.create',
index,
reason: 'uniqueness is different (true vs false)',
},
]);
});
});
});

View File

@@ -0,0 +1,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),
};
};