mirror of
https://github.com/immich-app/packages.git
synced 2026-09-30 13:33:01 +08:00
feat: sql tools (#2)
This commit is contained in:
@@ -17,6 +17,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
|
||||
|
||||
@@ -16,6 +16,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
{}
|
||||
{
|
||||
"packages/sql-tools": "0.0.1"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import svelte from 'eslint-plugin-svelte';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
|
||||
|
||||
export default defineConfig([
|
||||
js.configs.recommended,
|
||||
@@ -11,6 +12,7 @@ export default defineConfig([
|
||||
...svelte.configs['flat/recommended'],
|
||||
prettier,
|
||||
...svelte.configs['flat/prettier'],
|
||||
eslintPluginUnicorn.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
@@ -30,6 +32,19 @@ export default defineConfig([
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'unicorn/prevent-abbreviations': 'off',
|
||||
'unicorn/filename-case': 'off',
|
||||
'unicorn/no-null': 'off',
|
||||
'unicorn/prefer-top-level-await': 'off',
|
||||
'unicorn/prefer-event-target': 'off',
|
||||
'unicorn/no-thenable': 'off',
|
||||
'unicorn/import-style': 'off',
|
||||
'unicorn/prefer-structured-clone': 'off',
|
||||
'unicorn/no-for-loop': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['packages/**/build/', 'packages/**/.svelte-kit/', 'packages/**/dist/'],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "packages",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"description": "A collection of libraries around the Immich project",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
@@ -22,9 +23,11 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/eslint-config": "^8.3.4",
|
||||
"@types/node": "^25.3.0",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.12.5",
|
||||
"eslint-plugin-unicorn": "^63.0.0",
|
||||
"globals": "^17.3.0",
|
||||
"jiti": "^2.6.1",
|
||||
"prettier": "^3.6.2",
|
||||
@@ -33,6 +36,9 @@
|
||||
"svelte": "^5.42.3",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# SQL Tools
|
||||
|
||||
Kysely-based tools and utilities for managing postgres schema.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm i @immich/sql-tool
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@immich/sql-tools",
|
||||
"description": "Kysely-based tools and utilities for managing postgres schema",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/immich-app/packages.git",
|
||||
"directory": "packages/sql-tools"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"kysely": "^0.28.11",
|
||||
"kysely-postgres-js": "^3.0.0",
|
||||
"postgres": "^3.4.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { compareColumns } from 'src/comparers/column.comparer';
|
||||
import { DatabaseColumn, Reason } from 'src/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testColumn: DatabaseColumn = {
|
||||
name: 'test',
|
||||
tableName: 'table1',
|
||||
primary: false,
|
||||
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: 'ColumnDrop',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareColumns().onMissing(testColumn)).toEqual([
|
||||
{
|
||||
type: 'ColumnAdd',
|
||||
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: 'ColumnDrop',
|
||||
reason,
|
||||
},
|
||||
{
|
||||
type: 'ColumnAdd',
|
||||
column: source,
|
||||
reason,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should detect a change in default', () => {
|
||||
const source: DatabaseColumn = { ...testColumn, nullable: true };
|
||||
const target: DatabaseColumn = { ...testColumn, nullable: true, default: "''" };
|
||||
const reason = `default is different (null vs '')`;
|
||||
expect(compareColumns().onCompare(source, target)).toEqual([
|
||||
{
|
||||
columnName: 'test',
|
||||
tableName: 'table1',
|
||||
type: 'ColumnAlter',
|
||||
changes: {
|
||||
default: 'NULL',
|
||||
},
|
||||
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: 'ColumnAlter',
|
||||
changes: {
|
||||
comment: 'new comment',
|
||||
},
|
||||
reason,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { asRenameKey, getColumnType, isDefaultEqual } from 'src/helpers';
|
||||
import { Comparer, DatabaseColumn, Reason, SchemaDiff } from 'src/types';
|
||||
|
||||
export const compareColumns = () =>
|
||||
({
|
||||
getRenameKey: (column) => {
|
||||
return asRenameKey([
|
||||
column.tableName,
|
||||
column.type,
|
||||
column.nullable,
|
||||
column.default,
|
||||
column.storage,
|
||||
column.primary,
|
||||
column.isArray,
|
||||
column.length,
|
||||
column.identity,
|
||||
column.enumName,
|
||||
column.numericPrecision,
|
||||
column.numericScale,
|
||||
]);
|
||||
},
|
||||
onRename: (source, target) => [
|
||||
{
|
||||
type: 'ColumnRename',
|
||||
tableName: source.tableName,
|
||||
oldName: target.name,
|
||||
newName: source.name,
|
||||
reason: Reason.Rename,
|
||||
},
|
||||
],
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'ColumnAdd',
|
||||
column: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'ColumnDrop',
|
||||
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: 'ColumnAlter',
|
||||
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: 'ColumnAlter',
|
||||
tableName: source.tableName,
|
||||
columnName: source.name,
|
||||
changes: {
|
||||
default: String(source.default ?? 'NULL'),
|
||||
},
|
||||
reason: `default is different (${source.default ?? 'null'} vs ${target.default})`,
|
||||
});
|
||||
}
|
||||
|
||||
if (source.comment !== target.comment) {
|
||||
items.push({
|
||||
type: 'ColumnAlter',
|
||||
tableName: source.tableName,
|
||||
columnName: source.name,
|
||||
changes: {
|
||||
comment: String(source.comment),
|
||||
},
|
||||
reason: `comment is different (${source.comment} vs ${target.comment})`,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
}) satisfies Comparer<DatabaseColumn>;
|
||||
|
||||
const dropAndRecreateColumn = (source: DatabaseColumn, target: DatabaseColumn, reason: string): SchemaDiff[] => {
|
||||
return [
|
||||
{
|
||||
type: 'ColumnDrop',
|
||||
tableName: target.tableName,
|
||||
columnName: target.name,
|
||||
reason,
|
||||
},
|
||||
{ type: 'ColumnAdd', column: source, reason },
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { compareConstraints } from 'src/comparers/constraint.comparer';
|
||||
import { ConstraintType, DatabaseConstraint, Reason } from 'src/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testConstraint: DatabaseConstraint = {
|
||||
type: ConstraintType.PRIMARY_KEY,
|
||||
name: 'test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['column1'],
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareConstraints', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareConstraints().onExtra(testConstraint)).toEqual([
|
||||
{
|
||||
type: 'ConstraintDrop',
|
||||
constraintName: 'test',
|
||||
tableName: 'table1',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareConstraints().onMissing(testConstraint)).toEqual([
|
||||
{
|
||||
type: 'ConstraintAdd',
|
||||
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: 'ConstraintDrop',
|
||||
reason,
|
||||
},
|
||||
{
|
||||
type: 'ConstraintAdd',
|
||||
constraint: source,
|
||||
reason,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { asRenameKey, haveEqualColumns } from 'src/helpers';
|
||||
import {
|
||||
CompareFunction,
|
||||
Comparer,
|
||||
ConstraintType,
|
||||
DatabaseCheckConstraint,
|
||||
DatabaseConstraint,
|
||||
DatabaseForeignKeyConstraint,
|
||||
DatabasePrimaryKeyConstraint,
|
||||
DatabaseUniqueConstraint,
|
||||
Reason,
|
||||
SchemaDiff,
|
||||
} from 'src/types';
|
||||
|
||||
export const compareConstraints = (): Comparer<DatabaseConstraint> => ({
|
||||
getRenameKey: (constraint) => {
|
||||
switch (constraint.type) {
|
||||
case ConstraintType.PRIMARY_KEY:
|
||||
case ConstraintType.UNIQUE: {
|
||||
return asRenameKey([constraint.type, constraint.tableName, ...constraint.columnNames.toSorted()]);
|
||||
}
|
||||
|
||||
case ConstraintType.FOREIGN_KEY: {
|
||||
return asRenameKey([
|
||||
constraint.type,
|
||||
constraint.tableName,
|
||||
...constraint.columnNames.toSorted(),
|
||||
constraint.referenceTableName,
|
||||
...constraint.referenceColumnNames.toSorted(),
|
||||
]);
|
||||
}
|
||||
|
||||
case ConstraintType.CHECK: {
|
||||
const expression = constraint.expression.replaceAll('(', '').replaceAll(')', '');
|
||||
return asRenameKey([constraint.type, constraint.tableName, expression]);
|
||||
}
|
||||
}
|
||||
},
|
||||
onRename: (source, target) => [
|
||||
{
|
||||
type: 'ConstraintRename',
|
||||
tableName: target.tableName,
|
||||
oldName: target.name,
|
||||
newName: source.name,
|
||||
reason: Reason.Rename,
|
||||
},
|
||||
],
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'ConstraintAdd',
|
||||
constraint: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'ConstraintDrop',
|
||||
tableName: target.tableName,
|
||||
constraintName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => {
|
||||
switch (source.type) {
|
||||
case ConstraintType.PRIMARY_KEY: {
|
||||
return comparePrimaryKeyConstraint(source, target as DatabasePrimaryKeyConstraint);
|
||||
}
|
||||
|
||||
case ConstraintType.FOREIGN_KEY: {
|
||||
return compareForeignKeyConstraint(source, target as DatabaseForeignKeyConstraint);
|
||||
}
|
||||
|
||||
case ConstraintType.UNIQUE: {
|
||||
return compareUniqueConstraint(source, target as DatabaseUniqueConstraint);
|
||||
}
|
||||
|
||||
case ConstraintType.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: 'ConstraintDrop',
|
||||
tableName: target.tableName,
|
||||
constraintName: target.name,
|
||||
reason,
|
||||
},
|
||||
{ type: 'ConstraintAdd', constraint: source, reason },
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { compareEnums } from 'src/comparers/enum.comparer';
|
||||
import { DatabaseEnum, Reason } from 'src/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: 'EnumDrop',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareEnums().onMissing(testEnum)).toEqual([
|
||||
{
|
||||
type: 'EnumCreate',
|
||||
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: 'EnumDrop',
|
||||
reason: 'enum values has changed (foo,bar vs foo,bar,world)',
|
||||
},
|
||||
{
|
||||
type: 'EnumCreate',
|
||||
enum: source,
|
||||
reason: 'enum values has changed (foo,bar vs foo,bar,world)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Comparer, DatabaseEnum, Reason } from 'src/types';
|
||||
|
||||
export const compareEnums = (): Comparer<DatabaseEnum> => ({
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'EnumCreate',
|
||||
enum: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'EnumDrop',
|
||||
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: 'EnumDrop',
|
||||
enumName: source.name,
|
||||
reason,
|
||||
},
|
||||
{
|
||||
type: 'EnumCreate',
|
||||
enum: source,
|
||||
reason,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { compareExtensions } from 'src/comparers/extension.comparer';
|
||||
import { Reason } from 'src/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: 'ExtensionDrop',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareExtensions().onMissing(testExtension)).toEqual([
|
||||
{
|
||||
type: 'ExtensionCreate',
|
||||
extension: testExtension,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareExtensions().onCompare(testExtension, testExtension)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Comparer, DatabaseExtension, Reason } from 'src/types';
|
||||
|
||||
export const compareExtensions = (): Comparer<DatabaseExtension> => ({
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'ExtensionCreate',
|
||||
extension: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'ExtensionDrop',
|
||||
extensionName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: () => {
|
||||
// if the name matches they are the same
|
||||
return [];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { compareFunctions } from 'src/comparers/function.comparer';
|
||||
import { DatabaseFunction, Reason } from 'src/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: 'FunctionDrop',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareFunctions().onMissing(testFunction)).toEqual([
|
||||
{
|
||||
type: 'FunctionCreate',
|
||||
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: 'FunctionCreate',
|
||||
reason: 'function expression has changed (SELECT 1 vs SELECT 2)',
|
||||
function: source,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Comparer, DatabaseFunction, Reason } from 'src/types';
|
||||
|
||||
export const compareFunctions = (): Comparer<DatabaseFunction> => ({
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'FunctionCreate',
|
||||
function: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'FunctionDrop',
|
||||
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: 'FunctionCreate',
|
||||
function: source,
|
||||
reason,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { compareIndexes } from 'src/comparers/index.comparer';
|
||||
import { DatabaseIndex, Reason } from 'src/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: 'IndexDrop',
|
||||
indexName: 'test',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareIndexes().onMissing(testIndex)).toEqual([
|
||||
{
|
||||
type: 'IndexCreate',
|
||||
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: 'IndexDrop',
|
||||
reason: 'columns are different (column1 vs column1,column2)',
|
||||
},
|
||||
{
|
||||
type: 'IndexCreate',
|
||||
index: source,
|
||||
reason: 'columns are different (column1 vs column1,column2)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { asRenameKey, haveEqualColumns } from 'src/helpers';
|
||||
import { Comparer, DatabaseIndex, Reason } from 'src/types';
|
||||
|
||||
export const compareIndexes = (): Comparer<DatabaseIndex> => ({
|
||||
getRenameKey: (index) => {
|
||||
if (index.override) {
|
||||
return index.override.value.sql.replace(index.name, 'INDEX_NAME');
|
||||
}
|
||||
|
||||
return asRenameKey([index.tableName, ...(index.columnNames || []), index.unique]);
|
||||
},
|
||||
onRename: (source, target) => [
|
||||
{
|
||||
type: 'IndexRename',
|
||||
tableName: source.tableName,
|
||||
oldName: target.name,
|
||||
newName: source.name,
|
||||
reason: Reason.Rename,
|
||||
},
|
||||
],
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'IndexCreate',
|
||||
index: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'IndexDrop',
|
||||
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: 'IndexDrop', indexName: target.name, reason },
|
||||
{ type: 'IndexCreate', index: source, reason },
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { compareOverrides } from 'src/comparers/override.comparer';
|
||||
import { DatabaseOverride, Reason } from 'src/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testOverride: DatabaseOverride = {
|
||||
name: 'test',
|
||||
value: { type: 'function', name: 'test_func', sql: 'func implementation' },
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareOverrides', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareOverrides().onExtra(testOverride)).toEqual([
|
||||
{
|
||||
type: 'OverrideDrop',
|
||||
overrideName: 'test',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareOverrides().onMissing(testOverride)).toEqual([
|
||||
{
|
||||
type: 'OverrideCreate',
|
||||
override: testOverride,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareOverrides().onCompare(testOverride, testOverride)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should drop and recreate when the value changes', () => {
|
||||
const source: DatabaseOverride = {
|
||||
name: 'test',
|
||||
value: {
|
||||
type: 'function',
|
||||
name: 'test_func',
|
||||
sql: 'func implementation',
|
||||
},
|
||||
synchronize: true,
|
||||
};
|
||||
const target: DatabaseOverride = {
|
||||
name: 'test',
|
||||
value: {
|
||||
type: 'function',
|
||||
name: 'test_func',
|
||||
sql: 'func implementation2',
|
||||
},
|
||||
synchronize: true,
|
||||
};
|
||||
expect(compareOverrides().onCompare(source, target)).toEqual([
|
||||
{
|
||||
override: source,
|
||||
type: 'OverrideUpdate',
|
||||
reason: expect.stringContaining('value is different'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Comparer, DatabaseOverride, Reason } from 'src/types';
|
||||
|
||||
export const compareOverrides = (): Comparer<DatabaseOverride> => ({
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'OverrideCreate',
|
||||
override: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'OverrideDrop',
|
||||
overrideName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => {
|
||||
if (source.value.name !== target.value.name || source.value.sql !== target.value.sql) {
|
||||
const sourceValue = JSON.stringify(source.value);
|
||||
const targetValue = JSON.stringify(target.value);
|
||||
return [
|
||||
{ type: 'OverrideUpdate', override: source, reason: `value is different (${sourceValue} vs ${targetValue})` },
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { compareParameters } from 'src/comparers/parameter.comparer';
|
||||
import { DatabaseParameter, Reason } from 'src/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: 'ParameterReset',
|
||||
databaseName: 'immich',
|
||||
parameterName: 'test',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareParameters().onMissing(testParameter)).toEqual([
|
||||
{
|
||||
type: 'ParameterSet',
|
||||
parameter: testParameter,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareParameters().onCompare(testParameter, testParameter)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Comparer, DatabaseParameter, Reason } from 'src/types';
|
||||
|
||||
export const compareParameters = (): Comparer<DatabaseParameter> => ({
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'ParameterSet',
|
||||
parameter: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'ParameterReset',
|
||||
databaseName: target.databaseName,
|
||||
parameterName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: () => {
|
||||
// TODO
|
||||
return [];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { compareTables } from 'src/comparers/table.comparer';
|
||||
import { DatabaseTable, Reason } from 'src/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: 'TableDrop',
|
||||
tableName: 'test',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareTables({}).onMissing(testTable)).toEqual([
|
||||
{
|
||||
type: 'TableCreate',
|
||||
table: testTable,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareTables({}).onCompare(testTable, testTable)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { compareColumns } from 'src/comparers/column.comparer';
|
||||
import { compareConstraints } from 'src/comparers/constraint.comparer';
|
||||
import { compareIndexes } from 'src/comparers/index.comparer';
|
||||
import { compareTriggers } from 'src/comparers/trigger.comparer';
|
||||
import { compare } from 'src/helpers';
|
||||
import { Comparer, DatabaseTable, Reason, SchemaDiffOptions } from 'src/types';
|
||||
|
||||
export const compareTables = (options: SchemaDiffOptions): Comparer<DatabaseTable> => ({
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'TableCreate',
|
||||
table: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'TableDrop',
|
||||
tableName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => {
|
||||
return [
|
||||
...compare(source.columns, target.columns, options.columns, compareColumns()),
|
||||
...compare(source.indexes, target.indexes, options.indexes, compareIndexes()),
|
||||
...compare(source.constraints, target.constraints, options.constraints, compareConstraints()),
|
||||
...compare(source.triggers, target.triggers, options.triggers, compareTriggers()),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { compareTriggers } from 'src/comparers/trigger.comparer';
|
||||
import { DatabaseTrigger, Reason } from 'src/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: 'TriggerDrop',
|
||||
tableName: 'table1',
|
||||
triggerName: 'test',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareTriggers().onMissing(testTrigger)).toEqual([
|
||||
{
|
||||
type: 'TriggerCreate',
|
||||
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: 'TriggerCreate', 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: 'TriggerCreate', 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: 'TriggerCreate', 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: 'TriggerCreate', 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: 'TriggerCreate', 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: 'TriggerCreate', trigger: source, reason }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Comparer, DatabaseTrigger, Reason } from 'src/types';
|
||||
|
||||
export const compareTriggers = (): Comparer<DatabaseTrigger> => ({
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'TriggerCreate',
|
||||
trigger: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'TriggerDrop',
|
||||
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: 'TriggerCreate', trigger: source, reason }];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { DefaultNamingStrategy } from 'src/naming/default.naming';
|
||||
import { HashNamingStrategy } from 'src/naming/hash.naming';
|
||||
import { NamingInterface, NamingItem } from 'src/naming/naming.interface';
|
||||
import {
|
||||
BaseContextOptions,
|
||||
DatabaseEnum,
|
||||
DatabaseExtension,
|
||||
DatabaseFunction,
|
||||
DatabaseOverride,
|
||||
DatabaseParameter,
|
||||
DatabaseSchema,
|
||||
DatabaseTable,
|
||||
} from 'src/types';
|
||||
|
||||
const asOverrideKey = (type: string, name: string) => `${type}:${name}`;
|
||||
|
||||
const isNamingInterface = (strategy: string | NamingInterface): strategy is NamingInterface => {
|
||||
return typeof strategy === 'object' && typeof strategy.getName === 'function';
|
||||
};
|
||||
|
||||
const asNamingStrategy = (strategy: 'hash' | 'default' | NamingInterface): NamingInterface => {
|
||||
if (isNamingInterface(strategy)) {
|
||||
return strategy;
|
||||
}
|
||||
|
||||
switch (strategy) {
|
||||
case 'hash': {
|
||||
return new HashNamingStrategy();
|
||||
}
|
||||
|
||||
default: {
|
||||
return new DefaultNamingStrategy();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export class BaseContext {
|
||||
databaseName: string;
|
||||
schemaName: string;
|
||||
overrideTableName: string;
|
||||
|
||||
tables: DatabaseTable[] = [];
|
||||
functions: DatabaseFunction[] = [];
|
||||
enums: DatabaseEnum[] = [];
|
||||
extensions: DatabaseExtension[] = [];
|
||||
parameters: DatabaseParameter[] = [];
|
||||
overrides: DatabaseOverride[] = [];
|
||||
warnings: string[] = [];
|
||||
|
||||
private namingStrategy: NamingInterface;
|
||||
|
||||
constructor(options: BaseContextOptions) {
|
||||
this.databaseName = options.databaseName ?? 'postgres';
|
||||
this.schemaName = options.schemaName ?? 'public';
|
||||
this.overrideTableName = options.overrideTableName ?? 'migration_overrides';
|
||||
this.namingStrategy = asNamingStrategy(options.namingStrategy ?? 'hash');
|
||||
}
|
||||
|
||||
getNameFor(item: NamingItem) {
|
||||
return this.namingStrategy.getName(item);
|
||||
}
|
||||
|
||||
getTableByName(name: string) {
|
||||
return this.tables.find((table) => table.name === name);
|
||||
}
|
||||
|
||||
warn(context: string, message: string) {
|
||||
this.warnings.push(`[${context}] ${message}`);
|
||||
}
|
||||
|
||||
build(): DatabaseSchema {
|
||||
const overrideMap = new Map<string, DatabaseOverride>();
|
||||
for (const override of this.overrides) {
|
||||
const { type, name } = override.value;
|
||||
overrideMap.set(asOverrideKey(type, name), override);
|
||||
}
|
||||
|
||||
for (const func of this.functions) {
|
||||
func.override = overrideMap.get(asOverrideKey('function', func.name));
|
||||
}
|
||||
|
||||
for (const { indexes, triggers } of this.tables) {
|
||||
for (const index of indexes) {
|
||||
index.override = overrideMap.get(asOverrideKey('index', index.name));
|
||||
}
|
||||
|
||||
for (const trigger of triggers) {
|
||||
trigger.override = overrideMap.get(asOverrideKey('trigger', trigger.name));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
databaseName: this.databaseName,
|
||||
schemaName: this.schemaName,
|
||||
tables: this.tables,
|
||||
functions: this.functions,
|
||||
enums: this.enums,
|
||||
extensions: this.extensions,
|
||||
parameters: this.parameters,
|
||||
overrides: this.overrides,
|
||||
warnings: this.warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
|
||||
import { BaseContext } from 'src/contexts/base-context';
|
||||
import { ColumnOptions } from 'src/decorators/column.decorator';
|
||||
import { TableOptions } from 'src/decorators/table.decorator';
|
||||
import { DatabaseColumn, DatabaseTable, SchemaFromCodeOptions } from 'src/types';
|
||||
|
||||
type TableMetadata = { options: TableOptions; object: Function; methodToColumn: Map<string | symbol, DatabaseColumn> };
|
||||
|
||||
export class ProcessorContext extends BaseContext {
|
||||
constructor(public options: SchemaFromCodeOptions) {
|
||||
options.createForeignKeyIndexes = options.createForeignKeyIndexes ?? true;
|
||||
options.overrides = options.overrides ?? false;
|
||||
super(options);
|
||||
}
|
||||
|
||||
classToTable: WeakMap<Function, DatabaseTable> = new WeakMap();
|
||||
tableToMetadata: WeakMap<DatabaseTable, TableMetadata> = new WeakMap();
|
||||
|
||||
getTableByObject(object: Function) {
|
||||
return this.classToTable.get(object);
|
||||
}
|
||||
|
||||
getTableMetadata(table: DatabaseTable) {
|
||||
const metadata = this.tableToMetadata.get(table);
|
||||
if (!metadata) {
|
||||
throw new Error(`Table metadata not found for table: ${table.name}`);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
addTable(table: DatabaseTable, options: TableOptions, object: Function) {
|
||||
this.tables.push(table);
|
||||
this.classToTable.set(object, table);
|
||||
this.tableToMetadata.set(table, { options, object, methodToColumn: new Map() });
|
||||
}
|
||||
|
||||
getColumnByObjectAndPropertyName(
|
||||
object: object,
|
||||
propertyName: string | symbol,
|
||||
): { table?: DatabaseTable; column?: DatabaseColumn } {
|
||||
const table = this.getTableByObject(object.constructor);
|
||||
if (!table) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const tableMetadata = this.tableToMetadata.get(table);
|
||||
if (!tableMetadata) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const column = tableMetadata.methodToColumn.get(propertyName);
|
||||
|
||||
return { table, column };
|
||||
}
|
||||
|
||||
addColumn(table: DatabaseTable, column: DatabaseColumn, options: ColumnOptions, propertyName: string | symbol) {
|
||||
table.columns.push(column);
|
||||
const tableMetadata = this.getTableMetadata(table);
|
||||
tableMetadata.methodToColumn.set(propertyName, column);
|
||||
}
|
||||
|
||||
warnMissingTable(context: string, object: object, propertyName?: symbol | string) {
|
||||
const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : '');
|
||||
this.warn(context, `Unable to find table (${label})`);
|
||||
}
|
||||
|
||||
warnMissingColumn(context: string, object: object, propertyName?: symbol | string) {
|
||||
const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : '');
|
||||
this.warn(context, `Unable to find column (${label})`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { BaseContext } from 'src/contexts/base-context';
|
||||
import { SchemaFromDatabaseOptions } from 'src/types';
|
||||
|
||||
export class ReaderContext extends BaseContext {
|
||||
constructor(public options: SchemaFromDatabaseOptions) {
|
||||
super(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { TriggerFunction, TriggerFunctionOptions } from 'src/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/decorators/trigger-function.decorator';
|
||||
|
||||
export const AfterInsertTrigger = (options: Omit<TriggerFunctionOptions, 'timing' | 'actions'>) =>
|
||||
TriggerFunction({
|
||||
timing: 'after',
|
||||
actions: ['insert'],
|
||||
...options,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { TriggerFunction, TriggerFunctionOptions } from 'src/decorators/trigger-function.decorator';
|
||||
|
||||
export const BeforeUpdateTrigger = (options: Omit<TriggerFunctionOptions, 'timing' | 'actions'>) =>
|
||||
TriggerFunction({
|
||||
timing: 'before',
|
||||
actions: ['update'],
|
||||
...options,
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { register } from 'src/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,32 @@
|
||||
import { asOptions } from 'src/helpers';
|
||||
import { register } from 'src/register';
|
||||
import { ColumnStorage, ColumnType, DatabaseEnum } from 'src/types';
|
||||
|
||||
export type ColumnValue = null | boolean | string | number | Array<unknown> | 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;
|
||||
index?: boolean;
|
||||
indexName?: string;
|
||||
unique?: boolean;
|
||||
uniqueConstraintName?: string;
|
||||
};
|
||||
|
||||
export type ColumnOptions = ColumnBaseOptions & {
|
||||
enum?: DatabaseEnum;
|
||||
array?: boolean;
|
||||
};
|
||||
|
||||
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/decorators/column.decorator';
|
||||
import { register } from 'src/register';
|
||||
import { ParameterScope } from 'src/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/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/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/decorators/column.decorator';
|
||||
|
||||
export const DeleteDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
|
||||
return Column({
|
||||
type: 'timestamp with time zone',
|
||||
nullable: true,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { asOptions } from 'src/helpers';
|
||||
import { register } from 'src/register';
|
||||
|
||||
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 { asOptions } from 'src/helpers';
|
||||
import { register } from 'src/register';
|
||||
|
||||
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,16 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
|
||||
import { ForeignKeyAction } from 'src//decorators/foreign-key-constraint.decorator';
|
||||
import { ColumnBaseOptions } from 'src/decorators/column.decorator';
|
||||
import { register } from 'src/register';
|
||||
|
||||
export type ForeignKeyColumnOptions = ColumnBaseOptions & {
|
||||
onUpdate?: ForeignKeyAction;
|
||||
onDelete?: ForeignKeyAction;
|
||||
constraintName?: string;
|
||||
};
|
||||
|
||||
export const ForeignKeyColumn = (target: () => Function, options: ForeignKeyColumnOptions): PropertyDecorator => {
|
||||
return (object: object, propertyName: string | symbol) => {
|
||||
register({ type: 'foreignKeyColumn', item: { object, propertyName, options, target } });
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { register } from 'src/register';
|
||||
|
||||
export type ForeignKeyAction = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION';
|
||||
|
||||
export type ForeignKeyConstraintOptions = {
|
||||
name?: string;
|
||||
index?: boolean;
|
||||
indexName?: string;
|
||||
columns: string[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
referenceTable: () => Function;
|
||||
referenceColumns?: string[];
|
||||
onUpdate?: ForeignKeyAction;
|
||||
onDelete?: ForeignKeyAction;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
|
||||
export const ForeignKeyConstraint = (options: ForeignKeyConstraintOptions): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (target: Function) => {
|
||||
register({ type: 'foreignKeyConstraint', item: { object: target, options } });
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Column, ColumnOptions, ColumnValue } from 'src/decorators/column.decorator';
|
||||
import { ColumnType } from 'src/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,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { asOptions } from 'src/helpers';
|
||||
import { register } from 'src/register';
|
||||
|
||||
export type IndexOptions = {
|
||||
name?: string;
|
||||
unique?: boolean;
|
||||
expression?: string;
|
||||
using?: string;
|
||||
with?: string;
|
||||
where?: string;
|
||||
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/decorators/column.decorator';
|
||||
|
||||
export const PrimaryColumn = (options: Omit<ColumnOptions, 'primary'> = {}) => Column({ ...options, primary: true });
|
||||
@@ -0,0 +1,4 @@
|
||||
import { GenerateColumnOptions, GeneratedColumn } from 'src/decorators/generated-column.decorator';
|
||||
|
||||
export const PrimaryGeneratedColumn = (options: Omit<GenerateColumnOptions, 'primary'> = {}) =>
|
||||
GeneratedColumn({ ...options, primary: true });
|
||||
@@ -0,0 +1,14 @@
|
||||
import { asOptions } from 'src/helpers';
|
||||
import { register } from 'src/register';
|
||||
|
||||
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,10 @@
|
||||
import { Trigger, TriggerOptions } from 'src/decorators/trigger.decorator';
|
||||
import { DatabaseFunction } from 'src/types';
|
||||
|
||||
export type TriggerFunctionOptions = Omit<TriggerOptions, 'functionName'> & { function: DatabaseFunction };
|
||||
export const TriggerFunction = (options: TriggerFunctionOptions) =>
|
||||
Trigger({
|
||||
name: options.function.name,
|
||||
...options,
|
||||
functionName: options.function.name,
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { register } from 'src/register';
|
||||
import { TriggerAction, TriggerScope, TriggerTiming } from 'src/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/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/decorators/column.decorator';
|
||||
|
||||
export const UpdateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
|
||||
return Column({
|
||||
type: 'timestamp with time zone',
|
||||
default: () => 'now()',
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,247 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { ColumnValue } from 'src/decorators/column.decorator';
|
||||
import { Comparer, DatabaseColumn, DatabaseOverride, IgnoreOptions, SchemaDiff } from 'src/types';
|
||||
|
||||
export const asOptions = <T extends { name?: string }>(options: string | T): T => {
|
||||
if (typeof options === 'string') {
|
||||
return { name: options } as T;
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
export const sha1 = (value: string) => createHash('sha1').update(value).digest('hex');
|
||||
|
||||
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()}'`;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return "'{}'";
|
||||
}
|
||||
|
||||
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 haveEqualOverrides = <T extends { override?: DatabaseOverride }>(source: T, target: T) => {
|
||||
if (!source.override || !target.override) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourceValue = source.override.value;
|
||||
const targetValue = target.override.value;
|
||||
|
||||
return sourceValue.name === targetValue.name && sourceValue.sql === targetValue.sql;
|
||||
};
|
||||
|
||||
export const compare = <T extends { name: string; synchronize: boolean }>(
|
||||
sources: T[],
|
||||
targets: T[],
|
||||
options: IgnoreOptions | 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)]);
|
||||
const missingKeys = new Set<string>();
|
||||
const extraKeys = new Set<string>();
|
||||
|
||||
// common keys
|
||||
for (const key of keys) {
|
||||
const source = sourceMap[key];
|
||||
const target = targetMap[key];
|
||||
|
||||
if (isIgnored(source, target, options ?? true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSynchronizeDisabled(source, target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source && !target) {
|
||||
missingKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!source && target) {
|
||||
extraKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
haveEqualOverrides(
|
||||
source as unknown as { override?: DatabaseOverride },
|
||||
target as unknown as { override?: DatabaseOverride },
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push(...comparer.onCompare(source, target));
|
||||
}
|
||||
|
||||
// renames
|
||||
if (comparer.getRenameKey && comparer.onRename) {
|
||||
const renameMap: Record<string, string> = {};
|
||||
for (const sourceKey of missingKeys) {
|
||||
const source = sourceMap[sourceKey];
|
||||
const renameKey = comparer.getRenameKey(source);
|
||||
renameMap[renameKey] = sourceKey;
|
||||
}
|
||||
|
||||
for (const targetKey of extraKeys) {
|
||||
const target = targetMap[targetKey];
|
||||
const renameKey = comparer.getRenameKey(target);
|
||||
const sourceKey = renameMap[renameKey];
|
||||
if (!sourceKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const source = sourceMap[sourceKey];
|
||||
|
||||
items.push(...comparer.onRename(source, target));
|
||||
|
||||
missingKeys.delete(sourceKey);
|
||||
extraKeys.delete(targetKey);
|
||||
}
|
||||
}
|
||||
|
||||
// missing
|
||||
for (const key of missingKeys) {
|
||||
items.push(...comparer.onMissing(sourceMap[key]));
|
||||
}
|
||||
|
||||
// extra
|
||||
for (const key of extraKeys) {
|
||||
items.push(...comparer.onExtra(targetMap[key]));
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const isIgnored = (
|
||||
source: { synchronize?: boolean } | undefined,
|
||||
target: { synchronize?: boolean } | undefined,
|
||||
options: IgnoreOptions,
|
||||
) => {
|
||||
if (typeof options === 'boolean') {
|
||||
return !options;
|
||||
}
|
||||
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 ||
|
||||
withTypeCast(target.default, getColumnType(target)) === source.default
|
||||
) {
|
||||
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(', ');
|
||||
|
||||
export const asJsonString = (value: unknown): string => {
|
||||
return `'${escape(JSON.stringify(value))}'::jsonb`;
|
||||
};
|
||||
|
||||
const escape = (value: string) => {
|
||||
return value
|
||||
.replaceAll("'", "''")
|
||||
.replaceAll(/[\\]/g, '\\\\')
|
||||
.replaceAll(/[\b]/g, String.raw`\b`)
|
||||
.replaceAll(/[\f]/g, String.raw`\f`)
|
||||
.replaceAll(/[\n]/g, String.raw`\n`)
|
||||
.replaceAll(/[\r]/g, String.raw`\r`)
|
||||
.replaceAll(/[\t]/g, String.raw`\t`);
|
||||
};
|
||||
|
||||
export const asRenameKey = (values: Array<string | boolean | number | undefined>) =>
|
||||
values.map((value) => value ?? '').join('|');
|
||||
@@ -0,0 +1,31 @@
|
||||
export * from 'src/decorators/after-delete.decorator';
|
||||
export * from 'src/decorators/after-insert.decorator';
|
||||
export * from 'src/decorators/before-update.decorator';
|
||||
export * from 'src/decorators/check.decorator';
|
||||
export * from 'src/decorators/column.decorator';
|
||||
export * from 'src/decorators/configuration-parameter.decorator';
|
||||
export * from 'src/decorators/create-date-column.decorator';
|
||||
export * from 'src/decorators/database.decorator';
|
||||
export * from 'src/decorators/delete-date-column.decorator';
|
||||
export * from 'src/decorators/extension.decorator';
|
||||
export * from 'src/decorators/extensions.decorator';
|
||||
export * from 'src/decorators/foreign-key-column.decorator';
|
||||
export * from 'src/decorators/foreign-key-constraint.decorator';
|
||||
export * from 'src/decorators/generated-column.decorator';
|
||||
export * from 'src/decorators/index.decorator';
|
||||
export * from 'src/decorators/primary-column.decorator';
|
||||
export * from 'src/decorators/primary-generated-column.decorator';
|
||||
export * from 'src/decorators/table.decorator';
|
||||
export * from 'src/decorators/trigger-function.decorator';
|
||||
export * from 'src/decorators/trigger.decorator';
|
||||
export * from 'src/decorators/unique.decorator';
|
||||
export * from 'src/decorators/update-date-column.decorator';
|
||||
export * from 'src/naming/default.naming';
|
||||
export * from 'src/naming/hash.naming';
|
||||
export * from 'src/naming/naming.interface';
|
||||
export * from 'src/register-enum';
|
||||
export * from 'src/register-function';
|
||||
export { schemaDiff, schemaDiffToSql } from 'src/schema-diff';
|
||||
export { schemaFromCode } from 'src/schema-from-code';
|
||||
export { schemaFromDatabase } from 'src/schema-from-database';
|
||||
export * from 'src/types';
|
||||
@@ -0,0 +1,50 @@
|
||||
import { sha1 } from 'src/helpers';
|
||||
import { NamingItem } from 'src/naming/naming.interface';
|
||||
|
||||
const asSnakeCase = (name: string): string => name.replaceAll(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
|
||||
|
||||
export class DefaultNamingStrategy {
|
||||
getName(item: NamingItem): string {
|
||||
switch (item.type) {
|
||||
case 'database': {
|
||||
return asSnakeCase(item.name);
|
||||
}
|
||||
|
||||
case 'table': {
|
||||
return asSnakeCase(item.name);
|
||||
}
|
||||
|
||||
case 'column': {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
case 'primaryKey': {
|
||||
return `${item.tableName}_pkey`;
|
||||
}
|
||||
|
||||
case 'foreignKey': {
|
||||
return `${item.tableName}_${item.columnNames.join('_')}_fkey`;
|
||||
}
|
||||
|
||||
case 'check': {
|
||||
return `${item.tableName}_${sha1(item.expression).slice(0, 8)}_chk`;
|
||||
}
|
||||
|
||||
case 'unique': {
|
||||
return `${item.tableName}_${item.columnNames.join('_')}_uq`;
|
||||
}
|
||||
|
||||
case 'index': {
|
||||
if (item.columnNames) {
|
||||
return `${item.tableName}_${item.columnNames.join('_')}_idx`;
|
||||
}
|
||||
|
||||
return `${item.tableName}_${sha1(item.expression || item.where || '').slice(0, 8)}_idx`;
|
||||
}
|
||||
|
||||
case 'trigger': {
|
||||
return `${item.tableName}_${item.functionName}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { sha1 } from 'src/helpers';
|
||||
import { DefaultNamingStrategy } from 'src/naming/default.naming';
|
||||
import { NamingInterface, NamingItem } from 'src/naming/naming.interface';
|
||||
|
||||
const fallback = new DefaultNamingStrategy();
|
||||
|
||||
const asKey = (prefix: string, tableName: string, values: string[]) =>
|
||||
(prefix + sha1(`${tableName}_${values.toSorted().join('_')}`)).slice(0, 30);
|
||||
|
||||
export class HashNamingStrategy implements NamingInterface {
|
||||
getName(item: NamingItem): string {
|
||||
switch (item.type) {
|
||||
case 'primaryKey': {
|
||||
return asKey('PK_', item.tableName, item.columnNames);
|
||||
}
|
||||
|
||||
case 'foreignKey': {
|
||||
return asKey('FK_', item.tableName, item.columnNames);
|
||||
}
|
||||
|
||||
case 'check': {
|
||||
return asKey('CHK_', item.tableName, [item.expression]);
|
||||
}
|
||||
|
||||
case 'unique': {
|
||||
return asKey('UQ_', item.tableName, item.columnNames);
|
||||
}
|
||||
|
||||
case 'index': {
|
||||
const items: string[] = [];
|
||||
for (const columnName of item.columnNames ?? []) {
|
||||
items.push(columnName);
|
||||
}
|
||||
|
||||
if (item.where) {
|
||||
items.push(item.where);
|
||||
}
|
||||
|
||||
return asKey('IDX_', item.tableName, items);
|
||||
}
|
||||
|
||||
case 'trigger': {
|
||||
return asKey('TR_', item.tableName, [...item.actions, item.scope, item.timing, item.functionName]);
|
||||
}
|
||||
|
||||
default: {
|
||||
return fallback.getName(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { TriggerAction, TriggerScope, TriggerTiming } from 'src/types';
|
||||
|
||||
export type NamingItem =
|
||||
| {
|
||||
type: 'database';
|
||||
name: string;
|
||||
}
|
||||
| {
|
||||
type: 'table';
|
||||
name: string;
|
||||
}
|
||||
| {
|
||||
type: 'column';
|
||||
name: string;
|
||||
}
|
||||
| {
|
||||
type: 'primaryKey';
|
||||
tableName: string;
|
||||
columnNames: string[];
|
||||
}
|
||||
| {
|
||||
type: 'foreignKey';
|
||||
tableName: string;
|
||||
columnNames: string[];
|
||||
referenceTableName: string;
|
||||
referenceColumnNames: string[];
|
||||
}
|
||||
| {
|
||||
type: 'check';
|
||||
tableName: string;
|
||||
expression: string;
|
||||
}
|
||||
| {
|
||||
type: 'unique';
|
||||
tableName: string;
|
||||
columnNames: string[];
|
||||
}
|
||||
| {
|
||||
type: 'index';
|
||||
tableName: string;
|
||||
columnNames?: string[];
|
||||
expression?: string;
|
||||
where?: string;
|
||||
}
|
||||
| {
|
||||
type: 'trigger';
|
||||
tableName: string;
|
||||
functionName: string;
|
||||
actions: TriggerAction[];
|
||||
scope: TriggerScope;
|
||||
timing: TriggerTiming;
|
||||
columnNames?: string[];
|
||||
expression?: string;
|
||||
where?: string;
|
||||
};
|
||||
|
||||
export interface NamingInterface {
|
||||
getName(item: NamingItem): string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ConstraintType, Processor } from 'src/types';
|
||||
|
||||
export const processCheckConstraints: Processor = (ctx, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'checkConstraint')) {
|
||||
const table = ctx.getTableByObject(object);
|
||||
if (!table) {
|
||||
ctx.warnMissingTable('@Check', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tableName = table.name;
|
||||
|
||||
table.constraints.push({
|
||||
type: ConstraintType.CHECK,
|
||||
name: options.name || ctx.getNameFor({ type: 'check', tableName, expression: options.expression }),
|
||||
tableName,
|
||||
expression: options.expression,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ColumnOptions } from 'src/decorators/column.decorator';
|
||||
import { fromColumnValue } from 'src/helpers';
|
||||
import { Processor } from 'src/types';
|
||||
|
||||
export const processColumns: Processor = (ctx, items) => {
|
||||
for (const {
|
||||
type,
|
||||
item: { object, propertyName, options },
|
||||
} of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) {
|
||||
const table = ctx.getTableByObject(object.constructor);
|
||||
if (!table) {
|
||||
ctx.warnMissingTable(type === 'column' ? '@Column' : '@ForeignKeyColumn', object, propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const columnName = options.name ?? ctx.getNameFor({ type: 'column', name: String(propertyName) });
|
||||
const existingColumn = table.columns.find((column) => column.name === columnName);
|
||||
if (existingColumn) {
|
||||
// TODO log warnings if column name is not unique
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
ctx.addColumn(
|
||||
table,
|
||||
{
|
||||
name: columnName,
|
||||
tableName: table.name,
|
||||
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,
|
||||
},
|
||||
options,
|
||||
propertyName,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { fromColumnValue } from 'src/helpers';
|
||||
import { Processor } from 'src/types';
|
||||
|
||||
export const processConfigurationParameters: Processor = (ctx, items) => {
|
||||
for (const {
|
||||
item: { options },
|
||||
} of items.filter((item) => item.type === 'configurationParameter')) {
|
||||
ctx.parameters.push({
|
||||
databaseName: ctx.databaseName,
|
||||
name: options.name,
|
||||
value: fromColumnValue(options.value),
|
||||
scope: options.scope,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Processor } from 'src/types';
|
||||
|
||||
export const processDatabases: Processor = (ctx, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'database')) {
|
||||
ctx.databaseName = options.name || ctx.getNameFor({ type: 'database', name: object.name });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Processor } from 'src/types';
|
||||
|
||||
export const processEnums: Processor = (ctx, items) => {
|
||||
for (const { item } of items.filter((item) => item.type === 'enum')) {
|
||||
// TODO log warnings if enum name is not unique
|
||||
ctx.enums.push(item);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Processor } from 'src/types';
|
||||
|
||||
export const processExtensions: Processor = (ctx, items) => {
|
||||
if (ctx.options.extensions === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const {
|
||||
item: { options },
|
||||
} of items.filter((item) => item.type === 'extension')) {
|
||||
ctx.extensions.push({
|
||||
name: options.name,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ActionType, ConstraintType, Processor } from 'src/types';
|
||||
|
||||
export const processForeignKeyColumns: Processor = (ctx, items) => {
|
||||
for (const {
|
||||
item: { object, propertyName, options, target },
|
||||
} of items.filter((item) => item.type === 'foreignKeyColumn')) {
|
||||
const { table, column } = ctx.getColumnByObjectAndPropertyName(object, propertyName);
|
||||
if (!table) {
|
||||
ctx.warnMissingTable('@ForeignKeyColumn', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!column) {
|
||||
// should be impossible since they are pre-created in `column.processor.ts`
|
||||
ctx.warnMissingColumn('@ForeignKeyColumn', object, propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const referenceTable = ctx.getTableByObject(target());
|
||||
if (!referenceTable) {
|
||||
ctx.warnMissingTable('@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;
|
||||
}
|
||||
|
||||
const referenceTableName = referenceTable.name;
|
||||
const referenceColumnNames = referenceColumns.map((column) => column.name);
|
||||
const name =
|
||||
options.constraintName ||
|
||||
ctx.getNameFor({
|
||||
type: 'foreignKey',
|
||||
tableName: table.name,
|
||||
columnNames,
|
||||
referenceTableName,
|
||||
referenceColumnNames,
|
||||
});
|
||||
|
||||
table.constraints.push({
|
||||
name,
|
||||
tableName: table.name,
|
||||
columnNames,
|
||||
type: ConstraintType.FOREIGN_KEY,
|
||||
referenceTableName,
|
||||
referenceColumnNames,
|
||||
onUpdate: options.onUpdate as ActionType,
|
||||
onDelete: options.onDelete as ActionType,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
|
||||
if (options.unique || options.uniqueConstraintName) {
|
||||
table.constraints.push({
|
||||
name: options.uniqueConstraintName || ctx.getNameFor({ type: 'unique', tableName: table.name, columnNames }),
|
||||
tableName: table.name,
|
||||
columnNames,
|
||||
type: ConstraintType.UNIQUE,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ActionType, ConstraintType, Processor } from 'src/types';
|
||||
|
||||
export const processForeignKeyConstraints: Processor = (ctx, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'foreignKeyConstraint')) {
|
||||
const table = ctx.getTableByObject(object);
|
||||
if (!table) {
|
||||
ctx.warnMissingTable('@ForeignKeyConstraint', { name: 'referenceTable' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const referenceTable = ctx.getTableByObject(options.referenceTable());
|
||||
if (!referenceTable) {
|
||||
const referenceTableName = options.referenceTable()?.name;
|
||||
ctx.warn(
|
||||
'@ForeignKeyConstraint.referenceTable',
|
||||
`Unable to find table` + (referenceTableName ? ` (${referenceTableName})` : ''),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let missingColumn = false;
|
||||
|
||||
for (const columnName of options.columns) {
|
||||
if (!table.columns.some(({ name }) => name === columnName)) {
|
||||
const metadata = ctx.getTableMetadata(table);
|
||||
ctx.warn('@ForeignKeyConstraint.columns', `Unable to find column (${metadata.object.name}.${columnName})`);
|
||||
missingColumn = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const columnName of options.referenceColumns || []) {
|
||||
if (!referenceTable.columns.some(({ name }) => name === columnName)) {
|
||||
const metadata = ctx.getTableMetadata(referenceTable);
|
||||
ctx.warn(
|
||||
'@ForeignKeyConstraint.referenceColumns',
|
||||
`Unable to find column (${metadata.object.name}.${columnName})`,
|
||||
);
|
||||
missingColumn = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (missingColumn) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const referenceTableName = referenceTable.name;
|
||||
const referenceColumnNames =
|
||||
options.referenceColumns || referenceTable.columns.filter(({ primary }) => primary).map(({ name }) => name);
|
||||
|
||||
const name =
|
||||
options.name ||
|
||||
ctx.getNameFor({
|
||||
type: 'foreignKey',
|
||||
tableName: table.name,
|
||||
columnNames: options.columns,
|
||||
referenceTableName,
|
||||
referenceColumnNames,
|
||||
});
|
||||
|
||||
table.constraints.push({
|
||||
type: ConstraintType.FOREIGN_KEY,
|
||||
name,
|
||||
tableName: table.name,
|
||||
columnNames: options.columns,
|
||||
referenceTableName,
|
||||
referenceColumnNames,
|
||||
onUpdate: options.onUpdate as ActionType,
|
||||
onDelete: options.onDelete as ActionType,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
|
||||
if (options.index === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.index || options.indexName || ctx.options.createForeignKeyIndexes) {
|
||||
const indexName =
|
||||
options.indexName ||
|
||||
ctx.getNameFor({
|
||||
type: 'index',
|
||||
tableName: table.name,
|
||||
columnNames: options.columns,
|
||||
});
|
||||
table.indexes.push({
|
||||
name: indexName,
|
||||
tableName: table.name,
|
||||
columnNames: options.columns,
|
||||
unique: false,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Processor } from 'src/types';
|
||||
|
||||
export const processFunctions: Processor = (ctx, items) => {
|
||||
if (ctx.options.functions === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { item } of items.filter((item) => item.type === 'function')) {
|
||||
// TODO log warnings if function name is not unique
|
||||
ctx.functions.push(item);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Processor } from 'src/types';
|
||||
|
||||
export const processIndexes: Processor = (ctx, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'index')) {
|
||||
const table = ctx.getTableByObject(object);
|
||||
if (!table) {
|
||||
ctx.warnMissingTable('@Check', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
const indexName =
|
||||
options.name ||
|
||||
ctx.getNameFor({
|
||||
type: 'index',
|
||||
tableName: table.name,
|
||||
columnNames: options.columns,
|
||||
where: options.where,
|
||||
});
|
||||
|
||||
table.indexes.push({
|
||||
name: indexName,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// column indexes
|
||||
for (const {
|
||||
type,
|
||||
item: { object, propertyName, options },
|
||||
} of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) {
|
||||
const { table, column } = ctx.getColumnByObjectAndPropertyName(object, propertyName);
|
||||
if (!table) {
|
||||
ctx.warnMissingTable('@Column', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!column) {
|
||||
// should be impossible since they are created in `column.processor.ts`
|
||||
ctx.warnMissingColumn('@Column', object, propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.index === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isIndexRequested =
|
||||
options.indexName || options.index || (type === 'foreignKeyColumn' && ctx.options.createForeignKeyIndexes);
|
||||
if (!isIndexRequested) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const indexName =
|
||||
options.indexName ||
|
||||
ctx.getNameFor({
|
||||
type: 'index',
|
||||
tableName: table.name,
|
||||
columnNames: [column.name],
|
||||
});
|
||||
|
||||
const isIndexPresent = table.indexes.some((index) => index.name === indexName);
|
||||
if (isIndexPresent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isOnlyPrimaryColumn = options.primary && table.columns.filter(({ primary }) => primary === true).length === 1;
|
||||
if (isOnlyPrimaryColumn) {
|
||||
// will have an index created by the primary key constraint
|
||||
continue;
|
||||
}
|
||||
|
||||
table.indexes.push({
|
||||
name: indexName,
|
||||
tableName: table.name,
|
||||
unique: false,
|
||||
columnNames: [column.name],
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { processCheckConstraints } from 'src/processors/check-constraint.processor';
|
||||
import { processColumns } from 'src/processors/column.processor';
|
||||
import { processConfigurationParameters } from 'src/processors/configuration-parameter.processor';
|
||||
import { processDatabases } from 'src/processors/database.processor';
|
||||
import { processEnums } from 'src/processors/enum.processor';
|
||||
import { processExtensions } from 'src/processors/extension.processor';
|
||||
import { processForeignKeyColumns } from 'src/processors/foreign-key-column.processor';
|
||||
import { processForeignKeyConstraints } from 'src/processors/foreign-key-constraint.processor';
|
||||
import { processFunctions } from 'src/processors/function.processor';
|
||||
import { processIndexes } from 'src/processors/index.processor';
|
||||
import { processOverrides } from 'src/processors/override.processor';
|
||||
import { processPrimaryKeyConstraints } from 'src/processors/primary-key-contraint.processor';
|
||||
import { processTables } from 'src/processors/table.processor';
|
||||
import { processTriggers } from 'src/processors/trigger.processor';
|
||||
import { processUniqueConstraints } from 'src/processors/unique-constraint.processor';
|
||||
import { Processor } from 'src/types';
|
||||
|
||||
export const processors: Processor[] = [
|
||||
processDatabases,
|
||||
processConfigurationParameters,
|
||||
processEnums,
|
||||
processExtensions,
|
||||
processFunctions,
|
||||
processTables,
|
||||
processColumns,
|
||||
processForeignKeyColumns,
|
||||
processForeignKeyConstraints,
|
||||
processUniqueConstraints,
|
||||
processCheckConstraints,
|
||||
processPrimaryKeyConstraints,
|
||||
processIndexes,
|
||||
processTriggers,
|
||||
processOverrides,
|
||||
];
|
||||
@@ -0,0 +1,50 @@
|
||||
import { asFunctionCreate } from 'src/transformers/function.transformer';
|
||||
import { asIndexCreate } from 'src/transformers/index.transformer';
|
||||
import { asTriggerCreate } from 'src/transformers/trigger.transformer';
|
||||
import { Processor } from 'src/types';
|
||||
|
||||
export const processOverrides: Processor = (ctx) => {
|
||||
if (ctx.options.overrides === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const func of ctx.functions) {
|
||||
if (!func.synchronize) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.overrides.push({
|
||||
name: `function_${func.name}`,
|
||||
value: { type: 'function', name: func.name, sql: asFunctionCreate(func) },
|
||||
synchronize: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const { triggers, indexes } of ctx.tables) {
|
||||
for (const trigger of triggers) {
|
||||
if (!trigger.synchronize) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.overrides.push({
|
||||
name: `trigger_${trigger.name}`,
|
||||
value: { type: 'trigger', name: trigger.name, sql: asTriggerCreate(trigger) },
|
||||
synchronize: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const index of indexes) {
|
||||
if (!index.synchronize) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index.expression || index.using || index.with || index.where) {
|
||||
ctx.overrides.push({
|
||||
name: `index_${index.name}`,
|
||||
value: { type: 'index', name: index.name, sql: asIndexCreate(index) },
|
||||
synchronize: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ConstraintType, Processor } from 'src/types';
|
||||
|
||||
export const processPrimaryKeyConstraints: Processor = (ctx) => {
|
||||
for (const table of ctx.tables) {
|
||||
const columnNames: string[] = [];
|
||||
|
||||
for (const column of table.columns) {
|
||||
if (column.primary) {
|
||||
columnNames.push(column.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (columnNames.length > 0) {
|
||||
const tableMetadata = ctx.getTableMetadata(table);
|
||||
table.constraints.push({
|
||||
type: ConstraintType.PRIMARY_KEY,
|
||||
name:
|
||||
tableMetadata.options.primaryConstraintName ||
|
||||
ctx.getNameFor({
|
||||
type: 'primaryKey',
|
||||
tableName: table.name,
|
||||
columnNames,
|
||||
}),
|
||||
tableName: table.name,
|
||||
columnNames,
|
||||
synchronize: tableMetadata.options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Processor } from 'src/types';
|
||||
|
||||
export const processTables: Processor = (ctx, items) => {
|
||||
for (const {
|
||||
item: { options, object },
|
||||
} of items.filter((item) => item.type === 'table')) {
|
||||
const test = ctx.getTableByObject(object);
|
||||
if (test) {
|
||||
throw new Error(
|
||||
`Table ${test.name} has already been registered. Does ${object.name} have two @Table() decorators?`,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.addTable(
|
||||
{
|
||||
name: options.name || ctx.getNameFor({ type: 'table', name: object.name }),
|
||||
columns: [],
|
||||
constraints: [],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
synchronize: options.synchronize ?? true,
|
||||
},
|
||||
options,
|
||||
object,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Processor } from 'src/types';
|
||||
|
||||
export const processTriggers: Processor = (ctx, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'trigger')) {
|
||||
const table = ctx.getTableByObject(object);
|
||||
if (!table) {
|
||||
ctx.warnMissingTable('@Trigger', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
const triggerName =
|
||||
options.name ||
|
||||
ctx.getNameFor({
|
||||
type: 'trigger',
|
||||
tableName: table.name,
|
||||
actions: options.actions,
|
||||
scope: options.scope,
|
||||
timing: options.timing,
|
||||
functionName: options.functionName,
|
||||
});
|
||||
|
||||
table.triggers.push({
|
||||
name: triggerName,
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ConstraintType, Processor } from 'src/types';
|
||||
|
||||
export const processUniqueConstraints: Processor = (ctx, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'uniqueConstraint')) {
|
||||
const table = ctx.getTableByObject(object);
|
||||
if (!table) {
|
||||
ctx.warnMissingTable('@Unique', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tableName = table.name;
|
||||
const columnNames = options.columns;
|
||||
|
||||
table.constraints.push({
|
||||
type: ConstraintType.UNIQUE,
|
||||
name: options.name || ctx.getNameFor({ type: 'unique', tableName, columnNames }),
|
||||
tableName,
|
||||
columnNames,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
// column level constraints
|
||||
for (const {
|
||||
type,
|
||||
item: { object, propertyName, options },
|
||||
} of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) {
|
||||
const { table, column } = ctx.getColumnByObjectAndPropertyName(object, propertyName);
|
||||
if (!table) {
|
||||
ctx.warnMissingTable('@Column', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!column) {
|
||||
// should be impossible since they are created in `column.processor.ts`
|
||||
ctx.warnMissingColumn('@Column', object, propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'column' && !options.primary && (options.unique || options.uniqueConstraintName)) {
|
||||
const uniqueConstraintName =
|
||||
options.uniqueConstraintName ||
|
||||
ctx.getNameFor({
|
||||
type: 'unique',
|
||||
tableName: table.name,
|
||||
columnNames: [column.name],
|
||||
});
|
||||
|
||||
table.constraints.push({
|
||||
type: ConstraintType.UNIQUE,
|
||||
name: uniqueConstraintName,
|
||||
tableName: table.name,
|
||||
columnNames: [column.name],
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import { sql } from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { ColumnType, DatabaseColumn, Reader } from 'src/types';
|
||||
|
||||
export const readColumns: Reader = async (ctx, db) => {
|
||||
const columns = await db
|
||||
.selectFrom('information_schema.columns as c')
|
||||
.leftJoin('information_schema.element_types as o', (join) =>
|
||||
join
|
||||
.onRef('c.table_catalog', '=', 'o.object_catalog')
|
||||
.onRef('c.table_schema', '=', 'o.object_schema')
|
||||
.onRef('c.table_name', '=', 'o.object_name')
|
||||
.on('o.object_type', '=', sql.lit('TABLE'))
|
||||
.onRef('c.dtd_identifier', '=', 'o.collection_type_identifier'),
|
||||
)
|
||||
.leftJoin('pg_type as t', (join) =>
|
||||
join.onRef('t.typname', '=', 'c.udt_name').on('c.data_type', '=', sql.lit('USER-DEFINED')),
|
||||
)
|
||||
.leftJoin('pg_enum as e', (join) => join.onRef('e.enumtypid', '=', 't.oid'))
|
||||
.select([
|
||||
'c.table_name',
|
||||
'c.column_name',
|
||||
|
||||
// is ARRAY, USER-DEFINED, or data type
|
||||
'c.data_type',
|
||||
'c.column_default',
|
||||
'c.is_nullable',
|
||||
'c.character_maximum_length',
|
||||
|
||||
// number types
|
||||
'c.numeric_precision',
|
||||
'c.numeric_scale',
|
||||
|
||||
// date types
|
||||
'c.datetime_precision',
|
||||
|
||||
// user defined type
|
||||
'c.udt_catalog',
|
||||
'c.udt_schema',
|
||||
'c.udt_name',
|
||||
|
||||
// data type for ARRAYs
|
||||
'o.data_type as array_type',
|
||||
])
|
||||
.where('table_schema', '=', ctx.schemaName)
|
||||
.execute();
|
||||
|
||||
const enumRaw = await db
|
||||
.selectFrom('pg_type')
|
||||
.innerJoin('pg_namespace', (join) =>
|
||||
join.onRef('pg_namespace.oid', '=', 'pg_type.typnamespace').on('pg_namespace.nspname', '=', ctx.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();
|
||||
|
||||
const enums = enumRaw.map((item) => ({ name: item.name, values: item.values.map(({ value }) => value) }));
|
||||
for (const { name, values } of enums) {
|
||||
ctx.enums.push({ name, values, synchronize: true });
|
||||
}
|
||||
|
||||
const enumMap = Object.fromEntries(enums.map((e) => [e.name, e.values]));
|
||||
// add columns to tables
|
||||
for (const column of columns) {
|
||||
const table = ctx.getTableByName(column.table_name);
|
||||
if (!table) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const columnName = column.column_name;
|
||||
|
||||
const item: DatabaseColumn = {
|
||||
type: column.data_type as ColumnType,
|
||||
// TODO infer this from PK constraints
|
||||
primary: false,
|
||||
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,
|
||||
};
|
||||
|
||||
const columnLabel = `${table.name}.${columnName}`;
|
||||
|
||||
switch (column.data_type) {
|
||||
// array types
|
||||
case 'ARRAY': {
|
||||
if (!column.array_type) {
|
||||
ctx.warnings.push(`Unable to find type for ${columnLabel} (ARRAY)`);
|
||||
continue;
|
||||
}
|
||||
item.type = column.array_type as ColumnType;
|
||||
break;
|
||||
}
|
||||
|
||||
// enum types
|
||||
case 'USER-DEFINED': {
|
||||
if (!enumMap[column.udt_name]) {
|
||||
ctx.warnings.push(`Unable to find type for ${columnLabel} (ENUM)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
item.type = 'enum';
|
||||
item.enumName = column.udt_name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
table.columns.push(item);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Reader } from 'src/types';
|
||||
|
||||
export const readComments: Reader = async (ctx, db) => {
|
||||
const comments = await 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();
|
||||
|
||||
for (const comment of comments) {
|
||||
if (comment.object_type === 'r') {
|
||||
const table = ctx.getTableByName(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import { sql } from 'kysely';
|
||||
import { ActionType, ConstraintType, Reader } from 'src/types';
|
||||
|
||||
export const readConstraints: Reader = async (ctx, db) => {
|
||||
const constraints = await db
|
||||
.selectFrom('pg_constraint')
|
||||
.innerJoin('pg_namespace', 'pg_namespace.oid', 'pg_constraint.connamespace') // namespace
|
||||
.innerJoin('pg_class as source_table', (join) =>
|
||||
join.onRef('source_table.oid', '=', 'pg_constraint.conrelid').on('source_table.relkind', 'in', [
|
||||
// ordinary table
|
||||
sql.lit('r'),
|
||||
// partitioned table
|
||||
sql.lit('p'),
|
||||
// foreign table
|
||||
sql.lit('f'),
|
||||
]),
|
||||
) // table
|
||||
.leftJoin('pg_class as reference_table', 'reference_table.oid', 'pg_constraint.confrelid') // reference table
|
||||
.select((eb) => [
|
||||
'pg_constraint.contype as constraint_type',
|
||||
'pg_constraint.conname as constraint_name',
|
||||
'source_table.relname as table_name',
|
||||
'reference_table.relname as reference_table_name',
|
||||
'pg_constraint.confupdtype as update_action',
|
||||
'pg_constraint.confdeltype as delete_action',
|
||||
// 'pg_constraint.oid as constraint_id',
|
||||
eb
|
||||
.selectFrom('pg_attribute')
|
||||
// matching table for PK, FK, and UQ
|
||||
.whereRef('pg_attribute.attrelid', '=', 'pg_constraint.conrelid')
|
||||
.whereRef('pg_attribute.attnum', '=', sql`any("pg_constraint"."conkey")`)
|
||||
.select((eb) => eb.fn<string[]>('json_agg', ['pg_attribute.attname']).as('column_name'))
|
||||
.as('column_names'),
|
||||
eb
|
||||
.selectFrom('pg_attribute')
|
||||
// matching foreign table for FK
|
||||
.whereRef('pg_attribute.attrelid', '=', 'pg_constraint.confrelid')
|
||||
.whereRef('pg_attribute.attnum', '=', sql`any("pg_constraint"."confkey")`)
|
||||
.select((eb) => eb.fn<string[]>('json_agg', ['pg_attribute.attname']).as('column_name'))
|
||||
.as('reference_column_names'),
|
||||
eb.fn<string>('pg_get_constraintdef', ['pg_constraint.oid']).as('expression'),
|
||||
])
|
||||
.where('pg_namespace.nspname', '=', ctx.schemaName)
|
||||
.execute();
|
||||
|
||||
for (const constraint of constraints) {
|
||||
const table = ctx.getTableByName(constraint.table_name);
|
||||
if (!table) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const constraintName = constraint.constraint_name;
|
||||
|
||||
switch (constraint.constraint_type) {
|
||||
// primary key constraint
|
||||
case 'p': {
|
||||
if (!constraint.column_names) {
|
||||
ctx.warnings.push(`Skipping CONSTRAINT "${constraintName}", no columns found`);
|
||||
continue;
|
||||
}
|
||||
table.constraints.push({
|
||||
type: ConstraintType.PRIMARY_KEY,
|
||||
name: constraintName,
|
||||
tableName: constraint.table_name,
|
||||
columnNames: constraint.column_names,
|
||||
synchronize: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// foreign key constraint
|
||||
case 'f': {
|
||||
if (!constraint.column_names || !constraint.reference_table_name || !constraint.reference_column_names) {
|
||||
ctx.warnings.push(
|
||||
`Skipping CONSTRAINT "${constraintName}", missing either columns, referenced table, or referenced columns,`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
table.constraints.push({
|
||||
type: ConstraintType.FOREIGN_KEY,
|
||||
name: constraintName,
|
||||
tableName: constraint.table_name,
|
||||
columnNames: constraint.column_names,
|
||||
referenceTableName: constraint.reference_table_name,
|
||||
referenceColumnNames: constraint.reference_column_names,
|
||||
onUpdate: asDatabaseAction(constraint.update_action),
|
||||
onDelete: asDatabaseAction(constraint.delete_action),
|
||||
synchronize: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// unique constraint
|
||||
case 'u': {
|
||||
table.constraints.push({
|
||||
type: ConstraintType.UNIQUE,
|
||||
name: constraintName,
|
||||
tableName: constraint.table_name,
|
||||
columnNames: constraint.column_names as string[],
|
||||
synchronize: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// check constraint
|
||||
case 'c': {
|
||||
table.constraints.push({
|
||||
type: ConstraintType.CHECK,
|
||||
name: constraint.constraint_name,
|
||||
tableName: constraint.table_name,
|
||||
expression: constraint.expression.replace('CHECK ', ''),
|
||||
synchronize: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const asDatabaseAction = (action: string) => {
|
||||
switch (action) {
|
||||
case 'a': {
|
||||
return ActionType.NO_ACTION;
|
||||
}
|
||||
case 'c': {
|
||||
return ActionType.CASCADE;
|
||||
}
|
||||
case 'r': {
|
||||
return ActionType.RESTRICT;
|
||||
}
|
||||
case 'n': {
|
||||
return ActionType.SET_NULL;
|
||||
}
|
||||
case 'd': {
|
||||
return ActionType.SET_DEFAULT;
|
||||
}
|
||||
|
||||
default: {
|
||||
return ActionType.NO_ACTION;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Reader } from 'src/types';
|
||||
|
||||
export const readExtensions: Reader = async (ctx, db) => {
|
||||
const extensions = await 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();
|
||||
|
||||
for (const { name } of extensions) {
|
||||
ctx.extensions.push({ name, synchronize: true });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { sql } from 'kysely';
|
||||
import { Reader } from 'src/types';
|
||||
|
||||
export const readFunctions: Reader = async (ctx, db) => {
|
||||
const routines = await 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', '=', ctx.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();
|
||||
|
||||
for (const { name, expression } of routines) {
|
||||
ctx.functions.push({
|
||||
name,
|
||||
// TODO read expression from the overrides table
|
||||
expression,
|
||||
synchronize: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { sql } from 'kysely';
|
||||
import { Reader } from 'src/types';
|
||||
|
||||
export const readIndexes: Reader = async (ctx, db) => {
|
||||
const indexes = await db
|
||||
.selectFrom('pg_index as ix')
|
||||
// matching index, which has column information
|
||||
.innerJoin('pg_class as i', 'ix.indexrelid', 'i.oid')
|
||||
.innerJoin('pg_am as a', 'i.relam', 'a.oid')
|
||||
// matching table
|
||||
.innerJoin('pg_class as t', 'ix.indrelid', 't.oid')
|
||||
// namespace
|
||||
.innerJoin('pg_namespace', 'pg_namespace.oid', 'i.relnamespace')
|
||||
// PK and UQ constraints automatically have indexes, so we can ignore those
|
||||
.leftJoin('pg_constraint', (join) =>
|
||||
join
|
||||
.onRef('pg_constraint.conindid', '=', 'i.oid')
|
||||
.on('pg_constraint.contype', 'in', [sql.lit('p'), sql.lit('u')]),
|
||||
)
|
||||
.where('pg_constraint.oid', 'is', null)
|
||||
.select((eb) => [
|
||||
'i.relname as index_name',
|
||||
't.relname as table_name',
|
||||
'ix.indisunique as unique',
|
||||
'a.amname as using',
|
||||
eb.fn<string>('pg_get_expr', ['ix.indexprs', 'ix.indrelid']).as('expression'),
|
||||
eb.fn<string>('pg_get_expr', ['ix.indpred', 'ix.indrelid']).as('where'),
|
||||
eb
|
||||
.selectFrom('pg_attribute as a')
|
||||
.where('t.relkind', '=', sql.lit('r'))
|
||||
.whereRef('a.attrelid', '=', 't.oid')
|
||||
// list of columns numbers in the index
|
||||
.whereRef('a.attnum', '=', sql`any("ix"."indkey")`)
|
||||
.select((eb) => eb.fn<string[]>('json_agg', ['a.attname']).as('column_name'))
|
||||
.as('column_names'),
|
||||
])
|
||||
.where('pg_namespace.nspname', '=', ctx.schemaName)
|
||||
.where('ix.indisprimary', '=', sql.lit(false))
|
||||
.execute();
|
||||
|
||||
for (const index of indexes) {
|
||||
const table = ctx.getTableByName(index.table_name);
|
||||
if (!table) {
|
||||
continue;
|
||||
}
|
||||
|
||||
table.indexes.push({
|
||||
name: index.index_name,
|
||||
tableName: index.table_name,
|
||||
columnNames: index.column_names ?? undefined,
|
||||
expression: index.expression ?? undefined,
|
||||
using: index.using,
|
||||
where: index.where ?? undefined,
|
||||
unique: index.unique,
|
||||
synchronize: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readColumns } from 'src/readers/column.reader';
|
||||
import { readComments } from 'src/readers/comment.reader';
|
||||
import { readConstraints } from 'src/readers/constraint.reader';
|
||||
import { readExtensions } from 'src/readers/extension.reader';
|
||||
import { readFunctions } from 'src/readers/function.reader';
|
||||
import { readIndexes } from 'src/readers/index.reader';
|
||||
import { readName } from 'src/readers/name.reader';
|
||||
import { readOverrides } from 'src/readers/override.reader';
|
||||
import { readParameters } from 'src/readers/parameter.reader';
|
||||
import { readTables } from 'src/readers/table.reader';
|
||||
import { readTriggers } from 'src/readers/trigger.reader';
|
||||
import { Reader } from 'src/types';
|
||||
|
||||
export const readers: Reader[] = [
|
||||
readName,
|
||||
readParameters,
|
||||
readExtensions,
|
||||
readFunctions,
|
||||
readTables,
|
||||
readColumns,
|
||||
readIndexes,
|
||||
readConstraints,
|
||||
readTriggers,
|
||||
readComments,
|
||||
readOverrides,
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
import { QueryResult, sql } from 'kysely';
|
||||
import { Reader } from 'src/types';
|
||||
|
||||
export const readName: Reader = async (ctx, db) => {
|
||||
const result = (await sql`SELECT current_database() as name`.execute(db)) as QueryResult<{ name: string }>;
|
||||
|
||||
ctx.databaseName = result.rows[0].name;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { sql } from 'kysely';
|
||||
import { OverrideType, Reader } from 'src/types';
|
||||
|
||||
export const readOverrides: Reader = async (ctx, db) => {
|
||||
try {
|
||||
const result = await sql
|
||||
.raw<{
|
||||
name: string;
|
||||
value: { type: OverrideType; name: string; sql: string };
|
||||
}>(`SELECT name, value FROM "${ctx.overrideTableName}"`)
|
||||
.execute(db);
|
||||
|
||||
for (const { name, value } of result.rows) {
|
||||
ctx.overrides.push({ name, value, synchronize: true });
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.warn('Overrides', `Error reading override table: ${error}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { sql } from 'kysely';
|
||||
import { ParameterScope, Reader } from 'src/types';
|
||||
|
||||
export const readParameters: Reader = async (ctx, db) => {
|
||||
const parameters = await db
|
||||
.selectFrom('pg_settings')
|
||||
.where('source', 'in', [sql.lit('database'), sql.lit('user')])
|
||||
.select(['name', 'setting as value', 'source as scope'])
|
||||
.execute();
|
||||
|
||||
for (const parameter of parameters) {
|
||||
ctx.parameters.push({
|
||||
name: parameter.name,
|
||||
value: parameter.value,
|
||||
databaseName: ctx.databaseName,
|
||||
scope: parameter.scope as ParameterScope,
|
||||
synchronize: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { sql } from 'kysely';
|
||||
import { Reader } from 'src/types';
|
||||
|
||||
export const readTables: Reader = async (ctx, db) => {
|
||||
const tables = await db
|
||||
.selectFrom('information_schema.tables')
|
||||
.where('table_schema', '=', ctx.schemaName)
|
||||
.where('table_type', '=', sql.lit('BASE TABLE'))
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
for (const table of tables) {
|
||||
ctx.tables.push({
|
||||
name: table.table_name,
|
||||
columns: [],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
constraints: [],
|
||||
synchronize: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Reader, TriggerAction, TriggerScope, TriggerTiming } from 'src/types';
|
||||
|
||||
export const readTriggers: Reader = async (ctx, db) => {
|
||||
const triggers = await 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', '=', ctx.schemaName)
|
||||
.execute();
|
||||
|
||||
// add triggers to tables
|
||||
for (const trigger of triggers) {
|
||||
const table = ctx.getTableByName(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),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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 };
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { register } from 'src/register';
|
||||
import { DatabaseEnum } from 'src/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;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { register } from 'src/register';
|
||||
import { ColumnType, DatabaseFunction } from 'src/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;
|
||||
};
|
||||
|
||||
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) {
|
||||
const body = options.body;
|
||||
sql.push(...(body.includes('\n') ? [`AS $$`, ' ' + body.trim(), `$$;`] : [`AS $$${body}$$;`]));
|
||||
}
|
||||
|
||||
return sql.join('\n ').trim();
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
|
||||
import { CheckOptions } from 'src/decorators/check.decorator';
|
||||
import { ColumnOptions } from 'src/decorators/column.decorator';
|
||||
import { ConfigurationParameterOptions } from 'src/decorators/configuration-parameter.decorator';
|
||||
import { DatabaseOptions } from 'src/decorators/database.decorator';
|
||||
import { ExtensionOptions } from 'src/decorators/extension.decorator';
|
||||
import { ForeignKeyColumnOptions } from 'src/decorators/foreign-key-column.decorator';
|
||||
import { ForeignKeyConstraintOptions } from 'src/decorators/foreign-key-constraint.decorator';
|
||||
import { IndexOptions } from 'src/decorators/index.decorator';
|
||||
import { TableOptions } from 'src/decorators/table.decorator';
|
||||
import { TriggerOptions } from 'src/decorators/trigger.decorator';
|
||||
import { UniqueOptions } from 'src/decorators/unique.decorator';
|
||||
import { DatabaseEnum, DatabaseFunction } from 'src/types';
|
||||
|
||||
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: '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: () => Function }> }
|
||||
| { type: 'foreignKeyConstraint'; item: ClassBased<{ options: ForeignKeyConstraintOptions }> };
|
||||
export type RegisterItemType<T extends RegisterItem['type']> = Extract<RegisterItem, { type: T }>['item'];
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RegisterItem } from 'src/register-item';
|
||||
|
||||
const items: RegisterItem[] = [];
|
||||
|
||||
export const register = (item: RegisterItem) => void items.push(item);
|
||||
|
||||
export const getRegisteredItems = () => items;
|
||||
|
||||
export const resetRegisteredItems = () => {
|
||||
items.length = 0;
|
||||
};
|
||||
@@ -0,0 +1,689 @@
|
||||
import { schemaDiff } from 'src/schema-diff';
|
||||
import {
|
||||
ActionType,
|
||||
ColumnType,
|
||||
ConstraintType,
|
||||
DatabaseColumn,
|
||||
DatabaseConstraint,
|
||||
DatabaseIndex,
|
||||
DatabaseSchema,
|
||||
DatabaseTable,
|
||||
} from 'src/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const fromColumn = (column: Partial<Omit<DatabaseColumn, 'tableName'>>): DatabaseSchema => {
|
||||
const tableName = 'table1';
|
||||
|
||||
return {
|
||||
databaseName: 'postgres',
|
||||
schemaName: 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
overrides: [],
|
||||
tables: [
|
||||
{
|
||||
name: tableName,
|
||||
columns: [
|
||||
{
|
||||
name: 'column1',
|
||||
primary: false,
|
||||
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 {
|
||||
databaseName: 'postgres',
|
||||
schemaName: 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
overrides: [],
|
||||
tables: [
|
||||
{
|
||||
name: tableName,
|
||||
columns: [
|
||||
{
|
||||
name: 'column1',
|
||||
primary: false,
|
||||
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 {
|
||||
databaseName: 'postgres',
|
||||
schemaName: 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
overrides: [],
|
||||
tables: [
|
||||
{
|
||||
name: tableName,
|
||||
columns: [
|
||||
{
|
||||
name: 'column1',
|
||||
primary: false,
|
||||
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,
|
||||
primary: false,
|
||||
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 {
|
||||
databaseName: 'immich',
|
||||
schemaName: schema?.name || 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
overrides: [],
|
||||
tables,
|
||||
warnings: [],
|
||||
};
|
||||
};
|
||||
|
||||
describe(schemaDiff.name, () => {
|
||||
it('should work', () => {
|
||||
const diff = schemaDiff(newSchema({ tables: [] }), newSchema({ tables: [] }));
|
||||
expect(diff.items).toEqual([]);
|
||||
});
|
||||
|
||||
describe('table', () => {
|
||||
describe('TableCreate', () => {
|
||||
it('should find a missing table', () => {
|
||||
const column: DatabaseColumn = {
|
||||
type: 'character varying',
|
||||
tableName: 'table1',
|
||||
primary: false,
|
||||
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: 'TableCreate',
|
||||
table: {
|
||||
name: 'table1',
|
||||
columns: [column],
|
||||
constraints: [],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'missing in target',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TableDrop', () => {
|
||||
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: 'TableDrop',
|
||||
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('ColumnAdd', () => {
|
||||
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: 'ColumnAdd',
|
||||
column: {
|
||||
tableName: 'table1',
|
||||
isArray: false,
|
||||
primary: false,
|
||||
name: 'column2',
|
||||
nullable: false,
|
||||
type: 'character varying',
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'missing in target',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ColumnDrop', () => {
|
||||
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: 'ColumnDrop',
|
||||
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: 'ColumnAlter',
|
||||
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: 'ColumnAlter',
|
||||
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: 'ColumnAlter',
|
||||
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([]);
|
||||
});
|
||||
|
||||
it('should support arrays, ignoring types', () => {
|
||||
const diff = schemaDiff(
|
||||
fromColumn({ name: 'column1', type: 'character varying', isArray: true, default: "'{}'" }),
|
||||
fromColumn({
|
||||
name: 'column1',
|
||||
type: 'character varying',
|
||||
isArray: true,
|
||||
default: "'{}'::character varying[]",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('constraint', () => {
|
||||
describe('ConstraintAdd', () => {
|
||||
it('should detect a new constraint', () => {
|
||||
const diff = schemaDiff(
|
||||
fromConstraint({
|
||||
name: 'PK_test',
|
||||
type: ConstraintType.PRIMARY_KEY,
|
||||
tableName: 'table1',
|
||||
columnNames: ['id'],
|
||||
synchronize: true,
|
||||
}),
|
||||
fromConstraint(),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([
|
||||
{
|
||||
type: 'ConstraintAdd',
|
||||
constraint: {
|
||||
type: ConstraintType.PRIMARY_KEY,
|
||||
name: 'PK_test',
|
||||
columnNames: ['id'],
|
||||
tableName: 'table1',
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'missing in target',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConstraintDrop', () => {
|
||||
it('should detect an extra constraint', () => {
|
||||
const diff = schemaDiff(
|
||||
fromConstraint(),
|
||||
fromConstraint({
|
||||
name: 'PK_test',
|
||||
type: ConstraintType.PRIMARY_KEY,
|
||||
tableName: 'table1',
|
||||
columnNames: ['id'],
|
||||
synchronize: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([
|
||||
{
|
||||
type: 'ConstraintDrop',
|
||||
tableName: 'table1',
|
||||
constraintName: 'PK_test',
|
||||
reason: 'missing in source',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('primary key', () => {
|
||||
it('should skip identical primary key constraints', () => {
|
||||
const constraint: DatabaseConstraint = {
|
||||
type: ConstraintType.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: ConstraintType.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: ConstraintType.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: 'ConstraintDrop',
|
||||
},
|
||||
{
|
||||
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: 'ConstraintAdd',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should drop and recreate when the ON DELETE action changes', () => {
|
||||
const constraint: DatabaseConstraint = {
|
||||
type: ConstraintType.FOREIGN_KEY,
|
||||
name: 'FK_test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['parentId'],
|
||||
referenceTableName: 'table2',
|
||||
referenceColumnNames: ['id'],
|
||||
onDelete: ActionType.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: 'ConstraintDrop',
|
||||
},
|
||||
{
|
||||
constraint: {
|
||||
columnNames: ['parentId'],
|
||||
name: 'FK_test',
|
||||
referenceColumnNames: ['id'],
|
||||
referenceTableName: 'table2',
|
||||
onDelete: ActionType.CASCADE,
|
||||
synchronize: true,
|
||||
tableName: 'table1',
|
||||
type: 'foreign-key',
|
||||
},
|
||||
reason: 'ON DELETE action is different (CASCADE vs NO ACTION)',
|
||||
type: 'ConstraintAdd',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unique', () => {
|
||||
it('should skip identical unique constraints', () => {
|
||||
const constraint: DatabaseConstraint = {
|
||||
type: ConstraintType.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: ConstraintType.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('IndexCreate', () => {
|
||||
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: 'IndexCreate',
|
||||
index: {
|
||||
name: 'IDX_test',
|
||||
columnNames: ['id'],
|
||||
tableName: 'table1',
|
||||
unique: false,
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'missing in target',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IndexDrop', () => {
|
||||
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: 'IndexDrop',
|
||||
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: 'IndexDrop',
|
||||
indexName: 'IDX_test',
|
||||
reason: 'uniqueness is different (true vs false)',
|
||||
},
|
||||
{
|
||||
type: 'IndexCreate',
|
||||
index,
|
||||
reason: 'uniqueness is different (true vs false)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { compareEnums } from 'src/comparers/enum.comparer';
|
||||
import { compareExtensions } from 'src/comparers/extension.comparer';
|
||||
import { compareFunctions } from 'src/comparers/function.comparer';
|
||||
import { compareOverrides } from 'src/comparers/override.comparer';
|
||||
import { compareParameters } from 'src/comparers/parameter.comparer';
|
||||
import { compareTables } from 'src/comparers/table.comparer';
|
||||
import { BaseContext } from 'src/contexts/base-context';
|
||||
import { compare } from 'src/helpers';
|
||||
import { transformers } from 'src/transformers';
|
||||
import { ConstraintType, DatabaseSchema, SchemaDiff, SchemaDiffOptions, SchemaDiffToSqlOptions } from 'src/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.extensions, 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(options)),
|
||||
...compare(source.overrides, target.overrides, options.overrides, compareOverrides()),
|
||||
];
|
||||
|
||||
type SchemaName = SchemaDiff['type'];
|
||||
const itemMap: Record<SchemaName, SchemaDiff[]> = {
|
||||
ColumnRename: [],
|
||||
ConstraintRename: [],
|
||||
IndexRename: [],
|
||||
|
||||
ExtensionDrop: [],
|
||||
ExtensionCreate: [],
|
||||
|
||||
ParameterSet: [],
|
||||
ParameterReset: [],
|
||||
|
||||
FunctionDrop: [],
|
||||
FunctionCreate: [],
|
||||
|
||||
EnumDrop: [],
|
||||
EnumCreate: [],
|
||||
|
||||
TriggerDrop: [],
|
||||
ConstraintDrop: [],
|
||||
TableDrop: [],
|
||||
ColumnDrop: [],
|
||||
ColumnAdd: [],
|
||||
ColumnAlter: [],
|
||||
TableCreate: [],
|
||||
ConstraintAdd: [],
|
||||
TriggerCreate: [],
|
||||
|
||||
IndexCreate: [],
|
||||
IndexDrop: [],
|
||||
|
||||
OverrideCreate: [],
|
||||
OverrideUpdate: [],
|
||||
OverrideDrop: [],
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
itemMap[item.type].push(item);
|
||||
}
|
||||
|
||||
const constraintAdds = itemMap.ConstraintAdd.filter((item) => item.type === 'ConstraintAdd');
|
||||
|
||||
const orderedItems = [
|
||||
...itemMap.ExtensionCreate,
|
||||
...itemMap.FunctionCreate,
|
||||
...itemMap.ParameterSet,
|
||||
...itemMap.ParameterReset,
|
||||
...itemMap.EnumCreate,
|
||||
...itemMap.TriggerDrop,
|
||||
...itemMap.IndexDrop,
|
||||
...itemMap.ConstraintDrop,
|
||||
...itemMap.TableCreate,
|
||||
...itemMap.ColumnAlter,
|
||||
...itemMap.ColumnAdd,
|
||||
...itemMap.ColumnRename,
|
||||
...constraintAdds.filter(({ constraint }) => constraint.type === ConstraintType.PRIMARY_KEY),
|
||||
...constraintAdds.filter(({ constraint }) => constraint.type === ConstraintType.FOREIGN_KEY),
|
||||
...constraintAdds.filter(({ constraint }) => constraint.type === ConstraintType.UNIQUE),
|
||||
...constraintAdds.filter(({ constraint }) => constraint.type === ConstraintType.CHECK),
|
||||
...itemMap.ConstraintRename,
|
||||
...itemMap.IndexCreate,
|
||||
...itemMap.IndexRename,
|
||||
...itemMap.TriggerCreate,
|
||||
...itemMap.ColumnDrop,
|
||||
...itemMap.TableDrop,
|
||||
...itemMap.EnumDrop,
|
||||
...itemMap.FunctionDrop,
|
||||
...itemMap.OverrideCreate,
|
||||
...itemMap.OverrideUpdate,
|
||||
...itemMap.OverrideDrop,
|
||||
];
|
||||
|
||||
return {
|
||||
items: orderedItems,
|
||||
asSql: (options?: SchemaDiffToSqlOptions) => schemaDiffToSql(orderedItems, options),
|
||||
asHuman: () => schemaDiffToHuman(orderedItems),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert schema diffs into SQL statements
|
||||
*/
|
||||
export const schemaDiffToSql = (items: SchemaDiff[], options: SchemaDiffToSqlOptions = {}): string[] => {
|
||||
return items.flatMap((item) => asSql(item, options));
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert schema diff into human readable statements
|
||||
*/
|
||||
export const schemaDiffToHuman = (items: SchemaDiff[]): string[] => {
|
||||
return items.flatMap((item) => asHuman(item));
|
||||
};
|
||||
|
||||
export const asSql = (item: SchemaDiff, options: SchemaDiffToSqlOptions): string[] => {
|
||||
const ctx = new BaseContext(options);
|
||||
for (const transform of transformers) {
|
||||
const result = transform(ctx, item);
|
||||
if (!result) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return asArray(result).map((result) => result + withComments(options.comments, item));
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled schema diff type: ${item.type}`);
|
||||
};
|
||||
|
||||
export const asHuman = (item: SchemaDiff): string => {
|
||||
switch (item.type) {
|
||||
case 'ExtensionCreate': {
|
||||
return `The extension "${item.extension.name}" is missing and needs to be created`;
|
||||
}
|
||||
case 'ExtensionDrop': {
|
||||
return `The extension "${item.extensionName}" exists but is no longer needed`;
|
||||
}
|
||||
case 'FunctionCreate': {
|
||||
return `The function "${item.function.name}" is missing and needs to be created`;
|
||||
}
|
||||
case 'FunctionDrop': {
|
||||
return `The function "${item.functionName}" exists but should be removed`;
|
||||
}
|
||||
case 'TableCreate': {
|
||||
return `The table "${item.table.name}" is missing and needs to be created`;
|
||||
}
|
||||
case 'TableDrop': {
|
||||
return `The table "${item.tableName}" exists but should be removed`;
|
||||
}
|
||||
case 'ColumnAdd': {
|
||||
return `The column "${item.column.tableName}"."${item.column.name}" is missing and needs to be created`;
|
||||
}
|
||||
case 'ColumnRename': {
|
||||
return `The column "${item.tableName}"."${item.oldName}" was renamed to "${item.tableName}"."${item.newName}"`;
|
||||
}
|
||||
case 'ColumnAlter': {
|
||||
return `The column "${item.tableName}"."${item.columnName}" has changes that need to be applied ${JSON.stringify(
|
||||
item.changes,
|
||||
)}`;
|
||||
}
|
||||
case 'ColumnDrop': {
|
||||
return `The column "${item.tableName}"."${item.columnName}" exists but should be removed`;
|
||||
}
|
||||
case 'ConstraintAdd': {
|
||||
return `The constraint "${item.constraint.tableName}"."${item.constraint.name}" (${item.constraint.type}) is missing and needs to be created`;
|
||||
}
|
||||
case 'ConstraintRename': {
|
||||
return `The constraint "${item.tableName}"."${item.oldName}" was renamed to "${item.tableName}"."${item.newName}"`;
|
||||
}
|
||||
case 'ConstraintDrop': {
|
||||
return `The constraint "${item.tableName}"."${item.constraintName}" exists but should be removed`;
|
||||
}
|
||||
case 'IndexCreate': {
|
||||
return `The index "${item.index.tableName}"."${item.index.name}" is missing and needs to be created`;
|
||||
}
|
||||
case 'IndexRename': {
|
||||
return `The index "${item.tableName}"."${item.oldName}" was renamed to "${item.tableName}"."${item.newName}"`;
|
||||
}
|
||||
case 'IndexDrop': {
|
||||
return `The index "${item.indexName}" exists but is no longer needed`;
|
||||
}
|
||||
case 'TriggerCreate': {
|
||||
return `The trigger "${item.trigger.tableName}"."${item.trigger.name}" is missing and needs to be created`;
|
||||
}
|
||||
case 'TriggerDrop': {
|
||||
return `The trigger "${item.tableName}"."${item.triggerName}" exists but is no longer needed`;
|
||||
}
|
||||
case 'ParameterSet': {
|
||||
return `The configuration parameter "${item.parameter.name}" has a different value and needs to be updated to "${item.parameter.value}"`;
|
||||
}
|
||||
case 'ParameterReset': {
|
||||
return `The configuration parameter "${item.parameterName}" is set, but should be reset to the default value`;
|
||||
}
|
||||
case 'EnumCreate': {
|
||||
return `The enum "${item.enum.name}" is missing and needs to be created`;
|
||||
}
|
||||
case 'EnumDrop': {
|
||||
return `The enum "${item.enumName}" exists but is no longer needed`;
|
||||
}
|
||||
case 'OverrideCreate': {
|
||||
return `The override "${item.override.name}" is missing and needs to be created`;
|
||||
}
|
||||
case 'OverrideUpdate': {
|
||||
return `The override "${item.override.name}" needs to be updated`;
|
||||
}
|
||||
case 'OverrideDrop': {
|
||||
return `The override "${item.overrideName}" exists but is no longer needed`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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,57 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { schemaFromCode } from 'src/schema-from-code';
|
||||
import { SchemaFromCodeOptions } from 'src/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const importModule = async (filePath: string) => {
|
||||
const module = await import(filePath);
|
||||
const options: SchemaFromCodeOptions = module.options;
|
||||
|
||||
return { module, options };
|
||||
};
|
||||
|
||||
describe(schemaFromCode.name, () => {
|
||||
it('should work', () => {
|
||||
expect(schemaFromCode({ reset: true })).toEqual({
|
||||
databaseName: 'postgres',
|
||||
schemaName: 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
overrides: [],
|
||||
tables: [],
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe('test files', () => {
|
||||
const errorStubs = readdirSync('test/sql-tools/errors', { withFileTypes: true });
|
||||
for (const file of errorStubs) {
|
||||
const filePath = join(file.parentPath, file.name);
|
||||
it(filePath, async () => {
|
||||
const { module, options } = await importModule(filePath);
|
||||
|
||||
expect(module.message).toBeDefined();
|
||||
expect(() => schemaFromCode({ ...options, reset: true })).toThrowError(module.message);
|
||||
});
|
||||
}
|
||||
|
||||
const stubs = readdirSync('test/sql-tools', { withFileTypes: true });
|
||||
for (const file of stubs) {
|
||||
if (file.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(file.parentPath, file.name);
|
||||
it(filePath, async () => {
|
||||
const { module, options } = await importModule(filePath);
|
||||
|
||||
expect(module.description).toBeDefined();
|
||||
expect(module.schema).toBeDefined();
|
||||
expect(schemaFromCode({ ...options, reset: true }), module.description).toEqual(module.schema);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ProcessorContext } from 'src/contexts/processor-context';
|
||||
import { processors } from 'src/processors';
|
||||
import { getRegisteredItems, resetRegisteredItems } from 'src/register';
|
||||
import { ConstraintType, SchemaFromCodeOptions } from 'src/types';
|
||||
|
||||
/**
|
||||
* Load schema from code (decorators, etc)
|
||||
*/
|
||||
export const schemaFromCode = (options: SchemaFromCodeOptions = {}) => {
|
||||
try {
|
||||
const ctx = new ProcessorContext(options);
|
||||
const items = getRegisteredItems();
|
||||
|
||||
for (const processor of processors) {
|
||||
processor(ctx, items);
|
||||
}
|
||||
|
||||
if (ctx.options.overrides) {
|
||||
ctx.tables.push({
|
||||
name: ctx.overrideTableName,
|
||||
columns: [
|
||||
{
|
||||
name: 'name',
|
||||
tableName: ctx.overrideTableName,
|
||||
primary: true,
|
||||
type: 'character varying',
|
||||
nullable: false,
|
||||
isArray: false,
|
||||
synchronize: true,
|
||||
},
|
||||
{
|
||||
name: 'value',
|
||||
tableName: ctx.overrideTableName,
|
||||
primary: false,
|
||||
type: 'jsonb',
|
||||
nullable: false,
|
||||
isArray: false,
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
constraints: [
|
||||
{
|
||||
type: ConstraintType.PRIMARY_KEY,
|
||||
name: `${ctx.overrideTableName}_pkey`,
|
||||
tableName: ctx.overrideTableName,
|
||||
columnNames: ['name'],
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
synchronize: true,
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.build();
|
||||
} finally {
|
||||
if (options.reset) {
|
||||
resetRegisteredItems();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import { Sql } from 'postgres';
|
||||
import { ReaderContext } from 'src/contexts/reader-context';
|
||||
import { readers } from 'src/readers';
|
||||
import { DatabaseSchema, PostgresDB, SchemaFromDatabaseOptions } from 'src/types';
|
||||
|
||||
export type DatabaseLike = Sql | Kysely<unknown>;
|
||||
|
||||
const isKysely = (db: DatabaseLike): db is Kysely<unknown> => db instanceof Kysely;
|
||||
|
||||
/**
|
||||
* Load schema from a database url
|
||||
*/
|
||||
export const schemaFromDatabase = async (
|
||||
database: DatabaseLike,
|
||||
options: SchemaFromDatabaseOptions = {},
|
||||
): Promise<DatabaseSchema> => {
|
||||
const db = isKysely(database)
|
||||
? (database as Kysely<PostgresDB>)
|
||||
: new Kysely<PostgresDB>({ dialect: new PostgresJSDialect({ postgres: database }) });
|
||||
const ctx = new ReaderContext(options);
|
||||
|
||||
try {
|
||||
for (const reader of readers) {
|
||||
await reader(ctx, db);
|
||||
}
|
||||
|
||||
return ctx.build();
|
||||
} finally {
|
||||
// only close the connection it we created it
|
||||
if (!isKysely(database)) {
|
||||
await db.destroy();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { BaseContext } from 'src/contexts/base-context';
|
||||
import { transformColumns } from 'src/transformers/column.transformer';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ctx = new BaseContext({});
|
||||
|
||||
describe(transformColumns.name, () => {
|
||||
describe('ColumnAdd', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformColumns(ctx, {
|
||||
type: 'ColumnAdd',
|
||||
column: {
|
||||
name: 'column1',
|
||||
tableName: 'table1',
|
||||
primary: false,
|
||||
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(ctx, {
|
||||
type: 'ColumnAdd',
|
||||
column: {
|
||||
name: 'column1',
|
||||
tableName: 'table1',
|
||||
primary: false,
|
||||
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(ctx, {
|
||||
type: 'ColumnAdd',
|
||||
column: {
|
||||
name: 'column1',
|
||||
tableName: 'table1',
|
||||
primary: false,
|
||||
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(ctx, {
|
||||
type: 'ColumnAdd',
|
||||
column: {
|
||||
name: 'column1',
|
||||
tableName: 'table1',
|
||||
primary: false,
|
||||
type: 'boolean',
|
||||
nullable: true,
|
||||
isArray: true,
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual('ALTER TABLE "table1" ADD "column1" boolean[];');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ColumnAlter', () => {
|
||||
it('should make a column nullable', () => {
|
||||
expect(
|
||||
transformColumns(ctx, {
|
||||
type: 'ColumnAlter',
|
||||
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(ctx, {
|
||||
type: 'ColumnAlter',
|
||||
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(ctx, {
|
||||
type: 'ColumnAlter',
|
||||
tableName: 'table1',
|
||||
columnName: 'column1',
|
||||
changes: { default: 'uuid_generate_v4()' },
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET DEFAULT uuid_generate_v4();`]);
|
||||
});
|
||||
|
||||
it('should update the default value to NULL', () => {
|
||||
expect(
|
||||
transformColumns(ctx, {
|
||||
type: 'ColumnAlter',
|
||||
tableName: 'table1',
|
||||
columnName: 'column1',
|
||||
changes: {
|
||||
default: 'NULL',
|
||||
},
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET DEFAULT NULL;`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ColumnDrop', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformColumns(ctx, {
|
||||
type: 'ColumnDrop',
|
||||
tableName: 'table1',
|
||||
columnName: 'column1',
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual(`ALTER TABLE "table1" DROP COLUMN "column1";`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { asColumnComment, getColumnModifiers, getColumnType } from 'src/helpers';
|
||||
import { SqlTransformer } from 'src/transformers/types';
|
||||
import { ColumnChanges, DatabaseColumn } from 'src/types';
|
||||
|
||||
export const transformColumns: SqlTransformer = (ctx, item) => {
|
||||
switch (item.type) {
|
||||
case 'ColumnAdd': {
|
||||
return asColumnAdd(item.column);
|
||||
}
|
||||
|
||||
case 'ColumnAlter': {
|
||||
return asColumnAlter(item.tableName, item.columnName, item.changes);
|
||||
}
|
||||
|
||||
case 'ColumnRename': {
|
||||
return `ALTER TABLE "${item.tableName}" RENAME COLUMN "${item.oldName}" TO "${item.newName}";`;
|
||||
}
|
||||
|
||||
case 'ColumnDrop': {
|
||||
return `ALTER TABLE "${item.tableName}" DROP COLUMN "${item.columnName}";`;
|
||||
}
|
||||
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const asColumnAdd = (column: DatabaseColumn): string => {
|
||||
return (
|
||||
`ALTER TABLE "${column.tableName}" ADD "${column.name}" ${getColumnType(column)}` + getColumnModifiers(column) + ';'
|
||||
);
|
||||
};
|
||||
|
||||
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,99 @@
|
||||
import { BaseContext } from 'src/contexts/base-context';
|
||||
import { transformConstraints } from 'src/transformers/constraint.transformer';
|
||||
import { ConstraintType } from 'src/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ctx = new BaseContext({});
|
||||
|
||||
describe(transformConstraints.name, () => {
|
||||
describe('ConstraintAdd', () => {
|
||||
describe('primary keys', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformConstraints(ctx, {
|
||||
type: 'ConstraintAdd',
|
||||
constraint: {
|
||||
type: ConstraintType.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(ctx, {
|
||||
type: 'ConstraintAdd',
|
||||
constraint: {
|
||||
type: ConstraintType.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(ctx, {
|
||||
type: 'ConstraintAdd',
|
||||
constraint: {
|
||||
type: ConstraintType.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(ctx, {
|
||||
type: 'ConstraintAdd',
|
||||
constraint: {
|
||||
type: ConstraintType.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('ConstraintDrop', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformConstraints(ctx, {
|
||||
type: 'ConstraintDrop',
|
||||
tableName: 'table1',
|
||||
constraintName: 'PK_test',
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual(`ALTER TABLE "table1" DROP CONSTRAINT "PK_test";`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { asColumnList } from 'src/helpers';
|
||||
import { SqlTransformer } from 'src/transformers/types';
|
||||
import { ActionType, ConstraintType, DatabaseConstraint } from 'src/types';
|
||||
|
||||
export const transformConstraints: SqlTransformer = (ctx, item) => {
|
||||
switch (item.type) {
|
||||
case 'ConstraintAdd': {
|
||||
return `ALTER TABLE "${item.constraint.tableName}" ADD ${asConstraintBody(item.constraint)};`;
|
||||
}
|
||||
|
||||
case 'ConstraintRename': {
|
||||
return `ALTER TABLE "${item.tableName}" RENAME CONSTRAINT "${item.oldName}" TO "${item.newName}";`;
|
||||
}
|
||||
|
||||
case 'ConstraintDrop': {
|
||||
return `ALTER TABLE "${item.tableName}" DROP CONSTRAINT "${item.constraintName}";`;
|
||||
}
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const withAction = (constraint: { onDelete?: ActionType; onUpdate?: ActionType }) =>
|
||||
` ON UPDATE ${constraint.onUpdate ?? ActionType.NO_ACTION} ON DELETE ${constraint.onDelete ?? ActionType.NO_ACTION}`;
|
||||
|
||||
export const asConstraintBody = (constraint: DatabaseConstraint): string => {
|
||||
const base = `CONSTRAINT "${constraint.name}"`;
|
||||
const type = constraint.type;
|
||||
|
||||
switch (type) {
|
||||
case ConstraintType.PRIMARY_KEY: {
|
||||
const columnNames = asColumnList(constraint.columnNames);
|
||||
return `${base} PRIMARY KEY (${columnNames})`;
|
||||
}
|
||||
|
||||
case ConstraintType.FOREIGN_KEY: {
|
||||
const columnNames = asColumnList(constraint.columnNames);
|
||||
const referenceColumnNames = asColumnList(constraint.referenceColumnNames);
|
||||
return (
|
||||
`${base} FOREIGN KEY (${columnNames}) REFERENCES "${constraint.referenceTableName}" (${referenceColumnNames})` +
|
||||
withAction(constraint)
|
||||
);
|
||||
}
|
||||
|
||||
case ConstraintType.UNIQUE: {
|
||||
const columnNames = asColumnList(constraint.columnNames);
|
||||
return `${base} UNIQUE (${columnNames})`;
|
||||
}
|
||||
|
||||
case ConstraintType.CHECK: {
|
||||
return `${base} CHECK (${constraint.expression})`;
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`Unknown constraint type: ${type}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SqlTransformer } from 'src/transformers/types';
|
||||
import { DatabaseEnum } from 'src/types';
|
||||
|
||||
export const transformEnums: SqlTransformer = (ctx, item) => {
|
||||
switch (item.type) {
|
||||
case 'EnumCreate': {
|
||||
return asEnumCreate(item.enum);
|
||||
}
|
||||
|
||||
case 'EnumDrop': {
|
||||
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,34 @@
|
||||
import { BaseContext } from 'src/contexts/base-context';
|
||||
import { transformExtensions } from 'src/transformers/extension.transformer';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ctx = new BaseContext({});
|
||||
|
||||
describe(transformExtensions.name, () => {
|
||||
describe('ExtensionDrop', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformExtensions(ctx, {
|
||||
type: 'ExtensionDrop',
|
||||
extensionName: 'cube',
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual(`DROP EXTENSION "cube";`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExtensionCreate', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
transformExtensions(ctx, {
|
||||
type: 'ExtensionCreate',
|
||||
extension: {
|
||||
name: 'cube',
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'unknown',
|
||||
}),
|
||||
).toEqual(`CREATE EXTENSION IF NOT EXISTS "cube";`);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user