diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 024ed15fa3..1a2bca2d96 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -518,7 +518,7 @@ jobs: run: npm run build - name: Run existing migrations - run: npm run typeorm:migrations:run + run: npm run migrations:run - name: Test npm run schema:reset command works run: npm run typeorm:schema:reset @@ -532,7 +532,7 @@ jobs: id: verify-changed-files with: files: | - server/src/migrations/ + server/src - name: Verify migration files have not changed if: steps.verify-changed-files.outputs.files_changed == 'true' run: | diff --git a/server/package.json b/server/package.json index c7a64594ff..2015e1d8c5 100644 --- a/server/package.json +++ b/server/package.json @@ -25,10 +25,10 @@ "lifecycle": "node ./dist/utils/lifecycle.js", "migrations:generate": "node ./dist/bin/migrations.js generate", "migrations:create": "node ./dist/bin/migrations.js create", - "typeorm:migrations:run": "typeorm migration:run -d ./dist/bin/database.js", + "migrations:run": "node ./dist/bin/migrations.js run", "typeorm:migrations:revert": "typeorm migration:revert -d ./dist/bin/database.js", "typeorm:schema:drop": "typeorm query -d ./dist/bin/database.js 'DROP schema public cascade; CREATE schema public;'", - "typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run", + "typeorm:schema:reset": "npm run typeorm:schema:drop && npm run migrations:run", "kysely:codegen": "npx kysely-codegen --include-pattern=\"(public|vectors).*\" --dialect postgres --url postgres://postgres:postgres@localhost/immich --log-level debug --out-file=./src/db.d.ts", "sync:open-api": "node ./dist/bin/sync-open-api.js", "sync:sql": "node ./dist/bin/sync-sql.js", diff --git a/server/src/bin/migrations.ts b/server/src/bin/migrations.ts index b553ff7fa7..222ad96858 100644 --- a/server/src/bin/migrations.ts +++ b/server/src/bin/migrations.ts @@ -1,15 +1,20 @@ #!/usr/bin/env node -process.env.DB_URL = 'postgres://postgres:postgres@localhost:5432/immich'; +process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich'; +import { Kysely } from 'kysely'; +import { PostgresJSDialect } from 'kysely-postgres-js'; import { writeFileSync } from 'node:fs'; +import { basename, dirname, extname, join } from 'node:path'; import postgres from 'postgres'; import { ConfigRepository } from 'src/repositories/config.repository'; -import 'src/schema/tables'; -import { DatabaseTable, schemaDiff, schemaFromDatabase, schemaFromDecorators } from 'src/sql-tools'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import 'src/schema'; +import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools'; const main = async () => { const command = process.argv[2]; - const name = process.argv[3] || 'Migration'; + const path = process.argv[3] || 'src/Migration'; switch (command) { case 'debug': { @@ -17,13 +22,19 @@ const main = async () => { return; } + case 'run': { + const only = process.argv[3] as 'kysely' | 'typeorm' | undefined; + await run(only); + return; + } + case 'create': { - create(name, [], []); + create(path, [], []); return; } case 'generate': { - await generate(name); + await generate(path); return; } @@ -31,32 +42,57 @@ const main = async () => { console.log(`Usage: node dist/bin/migrations.js create node dist/bin/migrations.js generate + node dist/bin/migrations.js run `); } } }; +const run = async (only?: 'kysely' | 'typeorm') => { + const configRepository = new ConfigRepository(); + const { database } = configRepository.getEnv(); + const logger = new LoggingRepository(undefined, configRepository); + const db = new Kysely({ + dialect: new PostgresJSDialect({ postgres: postgres(database.config.kysely) }), + log(event) { + if (event.level === 'error') { + console.error('Query failed :', { + durationMs: event.queryDurationMillis, + error: event.error, + sql: event.query.sql, + params: event.query.parameters, + }); + } + }, + }); + const databaseRepository = new DatabaseRepository(db, logger, configRepository); + + await databaseRepository.runMigrations({ only }); +}; + const debug = async () => { - const { up, down } = await compare(); + const { up } = await compare(); const upSql = '-- UP\n' + up.asSql({ comments: true }).join('\n'); - const downSql = '-- DOWN\n' + down.asSql({ comments: true }).join('\n'); - writeFileSync('./migrations.sql', upSql + '\n\n' + downSql); + // const downSql = '-- DOWN\n' + down.asSql({ comments: true }).join('\n'); + writeFileSync('./migrations.sql', upSql + '\n\n'); console.log('Wrote migrations.sql'); }; -const generate = async (name: string) => { +const generate = async (path: string) => { const { up, down } = await compare(); if (up.items.length === 0) { console.log('No changes detected'); return; } - create(name, up.asSql(), down.asSql()); + create(path, up.asSql(), down.asSql()); }; -const create = (name: string, up: string[], down: string[]) => { +const create = (path: string, up: string[], down: string[]) => { const timestamp = Date.now(); + const name = basename(path, extname(path)); const filename = `${timestamp}-${name}.ts`; - const fullPath = `./src/${filename}`; + const folder = dirname(path); + const fullPath = join(folder, filename); writeFileSync(fullPath, asMigration('kysely', { name, timestamp, up, down })); console.log(`Wrote ${fullPath}`); }; @@ -66,16 +102,25 @@ const compare = async () => { const { database } = configRepository.getEnv(); const db = postgres(database.config.kysely); - const source = schemaFromDecorators(); + const source = schemaFromCode(); const target = await schemaFromDatabase(db, {}); + const sourceParams = new Set(source.parameters.map(({ name }) => name)); + target.parameters = target.parameters.filter(({ name }) => sourceParams.has(name)); + + const sourceTables = new Set(source.tables.map(({ name }) => name)); + target.tables = target.tables.filter(({ name }) => sourceTables.has(name)); + console.log(source.warnings.join('\n')); - const isIncluded = (table: DatabaseTable) => source.tables.some(({ name }) => table.name === name); - target.tables = target.tables.filter((table) => isIncluded(table)); - - const up = schemaDiff(source, target, { ignoreExtraTables: true }); - const down = schemaDiff(target, source, { ignoreExtraTables: false }); + const up = schemaDiff(source, target, { + tables: { ignoreExtra: true }, + functions: { ignoreExtra: false }, + }); + const down = schemaDiff(target, source, { + tables: { ignoreExtra: false }, + functions: { ignoreExtra: false }, + }); return { up, down }; }; diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 56efdd1c08..95a34d026e 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -4,8 +4,24 @@ import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; import { ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum'; import { EmitEvent } from 'src/repositories/event.repository'; +import { immich_uuid_v7, updated_at } from 'src/schema/functions'; +import { BeforeUpdateTrigger, Column, ColumnOptions } from 'src/sql-tools'; import { setUnion } from 'src/utils/set'; +const GeneratedUuidV7Column = (options: Omit = {}) => + Column({ ...options, type: 'uuid', nullable: false, default: () => `${immich_uuid_v7.name}()` }); + +export const UpdateIdColumn = () => GeneratedUuidV7Column(); + +export const PrimaryGeneratedUuidV7Column = () => GeneratedUuidV7Column({ primary: true }); + +export const UpdatedAtTrigger = (name: string) => + BeforeUpdateTrigger({ + name, + scope: 'row', + function: updated_at, + }); + // PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the // maximum number of parameters is 65535. Any query that tries to bind more than that (e.g. searching // by a list of IDs) requires splitting the query into multiple chunks. diff --git a/server/src/migrations/1743595393000-TableCleanup.ts b/server/src/migrations/1743595393000-TableCleanup.ts new file mode 100644 index 0000000000..adf9c65afa --- /dev/null +++ b/server/src/migrations/1743595393000-TableCleanup.ts @@ -0,0 +1,12 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class TableCleanup1743595393000 implements MigrationInterface { + name = 'TableCleanup1743595393000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "system_config"`); + await queryRunner.query(`DROP TABLE IF EXISTS "socket_io_attachments"`); + } + + public async down(): Promise {} +} diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 43d3d2c16c..ec0b263408 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -197,58 +197,62 @@ export class DatabaseRepository { return dimSize; } - async runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise { + async runMigrations(options?: { transaction?: 'all' | 'none' | 'each'; only?: 'kysely' | 'typeorm' }): Promise { const { database } = this.configRepository.getEnv(); - const dataSource = new DataSource(database.config.typeorm); + if (options?.only !== 'kysely') { + const dataSource = new DataSource(database.config.typeorm); - this.logger.log('Running migrations, this may take a while'); + this.logger.log('Running migrations, this may take a while'); - this.logger.debug('Running typeorm migrations'); + this.logger.debug('Running typeorm migrations'); - await dataSource.initialize(); - await dataSource.runMigrations(options); - await dataSource.destroy(); + await dataSource.initialize(); + await dataSource.runMigrations(options); + await dataSource.destroy(); - this.logger.debug('Finished running typeorm migrations'); - - // eslint-disable-next-line unicorn/prefer-module - const migrationFolder = join(__dirname, '..', 'schema/migrations'); - - // TODO remove after we have at least one kysely migration - if (!existsSync(migrationFolder)) { - return; + this.logger.debug('Finished running typeorm migrations'); } - this.logger.debug('Running kysely migrations'); - const migrator = new Migrator({ - db: this.db, - migrationLockTableName: 'kysely_migrations_lock', - migrationTableName: 'kysely_migrations', - provider: new FileMigrationProvider({ - fs: { readdir }, - path: { join }, - migrationFolder, - }), - }); + if (options?.only !== 'typeorm') { + // eslint-disable-next-line unicorn/prefer-module + const migrationFolder = join(__dirname, '..', 'schema/migrations'); - const { error, results } = await migrator.migrateToLatest(); - - for (const result of results ?? []) { - if (result.status === 'Success') { - this.logger.log(`Migration "${result.migrationName}" succeeded`); + // TODO remove after we have at least one kysely migration + if (!existsSync(migrationFolder)) { + return; } - if (result.status === 'Error') { - this.logger.warn(`Migration "${result.migrationName}" failed`); + this.logger.debug('Running kysely migrations'); + const migrator = new Migrator({ + db: this.db, + migrationLockTableName: 'kysely_migrations_lock', + migrationTableName: 'kysely_migrations', + provider: new FileMigrationProvider({ + fs: { readdir }, + path: { join }, + migrationFolder, + }), + }); + + const { error, results } = await migrator.migrateToLatest(); + + for (const result of results ?? []) { + if (result.status === 'Success') { + this.logger.log(`Migration "${result.migrationName}" succeeded`); + } + + if (result.status === 'Error') { + this.logger.warn(`Migration "${result.migrationName}" failed`); + } } - } - if (error) { - this.logger.error(`Kysely migrations failed: ${error}`); - throw error; - } + if (error) { + this.logger.error(`Kysely migrations failed: ${error}`); + throw error; + } - this.logger.debug('Finished running kysely migrations'); + this.logger.debug('Finished running kysely migrations'); + } } async withLock(lock: DatabaseLock, callback: () => Promise): Promise { diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts index aaf21a3d7c..3f809db41e 100644 --- a/server/src/repositories/logging.repository.ts +++ b/server/src/repositories/logging.repository.ts @@ -1,4 +1,4 @@ -import { ConsoleLogger, Injectable, Scope } from '@nestjs/common'; +import { ConsoleLogger, Inject, Injectable, Scope } from '@nestjs/common'; import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util'; import { ClsService } from 'nestjs-cls'; import { Telemetry } from 'src/decorators'; @@ -26,7 +26,7 @@ export class MyConsoleLogger extends ConsoleLogger { private isColorEnabled: boolean; constructor( - private cls: ClsService, + private cls: ClsService | undefined, options?: { color?: boolean; context?: string }, ) { super(options?.context || MyConsoleLogger.name); @@ -74,7 +74,7 @@ export class MyConsoleLogger extends ConsoleLogger { export class LoggingRepository { private logger: MyConsoleLogger; - constructor(cls: ClsService, configRepository: ConfigRepository) { + constructor(@Inject(ClsService) cls: ClsService | undefined, configRepository: ConfigRepository) { const { noColor } = configRepository.getEnv(); this.logger = new MyConsoleLogger(cls, { context: LoggingRepository.name, color: !noColor }); } diff --git a/server/src/schema/enums.ts b/server/src/schema/enums.ts new file mode 100644 index 0000000000..100b92aa63 --- /dev/null +++ b/server/src/schema/enums.ts @@ -0,0 +1,12 @@ +import { AssetStatus, SourceType } from 'src/enum'; +import { registerEnum } from 'src/sql-tools'; + +export const assets_status_enum = registerEnum({ + name: 'assets_status_enum', + values: Object.values(AssetStatus), +}); + +export const asset_face_source_type = registerEnum({ + name: 'sourcetype', + values: Object.values(SourceType), +}); diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts new file mode 100644 index 0000000000..65ad2b72dc --- /dev/null +++ b/server/src/schema/functions.ts @@ -0,0 +1,116 @@ +import { registerFunction } from 'src/sql-tools'; + +export const immich_uuid_v7 = registerFunction({ + name: 'immich_uuid_v7', + arguments: ['p_timestamp timestamp with time zone default clock_timestamp()'], + returnType: 'uuid', + language: 'SQL', + behavior: 'volatile', + body: ` + SELECT encode( + set_bit( + set_bit( + overlay(uuid_send(gen_random_uuid()) + placing substring(int8send(floor(extract(epoch from p_timestamp) * 1000)::bigint) from 3) + from 1 for 6 + ), + 52, 1 + ), + 53, 1 + ), + 'hex')::uuid; +`, + synchronize: false, +}); + +export const updated_at = registerFunction({ + name: 'updated_at', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + DECLARE + clock_timestamp TIMESTAMP := clock_timestamp(); + BEGIN + new."updatedAt" = clock_timestamp; + new."updateId" = immich_uuid_v7(clock_timestamp); + return new; + END;`, + synchronize: false, +}); + +export const f_concat_ws = registerFunction({ + name: 'f_concat_ws', + arguments: ['text', 'text[]'], + returnType: 'text', + language: 'SQL', + parallel: 'safe', + behavior: 'immutable', + body: `SELECT array_to_string($2, $1)`, + synchronize: false, +}); + +export const f_unaccent = registerFunction({ + name: 'f_unaccent', + arguments: ['text'], + returnType: 'text', + language: 'SQL', + parallel: 'safe', + strict: true, + behavior: 'immutable', + return: `unaccent('unaccent', $1)`, + synchronize: false, +}); + +export const ll_to_earth_public = registerFunction({ + name: 'll_to_earth_public', + arguments: ['latitude double precision', 'longitude double precision'], + returnType: 'public.earth', + language: 'SQL', + parallel: 'safe', + strict: true, + behavior: 'immutable', + body: `SELECT public.cube(public.cube(public.cube(public.earth()*cos(radians(latitude))*cos(radians(longitude))),public.earth()*cos(radians(latitude))*sin(radians(longitude))),public.earth()*sin(radians(latitude)))::public.earth`, + synchronize: false, +}); + +export const users_delete_audit = registerFunction({ + name: 'users_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO users_audit ("userId") + SELECT "id" + FROM OLD; + RETURN NULL; + END`, + synchronize: false, +}); + +export const partners_delete_audit = registerFunction({ + name: 'partners_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO partners_audit ("sharedById", "sharedWithId") + SELECT "sharedById", "sharedWithId" + FROM OLD; + RETURN NULL; + END`, + synchronize: false, +}); + +export const assets_delete_audit = registerFunction({ + name: 'assets_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO assets_audit ("assetId", "ownerId") + SELECT "id", "ownerId" + FROM OLD; + RETURN NULL; + END`, + synchronize: false, +}); diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts new file mode 100644 index 0000000000..fe4b86d65c --- /dev/null +++ b/server/src/schema/index.ts @@ -0,0 +1,109 @@ +import { asset_face_source_type, assets_status_enum } from 'src/schema/enums'; +import { + assets_delete_audit, + f_concat_ws, + f_unaccent, + immich_uuid_v7, + ll_to_earth_public, + partners_delete_audit, + updated_at, + users_delete_audit, +} from 'src/schema/functions'; +import { ActivityTable } from 'src/schema/tables/activity.table'; +import { AlbumAssetTable } from 'src/schema/tables/album-asset.table'; +import { AlbumUserTable } from 'src/schema/tables/album-user.table'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { APIKeyTable } from 'src/schema/tables/api-key.table'; +import { AssetAuditTable } from 'src/schema/tables/asset-audit.table'; +import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { AssetFileTable } from 'src/schema/tables/asset-files.table'; +import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { AuditTable } from 'src/schema/tables/audit.table'; +import { ExifTable } from 'src/schema/tables/exif.table'; +import { FaceSearchTable } from 'src/schema/tables/face-search.table'; +import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table'; +import { LibraryTable } from 'src/schema/tables/library.table'; +import { MemoryTable } from 'src/schema/tables/memory.table'; +import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table'; +import { MoveTable } from 'src/schema/tables/move.table'; +import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table'; +import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; +import { PartnerTable } from 'src/schema/tables/partner.table'; +import { PersonTable } from 'src/schema/tables/person.table'; +import { SessionTable } from 'src/schema/tables/session.table'; +import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table'; +import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; +import { SmartSearchTable } from 'src/schema/tables/smart-search.table'; +import { StackTable } from 'src/schema/tables/stack.table'; +import { SessionSyncCheckpointTable } from 'src/schema/tables/sync-checkpoint.table'; +import { SystemMetadataTable } from 'src/schema/tables/system-metadata.table'; +import { TagAssetTable } from 'src/schema/tables/tag-asset.table'; +import { TagClosureTable } from 'src/schema/tables/tag-closure.table'; +import { UserAuditTable } from 'src/schema/tables/user-audit.table'; +import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; +import { UserTable } from 'src/schema/tables/user.table'; +import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; +import { ConfigurationParameter, Database, Extensions } from 'src/sql-tools'; + +@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'vectors', 'plpgsql']) +@ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' }) +@ConfigurationParameter({ + name: 'vectors.pgvector_compatibility', + value: () => 'on', + scope: 'user', + synchronize: false, +}) +@Database({ name: 'immich' }) +export class ImmichDatabase { + tables = [ + ActivityTable, + AlbumAssetTable, + AlbumUserTable, + AlbumTable, + APIKeyTable, + AssetAuditTable, + AssetFaceTable, + AssetJobStatusTable, + AssetTable, + AssetFileTable, + AuditTable, + ExifTable, + FaceSearchTable, + GeodataPlacesTable, + LibraryTable, + MemoryAssetTable, + MemoryTable, + MoveTable, + NaturalEarthCountriesTable, + PartnerAuditTable, + PartnerTable, + PersonTable, + SessionTable, + SharedLinkAssetTable, + SharedLinkTable, + SmartSearchTable, + StackTable, + SessionSyncCheckpointTable, + SystemMetadataTable, + TagAssetTable, + TagClosureTable, + UserAuditTable, + UserMetadataTable, + UserTable, + VersionHistoryTable, + ]; + + functions = [ + immich_uuid_v7, + updated_at, + f_concat_ws, + f_unaccent, + ll_to_earth_public, + users_delete_audit, + partners_delete_audit, + assets_delete_audit, + ]; + + enum = [assets_status_enum, asset_face_source_type]; +} diff --git a/server/src/schema/tables/activity.table.ts b/server/src/schema/tables/activity.table.ts index 87597838c7..e7a144722c 100644 --- a/server/src/schema/tables/activity.table.ts +++ b/server/src/schema/tables/activity.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { UserTable } from 'src/schema/tables/user.table'; @@ -11,10 +12,10 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('activity') +@UpdatedAtTrigger('activity_updated_at') @Index({ name: 'IDX_activity_like', columns: ['assetId', 'userId', 'albumId'], @@ -35,9 +36,14 @@ export class ActivityTable { @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_activity_update_id') - @UpdateIdColumn() - updateId!: string; + @ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + albumId!: string; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + userId!: string; + + @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) + assetId!: string | null; @Column({ type: 'text', default: null }) comment!: string | null; @@ -45,12 +51,7 @@ export class ActivityTable { @Column({ type: 'boolean', default: false }) isLiked!: boolean; - @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) - assetId!: string | null; - - @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - userId!: string; - - @ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - albumId!: string; + @ColumnIndex('IDX_activity_update_id') + @UpdateIdColumn() + updateId!: string; } diff --git a/server/src/schema/tables/album-asset.table.ts b/server/src/schema/tables/album-asset.table.ts index ccd7fda5fd..1b931e3116 100644 --- a/server/src/schema/tables/album-asset.table.ts +++ b/server/src/schema/tables/album-asset.table.ts @@ -4,15 +4,6 @@ import { ColumnIndex, CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql- @Table({ name: 'albums_assets_assets', primaryConstraintName: 'PK_c67bc36fa845fb7b18e0e398180' }) export class AlbumAssetTable { - @ForeignKeyColumn(() => AssetTable, { - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - nullable: false, - primary: true, - }) - @ColumnIndex() - assetsId!: string; - @ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', @@ -22,6 +13,15 @@ export class AlbumAssetTable { @ColumnIndex() albumsId!: string; + @ForeignKeyColumn(() => AssetTable, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + nullable: false, + primary: true, + }) + @ColumnIndex() + assetsId!: string; + @CreateDateColumn() createdAt!: Date; } diff --git a/server/src/schema/tables/album.table.ts b/server/src/schema/tables/album.table.ts index cf2f2e1cb4..cdfd092b1b 100644 --- a/server/src/schema/tables/album.table.ts +++ b/server/src/schema/tables/album.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AssetOrder } from 'src/enum'; import { AssetTable } from 'src/schema/tables/asset.table'; import { UserTable } from 'src/schema/tables/user.table'; @@ -10,10 +11,10 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table({ name: 'albums', primaryConstraintName: 'PK_7f71c7b5bc7c87b8f94c9a93a00' }) +@UpdatedAtTrigger('albums_updated_at') export class AlbumTable { @PrimaryGeneratedColumn() id!: string; @@ -24,28 +25,33 @@ export class AlbumTable { @Column({ default: 'Untitled Album' }) albumName!: string; - @Column({ type: 'text', default: '' }) - description!: string; - @CreateDateColumn() createdAt!: Date; + @ForeignKeyColumn(() => AssetTable, { + nullable: true, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + comment: 'Asset ID to be used as thumbnail', + }) + albumThumbnailAssetId!: string; + @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_albums_update_id') - @UpdateIdColumn() - updateId?: string; + @Column({ type: 'text', default: '' }) + description!: string; @DeleteDateColumn() deletedAt!: Date | null; - @ForeignKeyColumn(() => AssetTable, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) - albumThumbnailAssetId!: string; - @Column({ type: 'boolean', default: true }) isActivityEnabled!: boolean; @Column({ default: AssetOrder.DESC }) order!: AssetOrder; + + @ColumnIndex('IDX_albums_update_id') + @UpdateIdColumn() + updateId?: string; } diff --git a/server/src/schema/tables/api-key.table.ts b/server/src/schema/tables/api-key.table.ts index 42b98ab957..29c4ad2b0f 100644 --- a/server/src/schema/tables/api-key.table.ts +++ b/server/src/schema/tables/api-key.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { Permission } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; import { @@ -8,22 +9,19 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('api_keys') +@UpdatedAtTrigger('api_keys_updated_at') export class APIKeyTable { - @PrimaryGeneratedColumn() - id!: string; - @Column() name!: string; @Column() key!: string; - @Column({ array: true, type: 'character varying' }) - permissions!: Permission[]; + @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + userId!: string; @CreateDateColumn() createdAt!: Date; @@ -31,10 +29,13 @@ export class APIKeyTable { @UpdateDateColumn() updatedAt!: Date; + @PrimaryGeneratedColumn() + id!: string; + + @Column({ array: true, type: 'character varying' }) + permissions!: Permission[]; + @ColumnIndex({ name: 'IDX_api_keys_update_id' }) @UpdateIdColumn() updateId?: string; - - @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) - userId!: string; } diff --git a/server/src/schema/tables/asset-audit.table.ts b/server/src/schema/tables/asset-audit.table.ts index 10f7b535bc..55d6f5c911 100644 --- a/server/src/schema/tables/asset-audit.table.ts +++ b/server/src/schema/tables/asset-audit.table.ts @@ -1,8 +1,9 @@ -import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, ColumnIndex, CreateDateColumn, Table } from 'src/sql-tools'; @Table('assets_audit') export class AssetAuditTable { - @PrimaryGeneratedColumn({ type: 'v7' }) + @PrimaryGeneratedUuidV7Column() id!: string; @ColumnIndex('IDX_assets_audit_asset_id') diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index 56f22cf9a7..0ae99f44bf 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -1,4 +1,5 @@ import { SourceType } from 'src/enum'; +import { asset_face_source_type } from 'src/schema/enums'; import { AssetTable } from 'src/schema/tables/asset.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { Column, DeleteDateColumn, ForeignKeyColumn, Index, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; @@ -7,8 +8,11 @@ import { Column, DeleteDateColumn, ForeignKeyColumn, Index, PrimaryGeneratedColu @Index({ name: 'IDX_asset_faces_assetId_personId', columns: ['assetId', 'personId'] }) @Index({ columns: ['personId', 'assetId'] }) export class AssetFaceTable { - @PrimaryGeneratedColumn() - id!: string; + @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + assetId!: string; + + @ForeignKeyColumn(() => PersonTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true }) + personId!: string | null; @Column({ default: 0, type: 'integer' }) imageWidth!: number; @@ -28,15 +32,12 @@ export class AssetFaceTable { @Column({ default: 0, type: 'integer' }) boundingBoxY2!: number; - @Column({ default: SourceType.MACHINE_LEARNING, enumName: 'sourcetype', enum: SourceType }) + @PrimaryGeneratedColumn() + id!: string; + + @Column({ default: SourceType.MACHINE_LEARNING, enum: asset_face_source_type }) sourceType!: SourceType; - @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - assetId!: string; - - @ForeignKeyColumn(() => PersonTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true }) - personId!: string | null; - @DeleteDateColumn() deletedAt!: Date | null; } diff --git a/server/src/schema/tables/asset-files.table.ts b/server/src/schema/tables/asset-files.table.ts index fb32070751..fb8750a8ef 100644 --- a/server/src/schema/tables/asset-files.table.ts +++ b/server/src/schema/tables/asset-files.table.ts @@ -1,5 +1,6 @@ -import { AssetEntity } from 'src/entities/asset.entity'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AssetFileType } from 'src/enum'; +import { AssetTable } from 'src/schema/tables/asset.table'; import { Column, ColumnIndex, @@ -9,18 +10,18 @@ import { Table, Unique, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; -@Unique({ name: 'UQ_assetId_type', columns: ['assetId', 'type'] }) @Table('asset_files') +@Unique({ name: 'UQ_assetId_type', columns: ['assetId', 'type'] }) +@UpdatedAtTrigger('asset_files_updated_at') export class AssetFileTable { @PrimaryGeneratedColumn() id!: string; @ColumnIndex('IDX_asset_files_assetId') - @ForeignKeyColumn(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - assetId?: AssetEntity; + @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + assetId?: string; @CreateDateColumn() createdAt!: Date; @@ -28,13 +29,13 @@ export class AssetFileTable { @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_asset_files_update_id') - @UpdateIdColumn() - updateId?: string; - @Column() type!: AssetFileType; @Column() path!: string; + + @ColumnIndex('IDX_asset_files_update_id') + @UpdateIdColumn() + updateId?: string; } diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index bd79d48149..0fcbf4f9b1 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -1,9 +1,13 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { ASSET_CHECKSUM_CONSTRAINT } from 'src/entities/asset.entity'; import { AssetStatus, AssetType } from 'src/enum'; +import { assets_status_enum } from 'src/schema/enums'; +import { assets_delete_audit } from 'src/schema/functions'; import { LibraryTable } from 'src/schema/tables/library.table'; import { StackTable } from 'src/schema/tables/stack.table'; import { UserTable } from 'src/schema/tables/user.table'; import { + AfterDeleteTrigger, Column, ColumnIndex, CreateDateColumn, @@ -13,10 +17,17 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('assets') +@UpdatedAtTrigger('assets_updated_at') +@AfterDeleteTrigger({ + name: 'assets_delete_audit', + scope: 'statement', + function: assets_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) // Checksums must be unique per user and library @Index({ name: ASSET_CHECKSUM_CONSTRAINT, @@ -30,7 +41,11 @@ import { unique: true, where: '("libraryId" IS NOT NULL)', }) -@Index({ name: 'idx_local_date_time', expression: `(("localDateTime" AT TIME ZONE 'UTC'::text))::date` }) +@Index({ + name: 'idx_local_date_time', + expression: `(("localDateTime" at time zone 'UTC')::date)`, + synchronize: false, +}) @Index({ name: 'idx_local_date_time_month', expression: `(date_trunc('MONTH'::text, ("localDateTime" AT TIME ZONE 'UTC'::text)) AT TIME ZONE 'UTC'::text)`, @@ -38,9 +53,10 @@ import { @Index({ name: 'IDX_originalPath_libraryId', columns: ['originalPath', 'libraryId'] }) @Index({ name: 'IDX_asset_id_stackId', columns: ['id', 'stackId'] }) @Index({ - name: 'idx_originalFileName_trigram', + name: 'idx_originalfilename_trigram', using: 'gin', - expression: 'f_unaccent(("originalFileName")::text)', + expression: 'f_unaccent("originalFileName") gin_trgm_ops', + synchronize: false, }) // For all assets, each originalpath must be unique per user and library export class AssetTable { @@ -53,75 +69,50 @@ export class AssetTable { @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) ownerId!: string; - @ForeignKeyColumn(() => LibraryTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) - libraryId?: string | null; - @Column() deviceId!: string; @Column() type!: AssetType; - @Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE }) - status!: AssetStatus; - @Column() originalPath!: string; - @Column({ type: 'bytea', nullable: true }) - thumbhash!: Buffer | null; - - @Column({ type: 'character varying', nullable: true, default: '' }) - encodedVideoPath!: string | null; - - @CreateDateColumn() - createdAt!: Date; - - @UpdateDateColumn() - updatedAt!: Date; - - @ColumnIndex('IDX_assets_update_id') - @UpdateIdColumn() - updateId?: string; - - @DeleteDateColumn() - deletedAt!: Date | null; - @ColumnIndex('idx_asset_file_created_at') @Column({ type: 'timestamp with time zone', default: null }) fileCreatedAt!: Date; - @Column({ type: 'timestamp with time zone', default: null }) - localDateTime!: Date; - @Column({ type: 'timestamp with time zone', default: null }) fileModifiedAt!: Date; @Column({ type: 'boolean', default: false }) isFavorite!: boolean; - @Column({ type: 'boolean', default: false }) - isArchived!: boolean; + @Column({ type: 'character varying', nullable: true }) + duration!: string | null; - @Column({ type: 'boolean', default: false }) - isExternal!: boolean; - - @Column({ type: 'boolean', default: false }) - isOffline!: boolean; + @Column({ type: 'character varying', nullable: true, default: '' }) + encodedVideoPath!: string | null; @Column({ type: 'bytea' }) @ColumnIndex() checksum!: Buffer; // sha1 checksum - @Column({ type: 'character varying', nullable: true }) - duration!: string | null; - @Column({ type: 'boolean', default: true }) isVisible!: boolean; @ForeignKeyColumn(() => AssetTable, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' }) livePhotoVideoId!: string | null; + @UpdateDateColumn() + updatedAt!: Date; + + @CreateDateColumn() + createdAt!: Date; + + @Column({ type: 'boolean', default: false }) + isArchived!: boolean; + @Column() @ColumnIndex() originalFileName!: string; @@ -129,10 +120,35 @@ export class AssetTable { @Column({ nullable: true }) sidecarPath!: string | null; + @Column({ type: 'bytea', nullable: true }) + thumbhash!: Buffer | null; + + @Column({ type: 'boolean', default: false }) + isOffline!: boolean; + + @ForeignKeyColumn(() => LibraryTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) + libraryId?: string | null; + + @Column({ type: 'boolean', default: false }) + isExternal!: boolean; + + @DeleteDateColumn() + deletedAt!: Date | null; + + @Column({ type: 'timestamp with time zone', default: null }) + localDateTime!: Date; + @ForeignKeyColumn(() => StackTable, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) stackId?: string | null; @ColumnIndex('IDX_assets_duplicateId') @Column({ type: 'uuid', nullable: true }) duplicateId!: string | null; + + @Column({ enum: assets_status_enum, default: AssetStatus.ACTIVE }) + status!: AssetStatus; + + @ColumnIndex('IDX_assets_update_id') + @UpdateIdColumn() + updateId?: string; } diff --git a/server/src/schema/tables/audit.table.ts b/server/src/schema/tables/audit.table.ts index a05b070ba7..9674bbf308 100644 --- a/server/src/schema/tables/audit.table.ts +++ b/server/src/schema/tables/audit.table.ts @@ -4,7 +4,7 @@ import { Column, CreateDateColumn, Index, PrimaryColumn, Table } from 'src/sql-t @Table('audit') @Index({ name: 'IDX_ownerId_createdAt', columns: ['ownerId', 'createdAt'] }) export class AuditTable { - @PrimaryColumn({ type: 'integer', default: 'increment', synchronize: false }) + @PrimaryColumn({ type: 'serial', synchronize: false }) id!: number; @Column() diff --git a/server/src/schema/tables/exif.table.ts b/server/src/schema/tables/exif.table.ts index 8eddafecc2..e40ce94b4f 100644 --- a/server/src/schema/tables/exif.table.ts +++ b/server/src/schema/tables/exif.table.ts @@ -1,21 +1,18 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { Column, ColumnIndex, ForeignKeyColumn, Table, UpdateDateColumn, UpdateIdColumn } from 'src/sql-tools'; +import { Column, ColumnIndex, ForeignKeyColumn, Table, UpdateDateColumn } from 'src/sql-tools'; @Table('exif') +@UpdatedAtTrigger('asset_exif_updated_at') export class ExifTable { @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true }) assetId!: string; - @UpdateDateColumn({ default: () => 'clock_timestamp()' }) - updatedAt?: Date; + @Column({ type: 'character varying', nullable: true }) + make!: string | null; - @ColumnIndex('IDX_asset_exif_update_id') - @UpdateIdColumn() - updateId?: string; - - /* General info */ - @Column({ type: 'text', default: '' }) - description!: string; // or caption + @Column({ type: 'character varying', nullable: true }) + model!: string | null; @Column({ type: 'integer', nullable: true }) exifImageWidth!: number | null; @@ -35,43 +32,6 @@ export class ExifTable { @Column({ type: 'timestamp with time zone', nullable: true }) modifyDate!: Date | null; - @Column({ type: 'character varying', nullable: true }) - timeZone!: string | null; - - @Column({ type: 'double precision', nullable: true }) - latitude!: number | null; - - @Column({ type: 'double precision', nullable: true }) - longitude!: number | null; - - @Column({ type: 'character varying', nullable: true }) - projectionType!: string | null; - - @ColumnIndex('exif_city') - @Column({ type: 'character varying', nullable: true }) - city!: string | null; - - @ColumnIndex('IDX_live_photo_cid') - @Column({ type: 'character varying', nullable: true }) - livePhotoCID!: string | null; - - @ColumnIndex('IDX_auto_stack_id') - @Column({ type: 'character varying', nullable: true }) - autoStackId!: string | null; - - @Column({ type: 'character varying', nullable: true }) - state!: string | null; - - @Column({ type: 'character varying', nullable: true }) - country!: string | null; - - /* Image info */ - @Column({ type: 'character varying', nullable: true }) - make!: string | null; - - @Column({ type: 'character varying', nullable: true }) - model!: string | null; - @Column({ type: 'character varying', nullable: true }) lensModel!: string | null; @@ -84,9 +44,41 @@ export class ExifTable { @Column({ type: 'integer', nullable: true }) iso!: number | null; + @Column({ type: 'double precision', nullable: true }) + latitude!: number | null; + + @Column({ type: 'double precision', nullable: true }) + longitude!: number | null; + + @ColumnIndex('exif_city') + @Column({ type: 'character varying', nullable: true }) + city!: string | null; + + @Column({ type: 'character varying', nullable: true }) + state!: string | null; + + @Column({ type: 'character varying', nullable: true }) + country!: string | null; + + @Column({ type: 'text', default: '' }) + description!: string; // or caption + + @Column({ type: 'double precision', nullable: true }) + fps?: number | null; + @Column({ type: 'character varying', nullable: true }) exposureTime!: string | null; + @ColumnIndex('IDX_live_photo_cid') + @Column({ type: 'character varying', nullable: true }) + livePhotoCID!: string | null; + + @Column({ type: 'character varying', nullable: true }) + timeZone!: string | null; + + @Column({ type: 'character varying', nullable: true }) + projectionType!: string | null; + @Column({ type: 'character varying', nullable: true }) profileDescription!: string | null; @@ -96,10 +88,17 @@ export class ExifTable { @Column({ type: 'integer', nullable: true }) bitsPerSample!: number | null; + @ColumnIndex('IDX_auto_stack_id') + @Column({ type: 'character varying', nullable: true }) + autoStackId!: string | null; + @Column({ type: 'integer', nullable: true }) rating!: number | null; - /* Video info */ - @Column({ type: 'double precision', nullable: true }) - fps?: number | null; + @UpdateDateColumn({ default: () => 'clock_timestamp()' }) + updatedAt?: Date; + + @ColumnIndex('IDX_asset_exif_update_id') + @UpdateIdColumn() + updateId?: string; } diff --git a/server/src/schema/tables/face-search.table.ts b/server/src/schema/tables/face-search.table.ts index d4da6a69ba..5ac1357198 100644 --- a/server/src/schema/tables/face-search.table.ts +++ b/server/src/schema/tables/face-search.table.ts @@ -1,7 +1,14 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; -import { Column, ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools'; +import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools'; @Table({ name: 'face_search', primaryConstraintName: 'face_search_pkey' }) +@Index({ + name: 'face_index', + using: 'hnsw', + expression: `embedding vector_cosine_ops`, + with: 'ef_construction = 300, m = 16', + synchronize: false, +}) export class FaceSearchTable { @ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'CASCADE', @@ -10,7 +17,6 @@ export class FaceSearchTable { }) faceId!: string; - @ColumnIndex({ name: 'face_index', synchronize: false }) - @Column({ type: 'vector', array: true, length: 512, synchronize: false }) + @Column({ type: 'vector', length: 512, synchronize: false }) embedding!: string; } diff --git a/server/src/schema/tables/geodata-places.table.ts b/server/src/schema/tables/geodata-places.table.ts index 2ac4ab2780..631cfdff08 100644 --- a/server/src/schema/tables/geodata-places.table.ts +++ b/server/src/schema/tables/geodata-places.table.ts @@ -1,10 +1,35 @@ import { Column, Index, PrimaryColumn, Table } from 'src/sql-tools'; -@Index({ name: 'idx_geodata_places_alternate_names', expression: 'f_unaccent("alternateNames") gin_trgm_ops' }) -@Index({ name: 'idx_geodata_places_admin1_name', expression: 'f_unaccent("admin1Name") gin_trgm_ops' }) -@Index({ name: 'idx_geodata_places_admin2_name', expression: 'f_unaccent("admin2Name") gin_trgm_ops' }) -@Index({ name: 'idx_geodata_places_name', expression: 'f_unaccent("name") gin_trgm_ops' }) -@Index({ name: 'idx_geodata_places_gist_earthcoord', expression: 'll_to_earth_public(latitude, longitude)' }) +@Table({ name: 'geodata_places' }) +@Index({ + name: 'idx_geodata_places_alternate_names', + using: 'gin', + expression: 'f_unaccent("alternateNames") gin_trgm_ops', + synchronize: false, +}) +@Index({ + name: 'idx_geodata_places_admin1_name', + using: 'gin', + expression: 'f_unaccent("admin1Name") gin_trgm_ops', + synchronize: false, +}) +@Index({ + name: 'idx_geodata_places_admin2_name', + using: 'gin', + expression: 'f_unaccent("admin2Name") gin_trgm_ops', + synchronize: false, +}) +@Index({ + name: 'idx_geodata_places_name', + using: 'gin', + expression: 'f_unaccent("name") gin_trgm_ops', + synchronize: false, +}) +@Index({ + name: 'idx_geodata_places_gist_earthcoord', + expression: 'll_to_earth_public(latitude, longitude)', + synchronize: false, +}) @Table({ name: 'idx_geodata_places', synchronize: false }) export class GeodataPlacesTable { @PrimaryColumn({ type: 'integer' }) @@ -28,41 +53,8 @@ export class GeodataPlacesTable { @Column({ type: 'character varying', length: 80, nullable: true }) admin2Code!: string; - @Column({ type: 'character varying', nullable: true }) - admin1Name!: string; - - @Column({ type: 'character varying', nullable: true }) - admin2Name!: string; - - @Column({ type: 'character varying', nullable: true }) - alternateNames!: string; - @Column({ type: 'date' }) modificationDate!: Date; -} - -@Table({ name: 'geodata_places_tmp', synchronize: false }) -export class GeodataPlacesTempEntity { - @PrimaryColumn({ type: 'integer' }) - id!: number; - - @Column({ type: 'character varying', length: 200 }) - name!: string; - - @Column({ type: 'double precision' }) - longitude!: number; - - @Column({ type: 'double precision' }) - latitude!: number; - - @Column({ type: 'character', length: 2 }) - countryCode!: string; - - @Column({ type: 'character varying', length: 20, nullable: true }) - admin1Code!: string; - - @Column({ type: 'character varying', length: 80, nullable: true }) - admin2Code!: string; @Column({ type: 'character varying', nullable: true }) admin1Name!: string; @@ -72,7 +64,4 @@ export class GeodataPlacesTempEntity { @Column({ type: 'character varying', nullable: true }) alternateNames!: string; - - @Column({ type: 'date' }) - modificationDate!: Date; } diff --git a/server/src/schema/tables/index.ts b/server/src/schema/tables/index.ts index 6991d957ae..470f500bc2 100644 --- a/server/src/schema/tables/index.ts +++ b/server/src/schema/tables/index.ts @@ -1,73 +1,35 @@ -import { ActivityTable } from 'src/schema/tables/activity.table'; -import { AlbumAssetTable } from 'src/schema/tables/album-asset.table'; -import { AlbumUserTable } from 'src/schema/tables/album-user.table'; -import { AlbumTable } from 'src/schema/tables/album.table'; -import { APIKeyTable } from 'src/schema/tables/api-key.table'; -import { AssetAuditTable } from 'src/schema/tables/asset-audit.table'; -import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; -import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; -import { AssetTable } from 'src/schema/tables/asset.table'; -import { AuditTable } from 'src/schema/tables/audit.table'; -import { ExifTable } from 'src/schema/tables/exif.table'; -import { FaceSearchTable } from 'src/schema/tables/face-search.table'; -import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table'; -import { LibraryTable } from 'src/schema/tables/library.table'; -import { MemoryTable } from 'src/schema/tables/memory.table'; -import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table'; -import { MoveTable } from 'src/schema/tables/move.table'; -import { - NaturalEarthCountriesTable, - NaturalEarthCountriesTempTable, -} from 'src/schema/tables/natural-earth-countries.table'; -import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; -import { PartnerTable } from 'src/schema/tables/partner.table'; -import { PersonTable } from 'src/schema/tables/person.table'; -import { SessionTable } from 'src/schema/tables/session.table'; -import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table'; -import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; -import { SmartSearchTable } from 'src/schema/tables/smart-search.table'; -import { StackTable } from 'src/schema/tables/stack.table'; -import { SessionSyncCheckpointTable } from 'src/schema/tables/sync-checkpoint.table'; -import { SystemMetadataTable } from 'src/schema/tables/system-metadata.table'; -import { TagAssetTable } from 'src/schema/tables/tag-asset.table'; -import { UserAuditTable } from 'src/schema/tables/user-audit.table'; -import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; -import { UserTable } from 'src/schema/tables/user.table'; -import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; - -export const tables = [ - ActivityTable, - AlbumAssetTable, - AlbumUserTable, - AlbumTable, - APIKeyTable, - AssetAuditTable, - AssetFaceTable, - AssetJobStatusTable, - AssetTable, - AuditTable, - ExifTable, - FaceSearchTable, - GeodataPlacesTable, - LibraryTable, - MemoryAssetTable, - MemoryTable, - MoveTable, - NaturalEarthCountriesTable, - NaturalEarthCountriesTempTable, - PartnerAuditTable, - PartnerTable, - PersonTable, - SessionTable, - SharedLinkAssetTable, - SharedLinkTable, - SmartSearchTable, - StackTable, - SessionSyncCheckpointTable, - SystemMetadataTable, - TagAssetTable, - UserAuditTable, - UserMetadataTable, - UserTable, - VersionHistoryTable, -]; +import 'src/schema/tables/activity.table'; +import 'src/schema/tables/album-asset.table'; +import 'src/schema/tables/album-user.table'; +import 'src/schema/tables/album.table'; +import 'src/schema/tables/api-key.table'; +import 'src/schema/tables/asset-audit.table'; +import 'src/schema/tables/asset-face.table'; +import 'src/schema/tables/asset-files.table'; +import 'src/schema/tables/asset-job-status.table'; +import 'src/schema/tables/asset.table'; +import 'src/schema/tables/audit.table'; +import 'src/schema/tables/exif.table'; +import 'src/schema/tables/face-search.table'; +import 'src/schema/tables/geodata-places.table'; +import 'src/schema/tables/library.table'; +import 'src/schema/tables/memory.table'; +import 'src/schema/tables/memory_asset.table'; +import 'src/schema/tables/move.table'; +import 'src/schema/tables/natural-earth-countries.table'; +import 'src/schema/tables/partner-audit.table'; +import 'src/schema/tables/partner.table'; +import 'src/schema/tables/person.table'; +import 'src/schema/tables/session.table'; +import 'src/schema/tables/shared-link-asset.table'; +import 'src/schema/tables/shared-link.table'; +import 'src/schema/tables/smart-search.table'; +import 'src/schema/tables/stack.table'; +import 'src/schema/tables/sync-checkpoint.table'; +import 'src/schema/tables/system-metadata.table'; +import 'src/schema/tables/tag-asset.table'; +import 'src/schema/tables/tag-closure.table'; +import 'src/schema/tables/user-audit.table'; +import 'src/schema/tables/user-metadata.table'; +import 'src/schema/tables/user.table'; +import 'src/schema/tables/version-history.table'; diff --git a/server/src/schema/tables/library.table.ts b/server/src/schema/tables/library.table.ts index ff0bfd64f7..54b3752f41 100644 --- a/server/src/schema/tables/library.table.ts +++ b/server/src/schema/tables/library.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UserTable } from 'src/schema/tables/user.table'; import { Column, @@ -8,10 +9,10 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('libraries') +@UpdatedAtTrigger('libraries_updated_at') export class LibraryTable { @PrimaryGeneratedColumn() id!: string; @@ -34,13 +35,13 @@ export class LibraryTable { @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_libraries_update_id') - @UpdateIdColumn() - updateId?: string; - @DeleteDateColumn() deletedAt?: Date; @Column({ type: 'timestamp with time zone', nullable: true }) refreshedAt!: Date | null; + + @ColumnIndex('IDX_libraries_update_id') + @UpdateIdColumn() + updateId?: string; } diff --git a/server/src/schema/tables/memory.table.ts b/server/src/schema/tables/memory.table.ts index 91a0412649..1926405565 100644 --- a/server/src/schema/tables/memory.table.ts +++ b/server/src/schema/tables/memory.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { MemoryType } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; import { @@ -9,11 +10,11 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; import { MemoryData } from 'src/types'; @Table('memories') +@UpdatedAtTrigger('memories_updated_at') export class MemoryTable { @PrimaryGeneratedColumn() id!: string; @@ -24,10 +25,6 @@ export class MemoryTable { @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_memories_update_id') - @UpdateIdColumn() - updateId?: string; - @DeleteDateColumn() deletedAt?: Date; @@ -48,13 +45,17 @@ export class MemoryTable { @Column({ type: 'timestamp with time zone' }) memoryAt!: Date; + /** when the user last viewed the memory */ + @Column({ type: 'timestamp with time zone', nullable: true }) + seenAt?: Date; + @Column({ type: 'timestamp with time zone', nullable: true }) showAt?: Date; @Column({ type: 'timestamp with time zone', nullable: true }) hideAt?: Date; - /** when the user last viewed the memory */ - @Column({ type: 'timestamp with time zone', nullable: true }) - seenAt?: Date; + @ColumnIndex('IDX_memories_update_id') + @UpdateIdColumn() + updateId?: string; } diff --git a/server/src/schema/tables/memory_asset.table.ts b/server/src/schema/tables/memory_asset.table.ts index 08cdcea442..864e6291c7 100644 --- a/server/src/schema/tables/memory_asset.table.ts +++ b/server/src/schema/tables/memory_asset.table.ts @@ -4,11 +4,11 @@ import { ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools'; @Table('memories_assets_assets') export class MemoryAssetTable { - @ColumnIndex() - @ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) - assetsId!: string; - @ColumnIndex() @ForeignKeyColumn(() => MemoryTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) memoriesId!: string; + + @ColumnIndex() + @ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) + assetsId!: string; } diff --git a/server/src/schema/tables/natural-earth-countries.table.ts b/server/src/schema/tables/natural-earth-countries.table.ts index 5ac5384afc..df1132d17d 100644 --- a/server/src/schema/tables/natural-earth-countries.table.ts +++ b/server/src/schema/tables/natural-earth-countries.table.ts @@ -1,26 +1,8 @@ -import { Column, PrimaryColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; +import { Column, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; -@Table({ name: 'naturalearth_countries', synchronize: false }) +@Table({ name: 'naturalearth_countries' }) export class NaturalEarthCountriesTable { - @PrimaryColumn({ type: 'serial' }) - id!: number; - - @Column({ type: 'character varying', length: 50 }) - admin!: string; - - @Column({ type: 'character varying', length: 3 }) - admin_a3!: string; - - @Column({ type: 'character varying', length: 50 }) - type!: string; - - @Column({ type: 'polygon' }) - coordinates!: string; -} - -@Table({ name: 'naturalearth_countries_tmp', synchronize: false }) -export class NaturalEarthCountriesTempTable { - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn({ strategy: 'identity' }) id!: number; @Column({ type: 'character varying', length: 50 }) diff --git a/server/src/schema/tables/partner-audit.table.ts b/server/src/schema/tables/partner-audit.table.ts index 77d9f976b1..08b6e94626 100644 --- a/server/src/schema/tables/partner-audit.table.ts +++ b/server/src/schema/tables/partner-audit.table.ts @@ -1,8 +1,9 @@ -import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, ColumnIndex, CreateDateColumn, Table } from 'src/sql-tools'; @Table('partners_audit') export class PartnerAuditTable { - @PrimaryGeneratedColumn({ type: 'v7' }) + @PrimaryGeneratedUuidV7Column() id!: string; @ColumnIndex('IDX_partners_audit_shared_by_id') diff --git a/server/src/schema/tables/partner.table.ts b/server/src/schema/tables/partner.table.ts index 6406b48277..770107fe7a 100644 --- a/server/src/schema/tables/partner.table.ts +++ b/server/src/schema/tables/partner.table.ts @@ -1,15 +1,25 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { partners_delete_audit } from 'src/schema/functions'; import { UserTable } from 'src/schema/tables/user.table'; import { + AfterDeleteTrigger, Column, ColumnIndex, CreateDateColumn, ForeignKeyColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('partners') +@UpdatedAtTrigger('partners_updated_at') +@AfterDeleteTrigger({ + name: 'partners_delete_audit', + scope: 'statement', + function: partners_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) export class PartnerTable { @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', primary: true }) sharedById!: string; @@ -23,10 +33,10 @@ export class PartnerTable { @UpdateDateColumn() updatedAt!: Date; + @Column({ type: 'boolean', default: false }) + inTimeline!: boolean; + @ColumnIndex('IDX_partners_update_id') @UpdateIdColumn() updateId!: string; - - @Column({ type: 'boolean', default: false }) - inTimeline!: boolean; } diff --git a/server/src/schema/tables/person.table.ts b/server/src/schema/tables/person.table.ts index 91a05d8d76..b96fc5b709 100644 --- a/server/src/schema/tables/person.table.ts +++ b/server/src/schema/tables/person.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { UserTable } from 'src/schema/tables/user.table'; import { @@ -9,10 +10,10 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('person') +@UpdatedAtTrigger('person_updated_at') @Check({ name: 'CHK_b0f82b0ed662bfc24fbb58bb45', expression: `"birthDate" <= CURRENT_DATE` }) export class PersonTable { @PrimaryGeneratedColumn('uuid') @@ -24,31 +25,31 @@ export class PersonTable { @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_person_update_id') - @UpdateIdColumn() - updateId!: string; - @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) ownerId!: string; @Column({ default: '' }) name!: string; - @Column({ type: 'date', nullable: true }) - birthDate!: Date | string | null; - @Column({ default: '' }) thumbnailPath!: string; - @ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true }) - faceAssetId!: string | null; - @Column({ type: 'boolean', default: false }) isHidden!: boolean; + @Column({ type: 'date', nullable: true }) + birthDate!: Date | string | null; + + @ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true }) + faceAssetId!: string | null; + @Column({ type: 'boolean', default: false }) isFavorite!: boolean; @Column({ type: 'character varying', nullable: true, default: null }) color?: string | null; + + @ColumnIndex('IDX_person_update_id') + @UpdateIdColumn() + updateId!: string; } diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index 287f13de7f..a66732a7d9 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UserTable } from 'src/schema/tables/user.table'; import { Column, @@ -7,10 +8,10 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table({ name: 'sessions', primaryConstraintName: 'PK_48cb6b5c20faa63157b3c1baf7f' }) +@UpdatedAtTrigger('sessions_updated_at') export class SessionTable { @PrimaryGeneratedColumn() id!: string; @@ -19,22 +20,22 @@ export class SessionTable { @Column() token!: string; - @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) - userId!: string; - @CreateDateColumn() createdAt!: Date; @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_sessions_update_id') - @UpdateIdColumn() - updateId!: string; + @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + userId!: string; @Column({ default: '' }) deviceType!: string; @Column({ default: '' }) deviceOS!: string; + + @ColumnIndex('IDX_sessions_update_id') + @UpdateIdColumn() + updateId!: string; } diff --git a/server/src/schema/tables/shared-link.table.ts b/server/src/schema/tables/shared-link.table.ts index 4372a5760a..36237c58ef 100644 --- a/server/src/schema/tables/shared-link.table.ts +++ b/server/src/schema/tables/shared-link.table.ts @@ -20,16 +20,9 @@ export class SharedLinkTable { @Column({ type: 'character varying', nullable: true }) description!: string | null; - @Column({ type: 'character varying', nullable: true }) - password!: string | null; - @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) userId!: string; - @ColumnIndex('IDX_sharedlink_albumId') - @ForeignKeyColumn(() => AlbumTable, { nullable: true, onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - albumId!: string; - @ColumnIndex('IDX_sharedlink_key') @Column({ type: 'bytea' }) key!: Buffer; // use to access the inidividual asset @@ -46,9 +39,16 @@ export class SharedLinkTable { @Column({ type: 'boolean', default: false }) allowUpload!: boolean; + @ColumnIndex('IDX_sharedlink_albumId') + @ForeignKeyColumn(() => AlbumTable, { nullable: true, onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + albumId!: string; + @Column({ type: 'boolean', default: true }) allowDownload!: boolean; @Column({ type: 'boolean', default: true }) showExif!: boolean; + + @Column({ type: 'character varying', nullable: true }) + password!: string | null; } diff --git a/server/src/schema/tables/smart-search.table.ts b/server/src/schema/tables/smart-search.table.ts index a71eb9ae99..09362b9dda 100644 --- a/server/src/schema/tables/smart-search.table.ts +++ b/server/src/schema/tables/smart-search.table.ts @@ -1,7 +1,14 @@ import { AssetTable } from 'src/schema/tables/asset.table'; -import { Column, ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools'; +import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools'; @Table({ name: 'smart_search', primaryConstraintName: 'smart_search_pkey' }) +@Index({ + name: 'clip_index', + using: 'hnsw', + expression: `embedding vector_cosine_ops`, + with: `ef_construction = 300, m = 16`, + synchronize: false, +}) export class SmartSearchTable { @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', @@ -10,7 +17,6 @@ export class SmartSearchTable { }) assetId!: string; - @ColumnIndex({ name: 'clip_index', synchronize: false }) - @Column({ type: 'vector', array: true, length: 512, synchronize: false }) + @Column({ type: 'vector', length: 512, storage: 'external', synchronize: false }) embedding!: string; } diff --git a/server/src/schema/tables/stack.table.ts b/server/src/schema/tables/stack.table.ts index ea58ccb425..222114d715 100644 --- a/server/src/schema/tables/stack.table.ts +++ b/server/src/schema/tables/stack.table.ts @@ -7,10 +7,10 @@ export class StackTable { @PrimaryGeneratedColumn() id!: string; - @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - ownerId!: string; - //TODO: Add constraint to ensure primary asset exists in the assets array @ForeignKeyColumn(() => AssetTable, { nullable: false, unique: true }) primaryAssetId!: string; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + ownerId!: string; } diff --git a/server/src/schema/tables/sync-checkpoint.table.ts b/server/src/schema/tables/sync-checkpoint.table.ts index 190cd81ffe..831205ce7a 100644 --- a/server/src/schema/tables/sync-checkpoint.table.ts +++ b/server/src/schema/tables/sync-checkpoint.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { SyncEntityType } from 'src/enum'; import { SessionTable } from 'src/schema/tables/session.table'; import { @@ -8,10 +9,10 @@ import { PrimaryColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('session_sync_checkpoints') +@UpdatedAtTrigger('session_sync_checkpoints_updated_at') export class SessionSyncCheckpointTable { @ForeignKeyColumn(() => SessionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', primary: true }) sessionId!: string; @@ -25,10 +26,10 @@ export class SessionSyncCheckpointTable { @UpdateDateColumn() updatedAt!: Date; + @Column() + ack!: string; + @ColumnIndex('IDX_session_sync_checkpoints_update_id') @UpdateIdColumn() updateId!: string; - - @Column() - ack!: string; } diff --git a/server/src/schema/tables/tag-closure.table.ts b/server/src/schema/tables/tag-closure.table.ts index 079dd4dcc5..acde84b91d 100644 --- a/server/src/schema/tables/tag-closure.table.ts +++ b/server/src/schema/tables/tag-closure.table.ts @@ -1,15 +1,13 @@ import { TagTable } from 'src/schema/tables/tag.table'; -import { ColumnIndex, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools'; +import { ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools'; @Table('tags_closure') export class TagClosureTable { - @PrimaryColumn() @ColumnIndex() - @ForeignKeyColumn(() => TagTable, { onDelete: 'CASCADE', onUpdate: 'NO ACTION' }) + @ForeignKeyColumn(() => TagTable, { primary: true, onDelete: 'CASCADE', onUpdate: 'NO ACTION' }) id_ancestor!: string; - @PrimaryColumn() @ColumnIndex() - @ForeignKeyColumn(() => TagTable, { onDelete: 'CASCADE', onUpdate: 'NO ACTION' }) + @ForeignKeyColumn(() => TagTable, { primary: true, onDelete: 'CASCADE', onUpdate: 'NO ACTION' }) id_descendant!: string; } diff --git a/server/src/schema/tables/tag.table.ts b/server/src/schema/tables/tag.table.ts index 1c6b8cb205..5042e2eb0e 100644 --- a/server/src/schema/tables/tag.table.ts +++ b/server/src/schema/tables/tag.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UserTable } from 'src/schema/tables/user.table'; import { Column, @@ -8,15 +9,18 @@ import { Table, Unique, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('tags') +@UpdatedAtTrigger('tags_updated_at') @Unique({ columns: ['userId', 'value'] }) export class TagTable { @PrimaryGeneratedColumn() id!: string; + @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + userId!: string; + @Column() value!: string; @@ -26,16 +30,13 @@ export class TagTable { @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_tags_update_id') - @UpdateIdColumn() - updateId!: string; - @Column({ type: 'character varying', nullable: true, default: null }) color!: string | null; @ForeignKeyColumn(() => TagTable, { nullable: true, onDelete: 'CASCADE' }) parentId?: string; - @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) - userId!: string; + @ColumnIndex('IDX_tags_update_id') + @UpdateIdColumn() + updateId!: string; } diff --git a/server/src/schema/tables/user-audit.table.ts b/server/src/schema/tables/user-audit.table.ts index e3f117381c..0f881ccc9a 100644 --- a/server/src/schema/tables/user-audit.table.ts +++ b/server/src/schema/tables/user-audit.table.ts @@ -1,14 +1,15 @@ -import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, ColumnIndex, CreateDateColumn, Table } from 'src/sql-tools'; @Table('users_audit') export class UserAuditTable { - @PrimaryGeneratedColumn({ type: 'v7' }) - id!: string; - @Column({ type: 'uuid' }) userId!: string; @ColumnIndex('IDX_users_audit_deleted_at') @CreateDateColumn({ default: () => 'clock_timestamp()' }) deletedAt!: Date; + + @PrimaryGeneratedUuidV7Column() + id!: string; } diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts index 5bd9cd94c6..5160f979b9 100644 --- a/server/src/schema/tables/user.table.ts +++ b/server/src/schema/tables/user.table.ts @@ -1,6 +1,9 @@ import { ColumnType } from 'kysely'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UserStatus } from 'src/enum'; +import { users_delete_audit } from 'src/schema/functions'; import { + AfterDeleteTrigger, Column, ColumnIndex, CreateDateColumn, @@ -9,7 +12,6 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; type Timestamp = ColumnType; @@ -17,50 +19,51 @@ type Generated = T extends ColumnType ? ColumnType : ColumnType; @Table('users') +@UpdatedAtTrigger('users_updated_at') +@AfterDeleteTrigger({ + name: 'users_delete_audit', + scope: 'statement', + function: users_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) @Index({ name: 'IDX_users_updated_at_asc_id_asc', columns: ['updatedAt', 'id'] }) export class UserTable { @PrimaryGeneratedColumn() id!: Generated; + @Column({ unique: true }) + email!: string; + @Column({ default: '' }) - name!: Generated; + password!: Generated; + + @CreateDateColumn() + createdAt!: Generated; + + @Column({ default: '' }) + profileImagePath!: Generated; @Column({ type: 'boolean', default: false }) isAdmin!: Generated; - @Column({ unique: true }) - email!: string; + @Column({ type: 'boolean', default: true }) + shouldChangePassword!: Generated; + + @DeleteDateColumn() + deletedAt!: Timestamp | null; + + @Column({ default: '' }) + oauthId!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; @Column({ unique: true, nullable: true, default: null }) storageLabel!: string | null; @Column({ default: '' }) - password!: Generated; - - @Column({ default: '' }) - oauthId!: Generated; - - @Column({ default: '' }) - profileImagePath!: Generated; - - @Column({ type: 'boolean', default: true }) - shouldChangePassword!: Generated; - - @CreateDateColumn() - createdAt!: Generated; - - @UpdateDateColumn() - updatedAt!: Generated; - - @DeleteDateColumn() - deletedAt!: Timestamp | null; - - @Column({ type: 'character varying', default: UserStatus.ACTIVE }) - status!: Generated; - - @ColumnIndex({ name: 'IDX_users_update_id' }) - @UpdateIdColumn() - updateId!: Generated; + name!: Generated; @Column({ type: 'bigint', nullable: true }) quotaSizeInBytes!: ColumnType | null; @@ -68,6 +71,13 @@ export class UserTable { @Column({ type: 'bigint', default: 0 }) quotaUsageInBytes!: Generated>; + @Column({ type: 'character varying', default: UserStatus.ACTIVE }) + status!: Generated; + @Column({ type: 'timestamp with time zone', default: () => 'now()' }) profileChangedAt!: Generated; + + @ColumnIndex({ name: 'IDX_users_update_id' }) + @UpdateIdColumn() + updateId!: Generated; } diff --git a/server/src/sql-tools/decorators.ts b/server/src/sql-tools/decorators.ts deleted file mode 100644 index 88b3e4c7d1..0000000000 --- a/server/src/sql-tools/decorators.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ -import { register } from 'src/sql-tools/schema-from-decorators'; -import { - CheckOptions, - ColumnDefaultValue, - ColumnIndexOptions, - ColumnOptions, - ForeignKeyColumnOptions, - GenerateColumnOptions, - IndexOptions, - TableOptions, - UniqueOptions, -} from 'src/sql-tools/types'; - -export const Table = (options: string | TableOptions = {}): ClassDecorator => { - return (object: Function) => void register({ type: 'table', item: { object, options: asOptions(options) } }); -}; - -export const Column = (options: string | ColumnOptions = {}): PropertyDecorator => { - return (object: object, propertyName: string | symbol) => - void register({ type: 'column', item: { object, propertyName, options: asOptions(options) } }); -}; - -export const Index = (options: string | IndexOptions = {}): ClassDecorator => { - return (object: Function) => void register({ type: 'index', item: { object, options: asOptions(options) } }); -}; - -export const Unique = (options: UniqueOptions): ClassDecorator => { - return (object: Function) => void register({ type: 'uniqueConstraint', item: { object, options } }); -}; - -export const Check = (options: CheckOptions): ClassDecorator => { - return (object: Function) => void register({ type: 'checkConstraint', item: { object, options } }); -}; - -export const ColumnIndex = (options: string | ColumnIndexOptions = {}): PropertyDecorator => { - return (object: object, propertyName: string | symbol) => - void register({ type: 'columnIndex', item: { object, propertyName, options: asOptions(options) } }); -}; - -export const ForeignKeyColumn = (target: () => object, options: ForeignKeyColumnOptions): PropertyDecorator => { - return (object: object, propertyName: string | symbol) => { - register({ type: 'foreignKeyColumn', item: { object, propertyName, options, target } }); - }; -}; - -export const CreateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { - return Column({ - type: 'timestamp with time zone', - default: () => 'now()', - ...options, - }); -}; - -export const UpdateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { - return Column({ - type: 'timestamp with time zone', - default: () => 'now()', - ...options, - }); -}; - -export const DeleteDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { - return Column({ - type: 'timestamp with time zone', - nullable: true, - ...options, - }); -}; - -export const PrimaryGeneratedColumn = (options: Omit = {}) => - GeneratedColumn({ type: 'v4', ...options, primary: true }); - -export const PrimaryColumn = (options: Omit = {}) => Column({ ...options, primary: true }); - -export const GeneratedColumn = ({ type = 'v4', ...options }: GenerateColumnOptions): PropertyDecorator => { - const columnType = type === 'v4' || type === 'v7' ? 'uuid' : type; - - let columnDefault: ColumnDefaultValue | undefined; - switch (type) { - case 'v4': { - columnDefault = () => 'uuid_generate_v4()'; - break; - } - - case 'v7': { - columnDefault = () => 'immich_uuid_v7()'; - break; - } - } - - return Column({ - type: columnType, - default: columnDefault, - ...options, - }); -}; - -export const UpdateIdColumn = () => GeneratedColumn({ type: 'v7', nullable: false }); - -const asOptions = (options: string | T): T => { - if (typeof options === 'string') { - return { name: options } as T; - } - - return options; -}; diff --git a/server/src/sql-tools/diff/comparers/column.comparer.spec.ts b/server/src/sql-tools/diff/comparers/column.comparer.spec.ts new file mode 100644 index 0000000000..082d15f0db --- /dev/null +++ b/server/src/sql-tools/diff/comparers/column.comparer.spec.ts @@ -0,0 +1,81 @@ +import { compareColumns } from 'src/sql-tools/diff/comparers/column.comparer'; +import { DatabaseColumn, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testColumn: DatabaseColumn = { + name: 'test', + tableName: 'table1', + nullable: false, + isArray: false, + type: 'character varying', + synchronize: true, +}; + +describe('compareColumns', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareColumns.onExtra(testColumn)).toEqual([ + { + tableName: 'table1', + columnName: 'test', + type: 'column.drop', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareColumns.onMissing(testColumn)).toEqual([ + { + type: 'column.add', + column: testColumn, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareColumns.onCompare(testColumn, testColumn)).toEqual([]); + }); + + it('should detect a change in type', () => { + const source: DatabaseColumn = { ...testColumn }; + const target: DatabaseColumn = { ...testColumn, type: 'text' }; + const reason = 'column type is different (character varying vs text)'; + expect(compareColumns.onCompare(source, target)).toEqual([ + { + columnName: 'test', + tableName: 'table1', + type: 'column.drop', + reason, + }, + { + type: 'column.add', + column: source, + reason, + }, + ]); + }); + + it('should detect a comment change', () => { + const source: DatabaseColumn = { ...testColumn, comment: 'new comment' }; + const target: DatabaseColumn = { ...testColumn, comment: 'old comment' }; + const reason = 'comment is different (new comment vs old comment)'; + expect(compareColumns.onCompare(source, target)).toEqual([ + { + columnName: 'test', + tableName: 'table1', + type: 'column.alter', + changes: { + comment: 'new comment', + }, + reason, + }, + ]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/column.comparer.ts b/server/src/sql-tools/diff/comparers/column.comparer.ts new file mode 100644 index 0000000000..205bd594ae --- /dev/null +++ b/server/src/sql-tools/diff/comparers/column.comparer.ts @@ -0,0 +1,82 @@ +import { getColumnType, isDefaultEqual } from 'src/sql-tools/helpers'; +import { Comparer, DatabaseColumn, Reason, SchemaDiff } from 'src/sql-tools/types'; + +export const compareColumns: Comparer = { + onMissing: (source) => [ + { + type: 'column.add', + column: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'column.drop', + tableName: target.tableName, + columnName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: (source, target) => { + const sourceType = getColumnType(source); + const targetType = getColumnType(target); + + const isTypeChanged = sourceType !== targetType; + + if (isTypeChanged) { + // TODO: convert between types via UPDATE when possible + return dropAndRecreateColumn(source, target, `column type is different (${sourceType} vs ${targetType})`); + } + + const items: SchemaDiff[] = []; + if (source.nullable !== target.nullable) { + items.push({ + type: 'column.alter', + tableName: source.tableName, + columnName: source.name, + changes: { + nullable: source.nullable, + }, + reason: `nullable is different (${source.nullable} vs ${target.nullable})`, + }); + } + + if (!isDefaultEqual(source, target)) { + items.push({ + type: 'column.alter', + tableName: source.tableName, + columnName: source.name, + changes: { + default: String(source.default), + }, + reason: `default is different (${source.default} vs ${target.default})`, + }); + } + + if (source.comment !== target.comment) { + items.push({ + type: 'column.alter', + tableName: source.tableName, + columnName: source.name, + changes: { + comment: String(source.comment), + }, + reason: `comment is different (${source.comment} vs ${target.comment})`, + }); + } + + return items; + }, +}; + +const dropAndRecreateColumn = (source: DatabaseColumn, target: DatabaseColumn, reason: string): SchemaDiff[] => { + return [ + { + type: 'column.drop', + tableName: target.tableName, + columnName: target.name, + reason, + }, + { type: 'column.add', column: source, reason }, + ]; +}; diff --git a/server/src/sql-tools/diff/comparers/constraint.comparer.spec.ts b/server/src/sql-tools/diff/comparers/constraint.comparer.spec.ts new file mode 100644 index 0000000000..69d8a8cc43 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/constraint.comparer.spec.ts @@ -0,0 +1,63 @@ +import { compareConstraints } from 'src/sql-tools/diff/comparers/constraint.comparer'; +import { DatabaseConstraint, DatabaseConstraintType, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testConstraint: DatabaseConstraint = { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'test', + tableName: 'table1', + columnNames: ['column1'], + synchronize: true, +}; + +describe('compareConstraints', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareConstraints.onExtra(testConstraint)).toEqual([ + { + type: 'constraint.drop', + constraintName: 'test', + tableName: 'table1', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareConstraints.onMissing(testConstraint)).toEqual([ + { + type: 'constraint.add', + constraint: testConstraint, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareConstraints.onCompare(testConstraint, testConstraint)).toEqual([]); + }); + + it('should detect a change in type', () => { + const source: DatabaseConstraint = { ...testConstraint }; + const target: DatabaseConstraint = { ...testConstraint, columnNames: ['column1', 'column2'] }; + const reason = 'Primary key columns are different: (column1 vs column1,column2)'; + expect(compareConstraints.onCompare(source, target)).toEqual([ + { + constraintName: 'test', + tableName: 'table1', + type: 'constraint.drop', + reason, + }, + { + type: 'constraint.add', + constraint: source, + reason, + }, + ]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/constraint.comparer.ts b/server/src/sql-tools/diff/comparers/constraint.comparer.ts new file mode 100644 index 0000000000..ccb594741c --- /dev/null +++ b/server/src/sql-tools/diff/comparers/constraint.comparer.ts @@ -0,0 +1,133 @@ +import { haveEqualColumns } from 'src/sql-tools/helpers'; +import { + CompareFunction, + Comparer, + DatabaseCheckConstraint, + DatabaseConstraint, + DatabaseConstraintType, + DatabaseForeignKeyConstraint, + DatabasePrimaryKeyConstraint, + DatabaseUniqueConstraint, + Reason, + SchemaDiff, +} from 'src/sql-tools/types'; + +export const compareConstraints: Comparer = { + onMissing: (source) => [ + { + type: 'constraint.add', + constraint: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'constraint.drop', + tableName: target.tableName, + constraintName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: (source, target) => { + switch (source.type) { + case DatabaseConstraintType.PRIMARY_KEY: { + return comparePrimaryKeyConstraint(source, target as DatabasePrimaryKeyConstraint); + } + + case DatabaseConstraintType.FOREIGN_KEY: { + return compareForeignKeyConstraint(source, target as DatabaseForeignKeyConstraint); + } + + case DatabaseConstraintType.UNIQUE: { + return compareUniqueConstraint(source, target as DatabaseUniqueConstraint); + } + + case DatabaseConstraintType.CHECK: { + return compareCheckConstraint(source, target as DatabaseCheckConstraint); + } + + default: { + return []; + } + } + }, +}; + +const comparePrimaryKeyConstraint: CompareFunction = (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 = (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 = (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 = (source, target) => { + if (source.expression !== target.expression) { + // comparing expressions is hard because postgres reconstructs it with different formatting + // for now if the constraint exists with the same name, we will just skip it + } + + return []; +}; + +const dropAndRecreateConstraint = ( + source: DatabaseConstraint, + target: DatabaseConstraint, + reason: string, +): SchemaDiff[] => { + return [ + { + type: 'constraint.drop', + tableName: target.tableName, + constraintName: target.name, + reason, + }, + { type: 'constraint.add', constraint: source, reason }, + ]; +}; diff --git a/server/src/sql-tools/diff/comparers/enum.comparer.spec.ts b/server/src/sql-tools/diff/comparers/enum.comparer.spec.ts new file mode 100644 index 0000000000..6e1ad992d5 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/enum.comparer.spec.ts @@ -0,0 +1,54 @@ +import { compareEnums } from 'src/sql-tools/diff/comparers/enum.comparer'; +import { DatabaseEnum, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testEnum: DatabaseEnum = { name: 'test', values: ['foo', 'bar'], synchronize: true }; + +describe('compareEnums', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareEnums.onExtra(testEnum)).toEqual([ + { + enumName: 'test', + type: 'enum.drop', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareEnums.onMissing(testEnum)).toEqual([ + { + type: 'enum.create', + enum: testEnum, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareEnums.onCompare(testEnum, testEnum)).toEqual([]); + }); + + it('should drop and recreate when values list is different', () => { + const source = { name: 'test', values: ['foo', 'bar'], synchronize: true }; + const target = { name: 'test', values: ['foo', 'bar', 'world'], synchronize: true }; + expect(compareEnums.onCompare(source, target)).toEqual([ + { + enumName: 'test', + type: 'enum.drop', + reason: 'enum values has changed (foo,bar vs foo,bar,world)', + }, + { + type: 'enum.create', + enum: source, + reason: 'enum values has changed (foo,bar vs foo,bar,world)', + }, + ]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/enum.comparer.ts b/server/src/sql-tools/diff/comparers/enum.comparer.ts new file mode 100644 index 0000000000..408f01050b --- /dev/null +++ b/server/src/sql-tools/diff/comparers/enum.comparer.ts @@ -0,0 +1,38 @@ +import { Comparer, DatabaseEnum, Reason } from 'src/sql-tools/types'; + +export const compareEnums: Comparer = { + onMissing: (source) => [ + { + type: 'enum.create', + enum: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'enum.drop', + enumName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: (source, target) => { + if (source.values.toString() !== target.values.toString()) { + // TODO add or remove values if the lists are different or the order has changed + const reason = `enum values has changed (${source.values} vs ${target.values})`; + return [ + { + type: 'enum.drop', + enumName: source.name, + reason, + }, + { + type: 'enum.create', + enum: source, + reason, + }, + ]; + } + + return []; + }, +}; diff --git a/server/src/sql-tools/diff/comparers/extension.comparer.spec.ts b/server/src/sql-tools/diff/comparers/extension.comparer.spec.ts new file mode 100644 index 0000000000..753c461c69 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/extension.comparer.spec.ts @@ -0,0 +1,37 @@ +import { compareExtensions } from 'src/sql-tools/diff/comparers/extension.comparer'; +import { Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testExtension = { name: 'test', synchronize: true }; + +describe('compareExtensions', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareExtensions.onExtra(testExtension)).toEqual([ + { + extensionName: 'test', + type: 'extension.drop', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareExtensions.onMissing(testExtension)).toEqual([ + { + type: 'extension.create', + extension: testExtension, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareExtensions.onCompare(testExtension, testExtension)).toEqual([]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/extension.comparer.ts b/server/src/sql-tools/diff/comparers/extension.comparer.ts new file mode 100644 index 0000000000..1c9d19165a --- /dev/null +++ b/server/src/sql-tools/diff/comparers/extension.comparer.ts @@ -0,0 +1,22 @@ +import { Comparer, DatabaseExtension, Reason } from 'src/sql-tools/types'; + +export const compareExtensions: Comparer = { + onMissing: (source) => [ + { + type: 'extension.create', + extension: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'extension.drop', + extensionName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: () => { + // if the name matches they are the same + return []; + }, +}; diff --git a/server/src/sql-tools/diff/comparers/function.comparer.spec.ts b/server/src/sql-tools/diff/comparers/function.comparer.spec.ts new file mode 100644 index 0000000000..ac478ed000 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/function.comparer.spec.ts @@ -0,0 +1,53 @@ +import { compareFunctions } from 'src/sql-tools/diff/comparers/function.comparer'; +import { DatabaseFunction, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testFunction: DatabaseFunction = { + name: 'test', + expression: 'CREATE FUNCTION something something something', + synchronize: true, +}; + +describe('compareFunctions', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareFunctions.onExtra(testFunction)).toEqual([ + { + functionName: 'test', + type: 'function.drop', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareFunctions.onMissing(testFunction)).toEqual([ + { + type: 'function.create', + function: testFunction, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should ignore functions with the same hash', () => { + expect(compareFunctions.onCompare(testFunction, testFunction)).toEqual([]); + }); + + it('should report differences if functions have different hashes', () => { + const source: DatabaseFunction = { ...testFunction, expression: 'SELECT 1' }; + const target: DatabaseFunction = { ...testFunction, expression: 'SELECT 2' }; + expect(compareFunctions.onCompare(source, target)).toEqual([ + { + type: 'function.create', + reason: 'function expression has changed (SELECT 1 vs SELECT 2)', + function: source, + }, + ]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/function.comparer.ts b/server/src/sql-tools/diff/comparers/function.comparer.ts new file mode 100644 index 0000000000..d10353b89c --- /dev/null +++ b/server/src/sql-tools/diff/comparers/function.comparer.ts @@ -0,0 +1,32 @@ +import { Comparer, DatabaseFunction, Reason } from 'src/sql-tools/types'; + +export const compareFunctions: Comparer = { + onMissing: (source) => [ + { + type: 'function.create', + function: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'function.drop', + functionName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: (source, target) => { + if (source.expression !== target.expression) { + const reason = `function expression has changed (${source.expression} vs ${target.expression})`; + return [ + { + type: 'function.create', + function: source, + reason, + }, + ]; + } + + return []; + }, +}; diff --git a/server/src/sql-tools/diff/comparers/index.comparer.spec.ts b/server/src/sql-tools/diff/comparers/index.comparer.spec.ts new file mode 100644 index 0000000000..806bab190c --- /dev/null +++ b/server/src/sql-tools/diff/comparers/index.comparer.spec.ts @@ -0,0 +1,72 @@ +import { compareIndexes } from 'src/sql-tools/diff/comparers/index.comparer'; +import { DatabaseIndex, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testIndex: DatabaseIndex = { + name: 'test', + tableName: 'table1', + columnNames: ['column1', 'column2'], + unique: false, + synchronize: true, +}; + +describe('compareIndexes', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareIndexes.onExtra(testIndex)).toEqual([ + { + type: 'index.drop', + indexName: 'test', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareIndexes.onMissing(testIndex)).toEqual([ + { + type: 'index.create', + index: testIndex, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareIndexes.onCompare(testIndex, testIndex)).toEqual([]); + }); + + it('should drop and recreate when column list is different', () => { + const source = { + name: 'test', + tableName: 'table1', + columnNames: ['column1'], + unique: true, + synchronize: true, + }; + const target = { + name: 'test', + tableName: 'table1', + columnNames: ['column1', 'column2'], + unique: true, + synchronize: true, + }; + expect(compareIndexes.onCompare(source, target)).toEqual([ + { + indexName: 'test', + type: 'index.drop', + reason: 'columns are different (column1 vs column1,column2)', + }, + { + type: 'index.create', + index: source, + reason: 'columns are different (column1 vs column1,column2)', + }, + ]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/index.comparer.ts b/server/src/sql-tools/diff/comparers/index.comparer.ts new file mode 100644 index 0000000000..ef07e3a17b --- /dev/null +++ b/server/src/sql-tools/diff/comparers/index.comparer.ts @@ -0,0 +1,46 @@ +import { haveEqualColumns } from 'src/sql-tools/helpers'; +import { Comparer, DatabaseIndex, Reason } from 'src/sql-tools/types'; + +export const compareIndexes: Comparer = { + onMissing: (source) => [ + { + type: 'index.create', + index: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'index.drop', + indexName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: (source, target) => { + const sourceUsing = source.using ?? 'btree'; + const targetUsing = target.using ?? 'btree'; + + let reason = ''; + + if (!haveEqualColumns(source.columnNames, target.columnNames)) { + reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; + } else if (source.unique !== target.unique) { + reason = `uniqueness is different (${source.unique} vs ${target.unique})`; + } else if (sourceUsing !== targetUsing) { + reason = `using method is different (${source.using} vs ${target.using})`; + } else if (source.where !== target.where) { + reason = `where clause is different (${source.where} vs ${target.where})`; + } else if (source.expression !== target.expression) { + reason = `expression is different (${source.expression} vs ${target.expression})`; + } + + if (reason) { + return [ + { type: 'index.drop', indexName: target.name, reason }, + { type: 'index.create', index: source, reason }, + ]; + } + + return []; + }, +}; diff --git a/server/src/sql-tools/diff/comparers/parameter.comparer.spec.ts b/server/src/sql-tools/diff/comparers/parameter.comparer.spec.ts new file mode 100644 index 0000000000..517ec79341 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/parameter.comparer.spec.ts @@ -0,0 +1,44 @@ +import { compareParameters } from 'src/sql-tools/diff/comparers/parameter.comparer'; +import { DatabaseParameter, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testParameter: DatabaseParameter = { + name: 'test', + databaseName: 'immich', + value: 'on', + scope: 'database', + synchronize: true, +}; + +describe('compareParameters', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareParameters.onExtra(testParameter)).toEqual([ + { + type: 'parameter.reset', + databaseName: 'immich', + parameterName: 'test', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareParameters.onMissing(testParameter)).toEqual([ + { + type: 'parameter.set', + parameter: testParameter, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareParameters.onCompare(testParameter, testParameter)).toEqual([]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/parameter.comparer.ts b/server/src/sql-tools/diff/comparers/parameter.comparer.ts new file mode 100644 index 0000000000..03c24bada7 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/parameter.comparer.ts @@ -0,0 +1,23 @@ +import { Comparer, DatabaseParameter, Reason } from 'src/sql-tools/types'; + +export const compareParameters: Comparer = { + onMissing: (source) => [ + { + type: 'parameter.set', + parameter: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'parameter.reset', + databaseName: target.databaseName, + parameterName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: () => { + // TODO + return []; + }, +}; diff --git a/server/src/sql-tools/diff/comparers/table.comparer.spec.ts b/server/src/sql-tools/diff/comparers/table.comparer.spec.ts new file mode 100644 index 0000000000..0b1873b2ba --- /dev/null +++ b/server/src/sql-tools/diff/comparers/table.comparer.spec.ts @@ -0,0 +1,44 @@ +import { compareTables } from 'src/sql-tools/diff/comparers/table.comparer'; +import { DatabaseTable, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testTable: DatabaseTable = { + name: 'test', + columns: [], + constraints: [], + indexes: [], + triggers: [], + synchronize: true, +}; + +describe('compareParameters', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareTables.onExtra(testTable)).toEqual([ + { + type: 'table.drop', + tableName: 'test', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareTables.onMissing(testTable)).toEqual([ + { + type: 'table.create', + table: testTable, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareTables.onCompare(testTable, testTable)).toEqual([]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/table.comparer.ts b/server/src/sql-tools/diff/comparers/table.comparer.ts new file mode 100644 index 0000000000..8f6d0e04f8 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/table.comparer.ts @@ -0,0 +1,59 @@ +import { compareColumns } from 'src/sql-tools/diff/comparers/column.comparer'; +import { compareConstraints } from 'src/sql-tools/diff/comparers/constraint.comparer'; +import { compareIndexes } from 'src/sql-tools/diff/comparers/index.comparer'; +import { compareTriggers } from 'src/sql-tools/diff/comparers/trigger.comparer'; +import { compare } from 'src/sql-tools/helpers'; +import { Comparer, DatabaseTable, Reason, SchemaDiff } from 'src/sql-tools/types'; + +export const compareTables: Comparer = { + onMissing: (source) => [ + { + type: 'table.create', + table: source, + reason: Reason.MissingInTarget, + }, + // TODO merge constraints into table create record when possible + ...compareTable( + source, + { + name: source.name, + columns: [], + indexes: [], + constraints: [], + triggers: [], + synchronize: true, + }, + + { columns: false }, + ), + ], + onExtra: (target) => [ + ...compareTable( + { + name: target.name, + columns: [], + indexes: [], + constraints: [], + triggers: [], + synchronize: true, + }, + target, + { columns: false }, + ), + { + type: 'table.drop', + tableName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: (source, target) => compareTable(source, target, { columns: true }), +}; + +const compareTable = (source: DatabaseTable, target: DatabaseTable, options: { columns?: boolean }): SchemaDiff[] => { + return [ + ...(options.columns ? compare(source.columns, target.columns, {}, compareColumns) : []), + ...compare(source.indexes, target.indexes, {}, compareIndexes), + ...compare(source.constraints, target.constraints, {}, compareConstraints), + ...compare(source.triggers, target.triggers, {}, compareTriggers), + ]; +}; diff --git a/server/src/sql-tools/diff/comparers/trigger.comparer.spec.ts b/server/src/sql-tools/diff/comparers/trigger.comparer.spec.ts new file mode 100644 index 0000000000..800cb4d66b --- /dev/null +++ b/server/src/sql-tools/diff/comparers/trigger.comparer.spec.ts @@ -0,0 +1,88 @@ +import { compareTriggers } from 'src/sql-tools/diff/comparers/trigger.comparer'; +import { DatabaseTrigger, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testTrigger: DatabaseTrigger = { + name: 'test', + tableName: 'table1', + timing: 'before', + actions: ['delete'], + scope: 'row', + functionName: 'my_trigger_function', + synchronize: true, +}; + +describe('compareTriggers', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareTriggers.onExtra(testTrigger)).toEqual([ + { + type: 'trigger.drop', + tableName: 'table1', + triggerName: 'test', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareTriggers.onMissing(testTrigger)).toEqual([ + { + type: 'trigger.create', + trigger: testTrigger, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareTriggers.onCompare(testTrigger, testTrigger)).toEqual([]); + }); + + it('should detect a change in function name', () => { + const source: DatabaseTrigger = { ...testTrigger, functionName: 'my_new_name' }; + const target: DatabaseTrigger = { ...testTrigger, functionName: 'my_old_name' }; + const reason = `function is different (my_new_name vs my_old_name)`; + expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]); + }); + + it('should detect a change in actions', () => { + const source: DatabaseTrigger = { ...testTrigger, actions: ['delete'] }; + const target: DatabaseTrigger = { ...testTrigger, actions: ['delete', 'insert'] }; + const reason = `action is different (delete vs delete,insert)`; + expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]); + }); + + it('should detect a change in timing', () => { + const source: DatabaseTrigger = { ...testTrigger, timing: 'before' }; + const target: DatabaseTrigger = { ...testTrigger, timing: 'after' }; + const reason = `timing method is different (before vs after)`; + expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]); + }); + + it('should detect a change in scope', () => { + const source: DatabaseTrigger = { ...testTrigger, scope: 'row' }; + const target: DatabaseTrigger = { ...testTrigger, scope: 'statement' }; + const reason = `scope is different (row vs statement)`; + expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]); + }); + + it('should detect a change in new table reference', () => { + const source: DatabaseTrigger = { ...testTrigger, referencingNewTableAs: 'new_table' }; + const target: DatabaseTrigger = { ...testTrigger, referencingNewTableAs: undefined }; + const reason = `new table reference is different (new_table vs undefined)`; + expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]); + }); + + it('should detect a change in old table reference', () => { + const source: DatabaseTrigger = { ...testTrigger, referencingOldTableAs: 'old_table' }; + const target: DatabaseTrigger = { ...testTrigger, referencingOldTableAs: undefined }; + const reason = `old table reference is different (old_table vs undefined)`; + expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/trigger.comparer.ts b/server/src/sql-tools/diff/comparers/trigger.comparer.ts new file mode 100644 index 0000000000..38adae9905 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/trigger.comparer.ts @@ -0,0 +1,41 @@ +import { Comparer, DatabaseTrigger, Reason } from 'src/sql-tools/types'; + +export const compareTriggers: Comparer = { + onMissing: (source) => [ + { + type: 'trigger.create', + trigger: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'trigger.drop', + tableName: target.tableName, + triggerName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: (source, target) => { + let reason = ''; + if (source.functionName !== target.functionName) { + reason = `function is different (${source.functionName} vs ${target.functionName})`; + } else if (source.actions.join(' OR ') !== target.actions.join(' OR ')) { + reason = `action is different (${source.actions} vs ${target.actions})`; + } else if (source.timing !== target.timing) { + reason = `timing method is different (${source.timing} vs ${target.timing})`; + } else if (source.scope !== target.scope) { + reason = `scope is different (${source.scope} vs ${target.scope})`; + } else if (source.referencingNewTableAs !== target.referencingNewTableAs) { + reason = `new table reference is different (${source.referencingNewTableAs} vs ${target.referencingNewTableAs})`; + } else if (source.referencingOldTableAs !== target.referencingOldTableAs) { + reason = `old table reference is different (${source.referencingOldTableAs} vs ${target.referencingOldTableAs})`; + } + + if (reason) { + return [{ type: 'trigger.create', trigger: source, reason }]; + } + + return []; + }, +}; diff --git a/server/src/sql-tools/schema-diff.spec.ts b/server/src/sql-tools/diff/index.spec.ts similarity index 94% rename from server/src/sql-tools/schema-diff.spec.ts rename to server/src/sql-tools/diff/index.spec.ts index 2f536cfabd..7ffd3946f2 100644 --- a/server/src/sql-tools/schema-diff.spec.ts +++ b/server/src/sql-tools/diff/index.spec.ts @@ -1,8 +1,8 @@ -import { schemaDiff } from 'src/sql-tools/schema-diff'; +import { schemaDiff } from 'src/sql-tools/diff'; import { + ColumnType, DatabaseActionType, DatabaseColumn, - DatabaseColumnType, DatabaseConstraint, DatabaseConstraintType, DatabaseIndex, @@ -15,7 +15,12 @@ const fromColumn = (column: Partial>): Databas const tableName = 'table1'; return { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: tableName, @@ -31,6 +36,7 @@ const fromColumn = (column: Partial>): Databas }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, @@ -43,7 +49,12 @@ const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => { const tableName = constraint?.tableName || 'table1'; return { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: tableName, @@ -58,6 +69,7 @@ const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => { }, ], indexes: [], + triggers: [], constraints: constraint ? [constraint] : [], synchronize: true, }, @@ -70,7 +82,12 @@ const fromIndex = (index?: DatabaseIndex): DatabaseSchema => { const tableName = index?.tableName || 'table1'; return { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: tableName, @@ -86,6 +103,7 @@ const fromIndex = (index?: DatabaseIndex): DatabaseSchema => { ], indexes: index ? [index] : [], constraints: [], + triggers: [], synchronize: true, }, ], @@ -99,7 +117,7 @@ const newSchema = (schema: { name: string; columns?: Array<{ name: string; - type?: DatabaseColumnType; + type?: ColumnType; nullable?: boolean; isArray?: boolean; }>; @@ -131,12 +149,18 @@ const newSchema = (schema: { columns, indexes: table.indexes ?? [], constraints: table.constraints ?? [], + triggers: [], synchronize: true, }); } return { - name: schema?.name || 'public', + name: 'immich', + schemaName: schema?.name || 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables, warnings: [], }; @@ -167,8 +191,14 @@ describe('schemaDiff', () => { expect(diff.items).toHaveLength(1); expect(diff.items[0]).toEqual({ type: 'table.create', - tableName: 'table1', - columns: [column], + table: { + name: 'table1', + columns: [column], + constraints: [], + indexes: [], + triggers: [], + synchronize: true, + }, reason: 'missing in target', }); }); @@ -181,7 +211,7 @@ describe('schemaDiff', () => { newSchema({ tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], }), - { ignoreExtraTables: false }, + { tables: { ignoreExtra: false } }, ); expect(diff.items).toHaveLength(1); diff --git a/server/src/sql-tools/diff/index.ts b/server/src/sql-tools/diff/index.ts new file mode 100644 index 0000000000..dd90293dc3 --- /dev/null +++ b/server/src/sql-tools/diff/index.ts @@ -0,0 +1,85 @@ +import { compareEnums } from 'src/sql-tools/diff/comparers/enum.comparer'; +import { compareExtensions } from 'src/sql-tools/diff/comparers/extension.comparer'; +import { compareFunctions } from 'src/sql-tools/diff/comparers/function.comparer'; +import { compareParameters } from 'src/sql-tools/diff/comparers/parameter.comparer'; +import { compareTables } from 'src/sql-tools/diff/comparers/table.comparer'; +import { compare } from 'src/sql-tools/helpers'; +import { schemaDiffToSql } from 'src/sql-tools/to-sql'; +import { + DatabaseConstraintType, + DatabaseSchema, + SchemaDiff, + SchemaDiffOptions, + SchemaDiffToSqlOptions, +} from 'src/sql-tools/types'; + +/** + * Compute the difference between two database schemas + */ +export const schemaDiff = (source: DatabaseSchema, target: DatabaseSchema, options: SchemaDiffOptions = {}) => { + const items = [ + ...compare(source.parameters, target.parameters, options.parameters, compareParameters), + ...compare(source.extensions, target.extensions, options.extension, compareExtensions), + ...compare(source.functions, target.functions, options.functions, compareFunctions), + ...compare(source.enums, target.enums, options.enums, compareEnums), + ...compare(source.tables, target.tables, options.tables, compareTables), + ]; + + type SchemaName = SchemaDiff['type']; + const itemMap: Record = { + 'enum.create': [], + 'enum.drop': [], + 'extension.create': [], + 'extension.drop': [], + 'function.create': [], + 'function.drop': [], + 'table.create': [], + 'table.drop': [], + 'column.add': [], + 'column.alter': [], + 'column.drop': [], + 'constraint.add': [], + 'constraint.drop': [], + 'index.create': [], + 'index.drop': [], + 'trigger.create': [], + 'trigger.drop': [], + 'parameter.set': [], + 'parameter.reset': [], + }; + + for (const item of items) { + itemMap[item.type].push(item); + } + + const constraintAdds = itemMap['constraint.add'].filter((item) => item.type === 'constraint.add'); + + const orderedItems = [ + ...itemMap['extension.create'], + ...itemMap['function.create'], + ...itemMap['parameter.set'], + ...itemMap['parameter.reset'], + ...itemMap['enum.create'], + ...itemMap['trigger.drop'], + ...itemMap['index.drop'], + ...itemMap['constraint.drop'], + ...itemMap['table.create'], + ...itemMap['column.alter'], + ...itemMap['column.add'], + ...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.PRIMARY_KEY), + ...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.FOREIGN_KEY), + ...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.UNIQUE), + ...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.CHECK), + ...itemMap['index.create'], + ...itemMap['trigger.create'], + ...itemMap['column.drop'], + ...itemMap['table.drop'], + ...itemMap['enum.drop'], + ...itemMap['function.drop'], + ]; + + return { + items: orderedItems, + asSql: (options?: SchemaDiffToSqlOptions) => schemaDiffToSql(orderedItems, options), + }; +}; diff --git a/server/src/sql-tools/from-code/decorators/after-delete.decorator.ts b/server/src/sql-tools/from-code/decorators/after-delete.decorator.ts new file mode 100644 index 0000000000..7713c4b625 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/after-delete.decorator.ts @@ -0,0 +1,8 @@ +import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/from-code/decorators/trigger-function.decorator'; + +export const AfterDeleteTrigger = (options: Omit) => + TriggerFunction({ + timing: 'after', + actions: ['delete'], + ...options, + }); diff --git a/server/src/sql-tools/from-code/decorators/before-update.decorator.ts b/server/src/sql-tools/from-code/decorators/before-update.decorator.ts new file mode 100644 index 0000000000..03dad25ed0 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/before-update.decorator.ts @@ -0,0 +1,8 @@ +import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/from-code/decorators/trigger-function.decorator'; + +export const BeforeUpdateTrigger = (options: Omit) => + TriggerFunction({ + timing: 'before', + actions: ['update'], + ...options, + }); diff --git a/server/src/sql-tools/from-code/decorators/check.decorator.ts b/server/src/sql-tools/from-code/decorators/check.decorator.ts new file mode 100644 index 0000000000..7d046df0c3 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/check.decorator.ts @@ -0,0 +1,11 @@ +import { register } from 'src/sql-tools/from-code/register'; + +export type CheckOptions = { + name?: string; + expression: string; + synchronize?: boolean; +}; +export const Check = (options: CheckOptions): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'checkConstraint', item: { object, options } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/column-index.decorator.ts b/server/src/sql-tools/from-code/decorators/column-index.decorator.ts new file mode 100644 index 0000000000..ab15292612 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/column-index.decorator.ts @@ -0,0 +1,16 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { asOptions } from 'src/sql-tools/helpers'; + +export type ColumnIndexOptions = { + name?: string; + unique?: boolean; + expression?: string; + using?: string; + with?: string; + where?: string; + synchronize?: boolean; +}; +export const ColumnIndex = (options: string | ColumnIndexOptions = {}): PropertyDecorator => { + return (object: object, propertyName: string | symbol) => + void register({ type: 'columnIndex', item: { object, propertyName, options: asOptions(options) } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/column.decorator.ts b/server/src/sql-tools/from-code/decorators/column.decorator.ts new file mode 100644 index 0000000000..74a83cbcf3 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/column.decorator.ts @@ -0,0 +1,30 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { asOptions } from 'src/sql-tools/helpers'; +import { ColumnStorage, ColumnType, DatabaseEnum } from 'src/sql-tools/types'; + +export type ColumnValue = null | boolean | string | number | object | Date | (() => string); + +export type ColumnBaseOptions = { + name?: string; + primary?: boolean; + type?: ColumnType; + nullable?: boolean; + length?: number; + default?: ColumnValue; + comment?: string; + synchronize?: boolean; + storage?: ColumnStorage; + identity?: boolean; +}; + +export type ColumnOptions = ColumnBaseOptions & { + enum?: DatabaseEnum; + array?: boolean; + unique?: boolean; + uniqueConstraintName?: string; +}; + +export const Column = (options: string | ColumnOptions = {}): PropertyDecorator => { + return (object: object, propertyName: string | symbol) => + void register({ type: 'column', item: { object, propertyName, options: asOptions(options) } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/configuration-parameter.decorator.ts b/server/src/sql-tools/from-code/decorators/configuration-parameter.decorator.ts new file mode 100644 index 0000000000..6a987884d1 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/configuration-parameter.decorator.ts @@ -0,0 +1,14 @@ +import { ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator'; +import { register } from 'src/sql-tools/from-code/register'; +import { ParameterScope } from 'src/sql-tools/types'; + +export type ConfigurationParameterOptions = { + name: string; + value: ColumnValue; + scope: ParameterScope; + synchronize?: boolean; +}; +export const ConfigurationParameter = (options: ConfigurationParameterOptions): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'configurationParameter', item: { object, options } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/create-date-column.decorator.ts b/server/src/sql-tools/from-code/decorators/create-date-column.decorator.ts new file mode 100644 index 0000000000..8f81d59914 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/create-date-column.decorator.ts @@ -0,0 +1,9 @@ +import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator'; + +export const CreateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { + return Column({ + type: 'timestamp with time zone', + default: () => 'now()', + ...options, + }); +}; diff --git a/server/src/sql-tools/from-code/decorators/database.decorator.ts b/server/src/sql-tools/from-code/decorators/database.decorator.ts new file mode 100644 index 0000000000..3bcc464f74 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/database.decorator.ts @@ -0,0 +1,10 @@ +import { register } from 'src/sql-tools/from-code/register'; + +export type DatabaseOptions = { + name?: string; + synchronize?: boolean; +}; +export const Database = (options: DatabaseOptions): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'database', item: { object, options } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/delete-date-column.decorator.ts b/server/src/sql-tools/from-code/decorators/delete-date-column.decorator.ts new file mode 100644 index 0000000000..518c4e76fc --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/delete-date-column.decorator.ts @@ -0,0 +1,9 @@ +import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator'; + +export const DeleteDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { + return Column({ + type: 'timestamp with time zone', + nullable: true, + ...options, + }); +}; diff --git a/server/src/sql-tools/from-code/decorators/extension.decorator.ts b/server/src/sql-tools/from-code/decorators/extension.decorator.ts new file mode 100644 index 0000000000..c43a18c16f --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/extension.decorator.ts @@ -0,0 +1,11 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { asOptions } from 'src/sql-tools/helpers'; + +export type ExtensionOptions = { + name: string; + synchronize?: boolean; +}; +export const Extension = (options: string | ExtensionOptions): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'extension', item: { object, options: asOptions(options) } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/extensions.decorator.ts b/server/src/sql-tools/from-code/decorators/extensions.decorator.ts new file mode 100644 index 0000000000..9d3769a210 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/extensions.decorator.ts @@ -0,0 +1,15 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { asOptions } from 'src/sql-tools/helpers'; + +export type ExtensionsOptions = { + name: string; + synchronize?: boolean; +}; +export const Extensions = (options: Array): 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) } }); + } + }; +}; diff --git a/server/src/sql-tools/from-code/decorators/foreign-key-column.decorator.ts b/server/src/sql-tools/from-code/decorators/foreign-key-column.decorator.ts new file mode 100644 index 0000000000..070aa5cb51 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/foreign-key-column.decorator.ts @@ -0,0 +1,18 @@ +import { ColumnBaseOptions } from 'src/sql-tools/from-code/decorators/column.decorator'; +import { register } from 'src/sql-tools/from-code/register'; + +type Action = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION'; + +export type ForeignKeyColumnOptions = ColumnBaseOptions & { + onUpdate?: Action; + onDelete?: Action; + constraintName?: string; + unique?: boolean; + uniqueConstraintName?: string; +}; + +export const ForeignKeyColumn = (target: () => object, options: ForeignKeyColumnOptions): PropertyDecorator => { + return (object: object, propertyName: string | symbol) => { + register({ type: 'foreignKeyColumn', item: { object, propertyName, options, target } }); + }; +}; diff --git a/server/src/sql-tools/from-code/decorators/generated-column.decorator.ts b/server/src/sql-tools/from-code/decorators/generated-column.decorator.ts new file mode 100644 index 0000000000..82d3131b5c --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/generated-column.decorator.ts @@ -0,0 +1,37 @@ +import { Column, ColumnOptions, ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator'; +import { ColumnType } from 'src/sql-tools/types'; + +export type GeneratedColumnStrategy = 'uuid' | 'identity'; + +export type GenerateColumnOptions = Omit & { + 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, + }); +}; diff --git a/server/src/sql-tools/from-code/decorators/index.decorator.ts b/server/src/sql-tools/from-code/decorators/index.decorator.ts new file mode 100644 index 0000000000..cd76b5e36d --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/index.decorator.ts @@ -0,0 +1,12 @@ +import { ColumnIndexOptions } from 'src/sql-tools/from-code/decorators/column-index.decorator'; +import { register } from 'src/sql-tools/from-code/register'; +import { asOptions } from 'src/sql-tools/helpers'; + +export type IndexOptions = ColumnIndexOptions & { + columns?: string[]; + synchronize?: boolean; +}; +export const Index = (options: string | IndexOptions = {}): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'index', item: { object, options: asOptions(options) } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/primary-column.decorator.ts b/server/src/sql-tools/from-code/decorators/primary-column.decorator.ts new file mode 100644 index 0000000000..f702965675 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/primary-column.decorator.ts @@ -0,0 +1,3 @@ +import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator'; + +export const PrimaryColumn = (options: Omit = {}) => Column({ ...options, primary: true }); diff --git a/server/src/sql-tools/from-code/decorators/primary-generated-column.decorator.ts b/server/src/sql-tools/from-code/decorators/primary-generated-column.decorator.ts new file mode 100644 index 0000000000..9dc8ca6817 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/primary-generated-column.decorator.ts @@ -0,0 +1,4 @@ +import { GenerateColumnOptions, GeneratedColumn } from 'src/sql-tools/from-code/decorators/generated-column.decorator'; + +export const PrimaryGeneratedColumn = (options: Omit = {}) => + GeneratedColumn({ ...options, primary: true }); diff --git a/server/src/sql-tools/from-code/decorators/table.decorator.ts b/server/src/sql-tools/from-code/decorators/table.decorator.ts new file mode 100644 index 0000000000..589a88aa29 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/table.decorator.ts @@ -0,0 +1,14 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { asOptions } from 'src/sql-tools/helpers'; + +export type TableOptions = { + name?: string; + primaryConstraintName?: string; + synchronize?: boolean; +}; + +/** Table comments here */ +export const Table = (options: string | TableOptions = {}): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'table', item: { object, options: asOptions(options) } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/trigger-function.decorator.ts b/server/src/sql-tools/from-code/decorators/trigger-function.decorator.ts new file mode 100644 index 0000000000..68ea286474 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/trigger-function.decorator.ts @@ -0,0 +1,6 @@ +import { Trigger, TriggerOptions } from 'src/sql-tools/from-code/decorators/trigger.decorator'; +import { DatabaseFunction } from 'src/sql-tools/types'; + +export type TriggerFunctionOptions = Omit & { function: DatabaseFunction }; +export const TriggerFunction = (options: TriggerFunctionOptions) => + Trigger({ ...options, functionName: options.function.name }); diff --git a/server/src/sql-tools/from-code/decorators/trigger.decorator.ts b/server/src/sql-tools/from-code/decorators/trigger.decorator.ts new file mode 100644 index 0000000000..e0c0ccf3e4 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/trigger.decorator.ts @@ -0,0 +1,19 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { TriggerAction, TriggerScope, TriggerTiming } from 'src/sql-tools/types'; + +export type TriggerOptions = { + name?: string; + timing: TriggerTiming; + actions: TriggerAction[]; + scope: TriggerScope; + functionName: string; + referencingNewTableAs?: string; + referencingOldTableAs?: string; + when?: string; + synchronize?: boolean; +}; + +export const Trigger = (options: TriggerOptions): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'trigger', item: { object, options } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/unique.decorator.ts b/server/src/sql-tools/from-code/decorators/unique.decorator.ts new file mode 100644 index 0000000000..c7186d7296 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/unique.decorator.ts @@ -0,0 +1,11 @@ +import { register } from 'src/sql-tools/from-code/register'; + +export type UniqueOptions = { + name?: string; + columns: string[]; + synchronize?: boolean; +}; +export const Unique = (options: UniqueOptions): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'uniqueConstraint', item: { object, options } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/update-date-column.decorator.ts b/server/src/sql-tools/from-code/decorators/update-date-column.decorator.ts new file mode 100644 index 0000000000..ddc7a6a1e8 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/update-date-column.decorator.ts @@ -0,0 +1,9 @@ +import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator'; + +export const UpdateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { + return Column({ + type: 'timestamp with time zone', + default: () => 'now()', + ...options, + }); +}; diff --git a/server/src/sql-tools/schema-from-decorators.spec.ts b/server/src/sql-tools/from-code/index.spec.ts similarity index 64% rename from server/src/sql-tools/schema-from-decorators.spec.ts rename to server/src/sql-tools/from-code/index.spec.ts index 6703277844..5306722c76 100644 --- a/server/src/sql-tools/schema-from-decorators.spec.ts +++ b/server/src/sql-tools/from-code/index.spec.ts @@ -1,16 +1,21 @@ import { readdirSync } from 'node:fs'; import { join } from 'node:path'; -import { reset, schemaFromDecorators } from 'src/sql-tools/schema-from-decorators'; +import { reset, schemaFromCode } from 'src/sql-tools/from-code'; import { describe, expect, it } from 'vitest'; -describe('schemaDiff', () => { +describe(schemaFromCode.name, () => { beforeEach(() => { reset(); }); it('should work', () => { - expect(schemaFromDecorators()).toEqual({ - name: 'public', + expect(schemaFromCode()).toEqual({ + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [], warnings: [], }); @@ -24,7 +29,7 @@ describe('schemaDiff', () => { const module = await import(filePath); expect(module.description).toBeDefined(); expect(module.schema).toBeDefined(); - expect(schemaFromDecorators(), module.description).toEqual(module.schema); + expect(schemaFromCode(), module.description).toEqual(module.schema); }); } }); diff --git a/server/src/sql-tools/from-code/index.ts b/server/src/sql-tools/from-code/index.ts new file mode 100644 index 0000000000..3c74d2763c --- /dev/null +++ b/server/src/sql-tools/from-code/index.ts @@ -0,0 +1,69 @@ +import 'reflect-metadata'; +import { processCheckConstraints } from 'src/sql-tools/from-code/processors/check-constraint.processor'; +import { processColumnIndexes } from 'src/sql-tools/from-code/processors/column-index.processor'; +import { processColumns } from 'src/sql-tools/from-code/processors/column.processor'; +import { processConfigurationParameters } from 'src/sql-tools/from-code/processors/configuration-parameter.processor'; +import { processDatabases } from 'src/sql-tools/from-code/processors/database.processor'; +import { processEnums } from 'src/sql-tools/from-code/processors/enum.processor'; +import { processExtensions } from 'src/sql-tools/from-code/processors/extension.processor'; +import { processForeignKeyConstraints } from 'src/sql-tools/from-code/processors/foreign-key-constriant.processor'; +import { processFunctions } from 'src/sql-tools/from-code/processors/function.processor'; +import { processIndexes } from 'src/sql-tools/from-code/processors/index.processor'; +import { processPrimaryKeyConstraints } from 'src/sql-tools/from-code/processors/primary-key-contraint.processor'; +import { processTables } from 'src/sql-tools/from-code/processors/table.processor'; +import { processTriggers } from 'src/sql-tools/from-code/processors/trigger.processor'; +import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type'; +import { processUniqueConstraints } from 'src/sql-tools/from-code/processors/unique-constraint.processor'; +import { getRegisteredItems, resetRegisteredItems } from 'src/sql-tools/from-code/register'; +import { DatabaseSchema } from 'src/sql-tools/types'; + +let initialized = false; +let schema: DatabaseSchema; + +export const reset = () => { + initialized = false; + resetRegisteredItems(); +}; + +const processors: Processor[] = [ + processDatabases, + processConfigurationParameters, + processEnums, + processExtensions, + processFunctions, + processTables, + processColumns, + processUniqueConstraints, + processCheckConstraints, + processPrimaryKeyConstraints, + processIndexes, + processColumnIndexes, + processForeignKeyConstraints, + processTriggers, +]; + +export const schemaFromCode = () => { + if (!initialized) { + const builder: SchemaBuilder = { + name: 'postgres', + schemaName: 'public', + tables: [], + functions: [], + enums: [], + extensions: [], + parameters: [], + warnings: [], + }; + + const items = getRegisteredItems(); + + for (const processor of processors) { + processor(builder, items); + } + + schema = { ...builder, tables: builder.tables.map(({ metadata: _, ...table }) => table) }; + initialized = true; + } + + return schema; +}; diff --git a/server/src/sql-tools/from-code/processors/check-constraint.processor.ts b/server/src/sql-tools/from-code/processors/check-constraint.processor.ts new file mode 100644 index 0000000000..d61ee18277 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/check-constraint.processor.ts @@ -0,0 +1,26 @@ +import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor'; +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { asCheckConstraintName } from 'src/sql-tools/helpers'; +import { DatabaseConstraintType } from 'src/sql-tools/types'; + +export const processCheckConstraints: Processor = (builder, items) => { + for (const { + item: { object, options }, + } of items.filter((item) => item.type === 'checkConstraint')) { + const table = resolveTable(builder, object); + if (!table) { + onMissingTable(builder, '@Check', object); + continue; + } + + const tableName = table.name; + + table.constraints.push({ + type: DatabaseConstraintType.CHECK, + name: options.name || asCheckConstraintName(tableName, options.expression), + tableName, + expression: options.expression, + synchronize: options.synchronize ?? true, + }); + } +}; diff --git a/server/src/sql-tools/from-code/processors/column-index.processor.ts b/server/src/sql-tools/from-code/processors/column-index.processor.ts new file mode 100644 index 0000000000..0e40fa1ee3 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/column-index.processor.ts @@ -0,0 +1,32 @@ +import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor'; +import { onMissingTable } from 'src/sql-tools/from-code/processors/table.processor'; +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { asIndexName } from 'src/sql-tools/helpers'; + +export const processColumnIndexes: Processor = (builder, items) => { + for (const { + item: { object, propertyName, options }, + } of items.filter((item) => item.type === 'columnIndex')) { + const { table, column } = resolveColumn(builder, object, propertyName); + if (!table) { + onMissingTable(builder, '@ColumnIndex', object); + continue; + } + + if (!column) { + onMissingColumn(builder, `@ColumnIndex`, object, propertyName); + continue; + } + + table.indexes.push({ + name: options.name || asIndexName(table.name, [column.name], options.where), + tableName: table.name, + unique: options.unique ?? false, + expression: options.expression, + using: options.using, + where: options.where, + columnNames: [column.name], + synchronize: options.synchronize ?? true, + }); + } +}; diff --git a/server/src/sql-tools/from-code/processors/column.processor.ts b/server/src/sql-tools/from-code/processors/column.processor.ts new file mode 100644 index 0000000000..37f3f5d082 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/column.processor.ts @@ -0,0 +1,103 @@ +import { ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator'; +import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor'; +import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type'; +import { asMetadataKey, asUniqueConstraintName, fromColumnValue } from 'src/sql-tools/helpers'; +import { DatabaseColumn, DatabaseConstraintType } from 'src/sql-tools/types'; + +export const processColumns: Processor = (builder, items) => { + for (const { + type, + item: { object, propertyName, options }, + } of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) { + const table = resolveTable(builder, object.constructor); + if (!table) { + onMissingTable(builder, type === 'column' ? '@Column' : '@ForeignKeyColumn', object, propertyName); + continue; + } + + const columnName = options.name ?? String(propertyName); + const existingColumn = table.columns.find((column) => column.name === columnName); + if (existingColumn) { + // TODO log warnings if column name is not unique + continue; + } + + const tableName = table.name; + + let defaultValue = fromColumnValue(options.default); + let nullable = options.nullable ?? false; + + // map `{ default: null }` to `{ nullable: true }` + if (defaultValue === null) { + nullable = true; + defaultValue = undefined; + } + + const isEnum = !!(options as ColumnOptions).enum; + + const column: DatabaseColumn = { + name: columnName, + tableName, + primary: options.primary ?? false, + default: defaultValue, + nullable, + isArray: (options as ColumnOptions).array ?? false, + length: options.length, + type: isEnum ? 'enum' : options.type || 'character varying', + enumName: isEnum ? (options as ColumnOptions).enum!.name : undefined, + comment: options.comment, + storage: options.storage, + identity: options.identity, + synchronize: options.synchronize ?? true, + }; + + writeMetadata(object, propertyName, { name: column.name, options }); + + table.columns.push(column); + + if (type === 'column' && !options.primary && options.unique) { + table.constraints.push({ + type: DatabaseConstraintType.UNIQUE, + name: options.uniqueConstraintName || asUniqueConstraintName(table.name, [column.name]), + tableName: table.name, + columnNames: [column.name], + synchronize: options.synchronize ?? true, + }); + } + } +}; + +type ColumnMetadata = { name: string; options: ColumnOptions }; + +export const resolveColumn = (builder: SchemaBuilder, object: object, propertyName: string | symbol) => { + const table = resolveTable(builder, object.constructor); + if (!table) { + return {}; + } + + const metadata = readMetadata(object, propertyName); + if (!metadata) { + return { table }; + } + + const column = table.columns.find((column) => column.name === metadata.name); + return { table, column }; +}; + +export const onMissingColumn = ( + builder: SchemaBuilder, + context: string, + object: object, + propertyName?: symbol | string, +) => { + const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : ''); + builder.warnings.push(`[${context}] Unable to find column (${label})`); +}; + +const METADATA_KEY = asMetadataKey('table-metadata'); + +const writeMetadata = (object: object, propertyName: symbol | string, metadata: ColumnMetadata) => + Reflect.defineMetadata(METADATA_KEY, metadata, object, propertyName); + +const readMetadata = (object: object, propertyName: symbol | string): ColumnMetadata | undefined => + Reflect.getMetadata(METADATA_KEY, object, propertyName); diff --git a/server/src/sql-tools/from-code/processors/configuration-parameter.processor.ts b/server/src/sql-tools/from-code/processors/configuration-parameter.processor.ts new file mode 100644 index 0000000000..493214e5b8 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/configuration-parameter.processor.ts @@ -0,0 +1,16 @@ +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { fromColumnValue } from 'src/sql-tools/helpers'; + +export const processConfigurationParameters: Processor = (builder, items) => { + for (const { + item: { options }, + } of items.filter((item) => item.type === 'configurationParameter')) { + builder.parameters.push({ + databaseName: builder.name, + name: options.name, + value: fromColumnValue(options.value), + scope: options.scope, + synchronize: options.synchronize ?? true, + }); + } +}; diff --git a/server/src/sql-tools/from-code/processors/database.processor.ts b/server/src/sql-tools/from-code/processors/database.processor.ts new file mode 100644 index 0000000000..9b0662f7e0 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/database.processor.ts @@ -0,0 +1,10 @@ +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { asSnakeCase } from 'src/sql-tools/helpers'; + +export const processDatabases: Processor = (builder, items) => { + for (const { + item: { object, options }, + } of items.filter((item) => item.type === 'database')) { + builder.name = options.name || asSnakeCase(object.name); + } +}; diff --git a/server/src/sql-tools/from-code/processors/enum.processor.ts b/server/src/sql-tools/from-code/processors/enum.processor.ts new file mode 100644 index 0000000000..d6d19ec025 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/enum.processor.ts @@ -0,0 +1,8 @@ +import { Processor } from 'src/sql-tools/from-code/processors/type'; + +export const processEnums: Processor = (builder, items) => { + for (const { item } of items.filter((item) => item.type === 'enum')) { + // TODO log warnings if enum name is not unique + builder.enums.push(item); + } +}; diff --git a/server/src/sql-tools/from-code/processors/extension.processor.ts b/server/src/sql-tools/from-code/processors/extension.processor.ts new file mode 100644 index 0000000000..4b12054aa3 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/extension.processor.ts @@ -0,0 +1,12 @@ +import { Processor } from 'src/sql-tools/from-code/processors/type'; + +export const processExtensions: Processor = (builder, items) => { + for (const { + item: { options }, + } of items.filter((item) => item.type === 'extension')) { + builder.extensions.push({ + name: options.name, + synchronize: options.synchronize ?? true, + }); + } +}; diff --git a/server/src/sql-tools/from-code/processors/foreign-key-constriant.processor.ts b/server/src/sql-tools/from-code/processors/foreign-key-constriant.processor.ts new file mode 100644 index 0000000000..784a8b8e99 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/foreign-key-constriant.processor.ts @@ -0,0 +1,59 @@ +import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor'; +import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor'; +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { asForeignKeyConstraintName, asRelationKeyConstraintName } from 'src/sql-tools/helpers'; +import { DatabaseActionType, DatabaseConstraintType } from 'src/sql-tools/types'; + +export const processForeignKeyConstraints: Processor = (builder, items) => { + for (const { + item: { object, propertyName, options, target }, + } of items.filter((item) => item.type === 'foreignKeyColumn')) { + const { table, column } = resolveColumn(builder, object, propertyName); + if (!table) { + onMissingTable(builder, '@ForeignKeyColumn', object); + continue; + } + + if (!column) { + // should be impossible since they are pre-created in `column.processor.ts` + onMissingColumn(builder, '@ForeignKeyColumn', object, propertyName); + continue; + } + + const referenceTable = resolveTable(builder, target()); + if (!referenceTable) { + onMissingTable(builder, '@ForeignKeyColumn', object, propertyName); + continue; + } + + const columnNames = [column.name]; + const referenceColumns = referenceTable.columns.filter((column) => column.primary); + + // infer FK column type from reference table + if (referenceColumns.length === 1) { + column.type = referenceColumns[0].type; + } + + table.constraints.push({ + name: options.constraintName || asForeignKeyConstraintName(table.name, columnNames), + tableName: table.name, + columnNames, + type: DatabaseConstraintType.FOREIGN_KEY, + referenceTableName: referenceTable.name, + referenceColumnNames: referenceColumns.map((column) => column.name), + onUpdate: options.onUpdate as DatabaseActionType, + onDelete: options.onDelete as DatabaseActionType, + synchronize: options.synchronize ?? true, + }); + + if (options.unique) { + table.constraints.push({ + name: options.uniqueConstraintName || asRelationKeyConstraintName(table.name, columnNames), + tableName: table.name, + columnNames, + type: DatabaseConstraintType.UNIQUE, + synchronize: options.synchronize ?? true, + }); + } + } +}; diff --git a/server/src/sql-tools/from-code/processors/function.processor.ts b/server/src/sql-tools/from-code/processors/function.processor.ts new file mode 100644 index 0000000000..cbd9c87abc --- /dev/null +++ b/server/src/sql-tools/from-code/processors/function.processor.ts @@ -0,0 +1,8 @@ +import { Processor } from 'src/sql-tools/from-code/processors/type'; + +export const processFunctions: Processor = (builder, items) => { + for (const { item } of items.filter((item) => item.type === 'function')) { + // TODO log warnings if function name is not unique + builder.functions.push(item); + } +}; diff --git a/server/src/sql-tools/from-code/processors/index.processor.ts b/server/src/sql-tools/from-code/processors/index.processor.ts new file mode 100644 index 0000000000..3625bf9784 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/index.processor.ts @@ -0,0 +1,27 @@ +import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor'; +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { asIndexName } from 'src/sql-tools/helpers'; + +export const processIndexes: Processor = (builder, items) => { + for (const { + item: { object, options }, + } of items.filter((item) => item.type === 'index')) { + const table = resolveTable(builder, object); + if (!table) { + onMissingTable(builder, '@Check', object); + continue; + } + + table.indexes.push({ + name: options.name || asIndexName(table.name, options.columns, options.where), + tableName: table.name, + unique: options.unique ?? false, + expression: options.expression, + using: options.using, + with: options.with, + where: options.where, + columnNames: options.columns, + synchronize: options.synchronize ?? true, + }); + } +}; diff --git a/server/src/sql-tools/from-code/processors/primary-key-contraint.processor.ts b/server/src/sql-tools/from-code/processors/primary-key-contraint.processor.ts new file mode 100644 index 0000000000..f123f2e495 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/primary-key-contraint.processor.ts @@ -0,0 +1,24 @@ +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { asPrimaryKeyConstraintName } from 'src/sql-tools/helpers'; +import { DatabaseConstraintType } from 'src/sql-tools/types'; + +export const processPrimaryKeyConstraints: Processor = (builder) => { + for (const table of builder.tables) { + const columnNames: string[] = []; + + for (const column of table.columns) { + if (column.primary) { + columnNames.push(column.name); + } + } + if (columnNames.length > 0) { + table.constraints.push({ + type: DatabaseConstraintType.PRIMARY_KEY, + name: table.metadata.options.primaryConstraintName || asPrimaryKeyConstraintName(table.name, columnNames), + tableName: table.name, + columnNames, + synchronize: table.metadata.options.synchronize ?? true, + }); + } + } +}; diff --git a/server/src/sql-tools/from-code/processors/table.processor.ts b/server/src/sql-tools/from-code/processors/table.processor.ts new file mode 100644 index 0000000000..eb4b414576 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/table.processor.ts @@ -0,0 +1,51 @@ +import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator'; +import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type'; +import { asMetadataKey, asSnakeCase } from 'src/sql-tools/helpers'; + +export const processTables: Processor = (builder, items) => { + for (const { + item: { options, object }, + } of items.filter((item) => item.type === 'table')) { + const tableName = options.name || asSnakeCase(object.name); + + writeMetadata(object, { name: tableName, options }); + + builder.tables.push({ + name: tableName, + columns: [], + constraints: [], + indexes: [], + triggers: [], + synchronize: options.synchronize ?? true, + metadata: { options, object }, + }); + } +}; + +export const resolveTable = (builder: SchemaBuilder, object: object) => { + const metadata = readMetadata(object); + if (!metadata) { + return; + } + + return builder.tables.find((table) => table.name === metadata.name); +}; + +export const onMissingTable = ( + builder: SchemaBuilder, + context: string, + object: object, + propertyName?: symbol | string, +) => { + const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : ''); + builder.warnings.push(`[${context}] Unable to find table (${label})`); +}; + +const METADATA_KEY = asMetadataKey('table-metadata'); + +type TableMetadata = { name: string; options: TableOptions }; + +const readMetadata = (object: object): TableMetadata | undefined => Reflect.getMetadata(METADATA_KEY, object); + +const writeMetadata = (object: object, metadata: TableMetadata): void => + Reflect.defineMetadata(METADATA_KEY, metadata, object); diff --git a/server/src/sql-tools/from-code/processors/trigger.processor.ts b/server/src/sql-tools/from-code/processors/trigger.processor.ts new file mode 100644 index 0000000000..2f4cc04326 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/trigger.processor.ts @@ -0,0 +1,28 @@ +import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor'; +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { asTriggerName } from 'src/sql-tools/helpers'; + +export const processTriggers: Processor = (builder, items) => { + for (const { + item: { object, options }, + } of items.filter((item) => item.type === 'trigger')) { + const table = resolveTable(builder, object); + if (!table) { + onMissingTable(builder, '@Trigger', object); + continue; + } + + table.triggers.push({ + name: options.name || asTriggerName(table.name, options), + tableName: table.name, + timing: options.timing, + actions: options.actions, + when: options.when, + scope: options.scope, + referencingNewTableAs: options.referencingNewTableAs, + referencingOldTableAs: options.referencingOldTableAs, + functionName: options.functionName, + synchronize: options.synchronize ?? true, + }); + } +}; diff --git a/server/src/sql-tools/from-code/processors/type.ts b/server/src/sql-tools/from-code/processors/type.ts new file mode 100644 index 0000000000..5a69efbcf0 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/type.ts @@ -0,0 +1,9 @@ +import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator'; +import { RegisterItem } from 'src/sql-tools/from-code/register-item'; +import { DatabaseSchema, DatabaseTable } from 'src/sql-tools/types'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export type TableWithMetadata = DatabaseTable & { metadata: { options: TableOptions; object: Function } }; +export type SchemaBuilder = Omit & { tables: TableWithMetadata[] }; + +export type Processor = (builder: SchemaBuilder, items: RegisterItem[]) => void; diff --git a/server/src/sql-tools/from-code/processors/unique-constraint.processor.ts b/server/src/sql-tools/from-code/processors/unique-constraint.processor.ts new file mode 100644 index 0000000000..74c0504f7e --- /dev/null +++ b/server/src/sql-tools/from-code/processors/unique-constraint.processor.ts @@ -0,0 +1,27 @@ +import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor'; +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { asUniqueConstraintName } from 'src/sql-tools/helpers'; +import { DatabaseConstraintType } from 'src/sql-tools/types'; + +export const processUniqueConstraints: Processor = (builder, items) => { + for (const { + item: { object, options }, + } of items.filter((item) => item.type === 'uniqueConstraint')) { + const table = resolveTable(builder, object); + if (!table) { + onMissingTable(builder, '@Unique', object); + continue; + } + + const tableName = table.name; + const columnNames = options.columns; + + table.constraints.push({ + type: DatabaseConstraintType.UNIQUE, + name: options.name || asUniqueConstraintName(tableName, columnNames), + tableName, + columnNames, + synchronize: options.synchronize ?? true, + }); + } +}; diff --git a/server/src/sql-tools/from-code/register-enum.ts b/server/src/sql-tools/from-code/register-enum.ts new file mode 100644 index 0000000000..e2415cebff --- /dev/null +++ b/server/src/sql-tools/from-code/register-enum.ts @@ -0,0 +1,20 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { DatabaseEnum } from 'src/sql-tools/types'; + +export type EnumOptions = { + name: string; + values: string[]; + synchronize?: boolean; +}; + +export const registerEnum = (options: EnumOptions) => { + const item: DatabaseEnum = { + name: options.name, + values: options.values, + synchronize: options.synchronize ?? true, + }; + + register({ type: 'enum', item }); + + return item; +}; diff --git a/server/src/sql-tools/from-code/register-function.ts b/server/src/sql-tools/from-code/register-function.ts new file mode 100644 index 0000000000..69e1a0f8f3 --- /dev/null +++ b/server/src/sql-tools/from-code/register-function.ts @@ -0,0 +1,29 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { asFunctionExpression } from 'src/sql-tools/helpers'; +import { ColumnType, DatabaseFunction } from 'src/sql-tools/types'; + +export type FunctionOptions = { + name: string; + arguments?: string[]; + returnType: ColumnType | string; + language?: 'SQL' | 'PLPGSQL'; + behavior?: 'immutable' | 'stable' | 'volatile'; + parallel?: 'safe' | 'unsafe' | 'restricted'; + strict?: boolean; + synchronize?: boolean; +} & ({ body: string } | { return: string }); + +export const registerFunction = (options: FunctionOptions) => { + const name = options.name; + const expression = asFunctionExpression(options); + + const item: DatabaseFunction = { + name, + expression, + synchronize: options.synchronize ?? true, + }; + + register({ type: 'function', item }); + + return item; +}; diff --git a/server/src/sql-tools/from-code/register-item.ts b/server/src/sql-tools/from-code/register-item.ts new file mode 100644 index 0000000000..08200cbc4f --- /dev/null +++ b/server/src/sql-tools/from-code/register-item.ts @@ -0,0 +1,31 @@ +import { CheckOptions } from 'src/sql-tools/from-code/decorators/check.decorator'; +import { ColumnIndexOptions } from 'src/sql-tools/from-code/decorators/column-index.decorator'; +import { ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator'; +import { ConfigurationParameterOptions } from 'src/sql-tools/from-code/decorators/configuration-parameter.decorator'; +import { DatabaseOptions } from 'src/sql-tools/from-code/decorators/database.decorator'; +import { ExtensionOptions } from 'src/sql-tools/from-code/decorators/extension.decorator'; +import { ForeignKeyColumnOptions } from 'src/sql-tools/from-code/decorators/foreign-key-column.decorator'; +import { IndexOptions } from 'src/sql-tools/from-code/decorators/index.decorator'; +import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator'; +import { TriggerOptions } from 'src/sql-tools/from-code/decorators/trigger.decorator'; +import { UniqueOptions } from 'src/sql-tools/from-code/decorators/unique.decorator'; +import { DatabaseEnum, DatabaseFunction } from 'src/sql-tools/types'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export type ClassBased = { object: Function } & T; +export type PropertyBased = { object: object; propertyName: string | symbol } & T; +export type RegisterItem = + | { type: 'database'; item: ClassBased<{ options: DatabaseOptions }> } + | { type: 'table'; item: ClassBased<{ options: TableOptions }> } + | { type: 'index'; item: ClassBased<{ options: IndexOptions }> } + | { type: 'uniqueConstraint'; item: ClassBased<{ options: UniqueOptions }> } + | { type: 'checkConstraint'; item: ClassBased<{ options: CheckOptions }> } + | { type: 'column'; item: PropertyBased<{ options: ColumnOptions }> } + | { type: 'columnIndex'; item: PropertyBased<{ options: ColumnIndexOptions }> } + | { type: 'function'; item: DatabaseFunction } + | { type: 'enum'; item: DatabaseEnum } + | { type: 'trigger'; item: ClassBased<{ options: TriggerOptions }> } + | { type: 'extension'; item: ClassBased<{ options: ExtensionOptions }> } + | { type: 'configurationParameter'; item: ClassBased<{ options: ConfigurationParameterOptions }> } + | { type: 'foreignKeyColumn'; item: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }> }; +export type RegisterItemType = Extract['item']; diff --git a/server/src/sql-tools/from-code/register.ts b/server/src/sql-tools/from-code/register.ts new file mode 100644 index 0000000000..824af28c52 --- /dev/null +++ b/server/src/sql-tools/from-code/register.ts @@ -0,0 +1,11 @@ +import { RegisterItem } from 'src/sql-tools/from-code/register-item'; + +const items: RegisterItem[] = []; + +export const register = (item: RegisterItem) => void items.push(item); + +export const getRegisteredItems = () => items; + +export const resetRegisteredItems = () => { + items.length = 0; +}; diff --git a/server/src/sql-tools/schema-from-database.ts b/server/src/sql-tools/from-database/index.ts similarity index 67% rename from server/src/sql-tools/schema-from-database.ts rename to server/src/sql-tools/from-database/index.ts index fe7af6b623..3c66788670 100644 --- a/server/src/sql-tools/schema-from-database.ts +++ b/server/src/sql-tools/from-database/index.ts @@ -1,16 +1,22 @@ -import { Kysely, sql } from 'kysely'; +import { Kysely, QueryResult, sql } from 'kysely'; import { PostgresJSDialect } from 'kysely-postgres-js'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { Sql } from 'postgres'; +import { parseTriggerType } from 'src/sql-tools/helpers'; import { + ColumnType, DatabaseActionType, DatabaseClient, DatabaseColumn, - DatabaseColumnType, DatabaseConstraintType, + DatabaseEnum, + DatabaseExtension, + DatabaseFunction, + DatabaseParameter, DatabaseSchema, DatabaseTable, LoadSchemaOptions, + ParameterScope, PostgresDB, } from 'src/sql-tools/types'; @@ -28,16 +34,66 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio const schemaName = options.schemaName || 'public'; const tablesMap: Record = {}; - const [tables, columns, indexes, constraints, enums] = await Promise.all([ + const [ + databaseName, + tables, + columns, + indexes, + constraints, + enums, + routines, + extensions, + triggers, + parameters, + comments, + ] = await Promise.all([ + getDatabaseName(db), getTables(db, schemaName), getTableColumns(db, schemaName), getTableIndexes(db, schemaName), getTableConstraints(db, schemaName), getUserDefinedEnums(db, schemaName), + getRoutines(db, schemaName), + getExtensions(db), + getTriggers(db, schemaName), + getParameters(db), + getObjectComments(db), ]); + const schemaEnums: DatabaseEnum[] = []; + const schemaFunctions: DatabaseFunction[] = []; + const schemaExtensions: DatabaseExtension[] = []; + const schemaParameters: DatabaseParameter[] = []; + const enumMap = Object.fromEntries(enums.map((e) => [e.name, e.values])); + for (const { name } of extensions) { + schemaExtensions.push({ name, synchronize: true }); + } + + for (const { name, values } of enums) { + schemaEnums.push({ name, values, synchronize: true }); + } + + for (const parameter of parameters) { + schemaParameters.push({ + name: parameter.name, + value: parameter.value, + databaseName, + scope: parameter.scope as ParameterScope, + synchronize: true, + }); + } + + for (const { name, expression } of routines) { + schemaFunctions.push({ + name, + // TODO read expression from the overrides table + expression, + synchronize: true, + }); + } + // add tables for (const table of tables) { const tableName = table.table_name; @@ -49,6 +105,7 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio name: table.table_name, columns: [], indexes: [], + triggers: [], constraints: [], synchronize: true, }; @@ -64,13 +121,14 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio const columnName = column.column_name; const item: DatabaseColumn = { - type: column.data_type as DatabaseColumnType, + type: column.data_type as ColumnType, name: columnName, tableName: column.table_name, nullable: column.is_nullable === 'YES', isArray: column.array_type !== null, numericPrecision: column.numeric_precision ?? undefined, numericScale: column.numeric_scale ?? undefined, + length: column.character_maximum_length ?? undefined, default: column.column_default ?? undefined, synchronize: true, }; @@ -84,7 +142,7 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio warn(`Unable to find type for ${columnLabel} (ARRAY)`); continue; } - item.type = column.array_type as DatabaseColumnType; + item.type = column.array_type as ColumnType; break; } @@ -97,7 +155,6 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio item.type = 'enum'; item.enumName = column.udt_name; - item.enumValues = enumMap[column.udt_name]; break; } } @@ -201,10 +258,50 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio } } + // add triggers to tables + for (const trigger of triggers) { + const table = tablesMap[trigger.table_name]; + if (!table) { + continue; + } + + table.triggers.push({ + name: trigger.name, + tableName: trigger.table_name, + functionName: trigger.function_name, + referencingNewTableAs: trigger.referencing_new_table_as ?? undefined, + referencingOldTableAs: trigger.referencing_old_table_as ?? undefined, + when: trigger.when_expression, + synchronize: true, + ...parseTriggerType(trigger.type), + }); + } + + for (const comment of comments) { + if (comment.object_type === 'r') { + const table = tablesMap[comment.object_name]; + if (!table) { + continue; + } + + if (comment.column_name) { + const column = table.columns.find(({ name }) => name === comment.column_name); + if (column) { + column.comment = comment.value; + } + } + } + } + await db.destroy(); return { - name: schemaName, + name: databaseName, + schemaName, + parameters: schemaParameters, + functions: schemaFunctions, + enums: schemaEnums, + extensions: schemaExtensions, tables: Object.values(tablesMap), warnings, }; @@ -237,6 +334,11 @@ const asDatabaseAction = (action: string) => { } }; +const getDatabaseName = async (db: DatabaseClient) => { + const result = (await sql`SELECT current_database() as name`.execute(db)) as QueryResult<{ name: string }>; + return result.rows[0].name; +}; + const getTables = (db: DatabaseClient, schemaName: string) => { return db .selectFrom('information_schema.tables') @@ -246,27 +348,6 @@ const getTables = (db: DatabaseClient, schemaName: string) => { .execute(); }; -const getUserDefinedEnums = async (db: DatabaseClient, schemaName: string) => { - const items = await db - .selectFrom('pg_type') - .innerJoin('pg_namespace', (join) => - join.onRef('pg_namespace.oid', '=', 'pg_type.typnamespace').on('pg_namespace.nspname', '=', schemaName), - ) - .where('typtype', '=', sql.lit('e')) - .select((eb) => [ - 'pg_type.typname as name', - jsonArrayFrom( - eb.selectFrom('pg_enum as e').select(['e.enumlabel as value']).whereRef('e.enumtypid', '=', 'pg_type.oid'), - ).as('values'), - ]) - .execute(); - - return items.map((item) => ({ - name: item.name, - values: item.values.map(({ value }) => value), - })); -}; - const getTableColumns = (db: DatabaseClient, schemaName: string) => { return db .selectFrom('information_schema.columns as c') @@ -290,6 +371,7 @@ const getTableColumns = (db: DatabaseClient, schemaName: string) => { 'c.data_type', 'c.column_default', 'c.is_nullable', + 'c.character_maximum_length', // number types 'c.numeric_precision', @@ -392,3 +474,103 @@ const getTableConstraints = (db: DatabaseClient, schemaName: string) => { .where('pg_namespace.nspname', '=', schemaName) .execute(); }; + +const getUserDefinedEnums = async (db: DatabaseClient, schemaName: string) => { + const items = await db + .selectFrom('pg_type') + .innerJoin('pg_namespace', (join) => + join.onRef('pg_namespace.oid', '=', 'pg_type.typnamespace').on('pg_namespace.nspname', '=', schemaName), + ) + .where('typtype', '=', sql.lit('e')) + .select((eb) => [ + 'pg_type.typname as name', + jsonArrayFrom( + eb.selectFrom('pg_enum as e').select(['e.enumlabel as value']).whereRef('e.enumtypid', '=', 'pg_type.oid'), + ).as('values'), + ]) + .execute(); + + return items.map((item) => ({ + name: item.name, + values: item.values.map(({ value }) => value), + })); +}; + +const getRoutines = async (db: DatabaseClient, schemaName: string) => { + return db + .selectFrom('pg_proc as p') + .innerJoin('pg_namespace', 'pg_namespace.oid', 'p.pronamespace') + .leftJoin('pg_depend as d', (join) => join.onRef('d.objid', '=', 'p.oid').on('d.deptype', '=', sql.lit('e'))) + .where('d.objid', 'is', sql.lit(null)) + .where('p.prokind', '=', sql.lit('f')) + .where('pg_namespace.nspname', '=', schemaName) + .select((eb) => [ + 'p.proname as name', + eb.fn('pg_get_function_identity_arguments', ['p.oid']).as('arguments'), + eb.fn('pg_get_functiondef', ['p.oid']).as('expression'), + ]) + .execute(); +}; + +const getExtensions = async (db: DatabaseClient) => { + return ( + db + .selectFrom('pg_catalog.pg_extension') + // .innerJoin('pg_namespace', 'pg_namespace.oid', 'pg_catalog.pg_extension.extnamespace') + // .where('pg_namespace.nspname', '=', schemaName) + .select(['extname as name', 'extversion as version']) + .execute() + ); +}; + +const getTriggers = async (db: Kysely, schemaName: string) => { + return db + .selectFrom('pg_trigger as t') + .innerJoin('pg_proc as p', 't.tgfoid', 'p.oid') + .innerJoin('pg_namespace as n', 'p.pronamespace', 'n.oid') + .innerJoin('pg_class as c', 't.tgrelid', 'c.oid') + .select((eb) => [ + 't.tgname as name', + 't.tgenabled as enabled', + 't.tgtype as type', + 't.tgconstraint as _constraint', + 't.tgdeferrable as is_deferrable', + 't.tginitdeferred as is_initially_deferred', + 't.tgargs as arguments', + 't.tgoldtable as referencing_old_table_as', + 't.tgnewtable as referencing_new_table_as', + eb.fn('pg_get_expr', ['t.tgqual', 't.tgrelid']).as('when_expression'), + 'p.proname as function_name', + 'c.relname as table_name', + ]) + .where('t.tgisinternal', '=', false) // Exclude internal system triggers + .where('n.nspname', '=', schemaName) + .execute(); +}; + +const getParameters = async (db: Kysely) => { + return db + .selectFrom('pg_settings') + .where('source', 'in', [sql.lit('database'), sql.lit('user')]) + .select(['name', 'setting as value', 'source as scope']) + .execute(); +}; + +const getObjectComments = async (db: Kysely) => { + return db + .selectFrom('pg_description as d') + .innerJoin('pg_class as c', 'd.objoid', 'c.oid') + .leftJoin('pg_attribute as a', (join) => + join.onRef('a.attrelid', '=', 'c.oid').onRef('a.attnum', '=', 'd.objsubid'), + ) + .select([ + 'c.relname as object_name', + 'c.relkind as object_type', + 'd.description as value', + 'a.attname as column_name', + ]) + .where('d.description', 'is not', null) + .orderBy('object_type') + .orderBy('object_name') + .execute(); +}; diff --git a/server/src/sql-tools/helpers.ts b/server/src/sql-tools/helpers.ts new file mode 100644 index 0000000000..364b695194 --- /dev/null +++ b/server/src/sql-tools/helpers.ts @@ -0,0 +1,268 @@ +import { createHash } from 'node:crypto'; +import { ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator'; +import { TriggerOptions } from 'src/sql-tools/from-code/decorators/trigger.decorator'; +import { FunctionOptions } from 'src/sql-tools/from-code/register-function'; +import { + Comparer, + DatabaseColumn, + DiffOptions, + SchemaDiff, + TriggerAction, + TriggerScope, + TriggerTiming, +} from 'src/sql-tools/types'; + +export const asMetadataKey = (name: string) => `sql-tools:${name}`; + +export const asSnakeCase = (name: string): string => name.replaceAll(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); +// match TypeORM +export const asKey = (prefix: string, tableName: string, values: string[]) => + (prefix + sha1(`${tableName}_${values.toSorted().join('_')}`)).slice(0, 30); +export const asPrimaryKeyConstraintName = (table: string, columns: string[]) => asKey('PK_', table, columns); +export const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, columns); +export const asTriggerName = (table: string, trigger: TriggerOptions) => + asKey('TR_', table, [...trigger.actions, trigger.scope, trigger.timing, trigger.functionName]); +export const asRelationKeyConstraintName = (table: string, columns: string[]) => asKey('REL_', table, columns); +export const asUniqueConstraintName = (table: string, columns: string[]) => asKey('UQ_', table, columns); +export const asCheckConstraintName = (table: string, expression: string) => asKey('CHK_', table, [expression]); +export const asIndexName = (table: string, columns: string[] | undefined, where: string | undefined) => { + const items: string[] = []; + for (const columnName of columns ?? []) { + items.push(columnName); + } + + if (where) { + items.push(where); + } + + return asKey('IDX_', table, items); +}; + +export const asOptions = (options: string | T): T => { + if (typeof options === 'string') { + return { name: options } as T; + } + + return options; +}; + +export const asFunctionExpression = (options: FunctionOptions) => { + const name = options.name; + const sql: string[] = [ + `CREATE OR REPLACE FUNCTION ${name}(${(options.arguments || []).join(', ')})`, + `RETURNS ${options.returnType}`, + ]; + + const flags = [ + options.parallel ? `PARALLEL ${options.parallel.toUpperCase()}` : undefined, + options.strict ? 'STRICT' : undefined, + options.behavior ? options.behavior.toUpperCase() : undefined, + `LANGUAGE ${options.language ?? 'SQL'}`, + ].filter((x) => x !== undefined); + + if (flags.length > 0) { + sql.push(flags.join(' ')); + } + + if ('return' in options) { + sql.push(` RETURN ${options.return}`); + } + + if ('body' in options) { + sql.push( + // + `AS $$`, + ' ' + options.body.trim(), + `$$;`, + ); + } + + return sql.join('\n ').trim(); +}; + +export const sha1 = (value: string) => createHash('sha1').update(value).digest('hex'); +export const hasMask = (input: number, mask: number) => (input & mask) === mask; + +export const parseTriggerType = (type: number) => { + // eslint-disable-next-line unicorn/prefer-math-trunc + const scope: TriggerScope = hasMask(type, 1 << 0) ? 'row' : 'statement'; + + let timing: TriggerTiming = 'after'; + const timingMasks: Array<{ mask: number; value: TriggerTiming }> = [ + { mask: 1 << 1, value: 'before' }, + { mask: 1 << 6, value: 'instead of' }, + ]; + + for (const { mask, value } of timingMasks) { + if (hasMask(type, mask)) { + timing = value; + break; + } + } + + const actions: TriggerAction[] = []; + const actionMasks: Array<{ mask: number; value: TriggerAction }> = [ + { mask: 1 << 2, value: 'insert' }, + { mask: 1 << 3, value: 'delete' }, + { mask: 1 << 4, value: 'update' }, + { mask: 1 << 5, value: 'truncate' }, + ]; + + for (const { mask, value } of actionMasks) { + if (hasMask(type, mask)) { + actions.push(value); + break; + } + } + + if (actions.length === 0) { + throw new Error(`Unable to parse trigger type ${type}`); + } + + return { actions, timing, scope }; +}; + +export const fromColumnValue = (columnValue?: ColumnValue) => { + if (columnValue === undefined) { + return; + } + + if (typeof columnValue === 'function') { + return columnValue() as string; + } + + const value = columnValue; + + if (value === null) { + return value; + } + + if (typeof value === 'number') { + return String(value); + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + + if (value instanceof Date) { + return `'${value.toISOString()}'`; + } + + return `'${String(value)}'`; +}; + +export const setIsEqual = (source: Set, target: Set) => + source.size === target.size && [...source].every((x) => target.has(x)); + +export const haveEqualColumns = (sourceColumns?: string[], targetColumns?: string[]) => { + return setIsEqual(new Set(sourceColumns ?? []), new Set(targetColumns ?? [])); +}; + +export const compare = ( + sources: T[], + targets: T[], + options: DiffOptions | undefined, + comparer: Comparer, +) => { + options = options || {}; + const sourceMap = Object.fromEntries(sources.map((table) => [table.name, table])); + const targetMap = Object.fromEntries(targets.map((table) => [table.name, table])); + const items: SchemaDiff[] = []; + + const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); + for (const key of keys) { + const source = sourceMap[key]; + const target = targetMap[key]; + + if (isIgnored(source, target, options)) { + continue; + } + + if (isSynchronizeDisabled(source, target)) { + continue; + } + + if (source && !target) { + items.push(...comparer.onMissing(source)); + } else if (!source && target) { + items.push(...comparer.onExtra(target)); + } else { + items.push(...comparer.onCompare(source, target)); + } + } + + return items; +}; + +const isIgnored = ( + source: { synchronize?: boolean } | undefined, + target: { synchronize?: boolean } | undefined, + options: DiffOptions, +) => { + return (options.ignoreExtra && !source) || (options.ignoreMissing && !target); +}; + +const isSynchronizeDisabled = (source?: { synchronize?: boolean }, target?: { synchronize?: boolean }) => { + return source?.synchronize === false || target?.synchronize === false; +}; + +export const isDefaultEqual = (source: DatabaseColumn, target: DatabaseColumn) => { + if (source.default === target.default) { + return true; + } + + if (source.default === undefined || target.default === undefined) { + return false; + } + + if ( + withTypeCast(source.default, getColumnType(source)) === target.default || + source.default === withTypeCast(target.default, getColumnType(target)) + ) { + return true; + } + + return false; +}; + +export const getColumnType = (column: DatabaseColumn) => { + let type = column.enumName || column.type; + if (column.isArray) { + type += `[${column.length ?? ''}]`; + } else if (column.length !== undefined) { + type += `(${column.length})`; + } + + return type; +}; + +const withTypeCast = (value: string, type: string) => { + if (!value.startsWith(`'`)) { + value = `'${value}'`; + } + return `${value}::${type}`; +}; + +export const getColumnModifiers = (column: DatabaseColumn) => { + const modifiers: string[] = []; + + if (!column.nullable) { + modifiers.push('NOT NULL'); + } + + if (column.default) { + modifiers.push(`DEFAULT ${column.default}`); + } + if (column.identity) { + modifiers.push(`GENERATED ALWAYS AS IDENTITY`); + } + + return modifiers.length === 0 ? '' : ' ' + modifiers.join(' '); +}; + +export const asColumnComment = (tableName: string, columnName: string, comment: string): string => { + return `COMMENT ON COLUMN "${tableName}"."${columnName}" IS '${comment}';`; +}; + +export const asColumnList = (columns: string[]) => columns.map((column) => `"${column}"`).join(', '); diff --git a/server/src/sql-tools/public_api.ts b/server/src/sql-tools/public_api.ts index 8b5a36e6a5..d916678d4a 100644 --- a/server/src/sql-tools/public_api.ts +++ b/server/src/sql-tools/public_api.ts @@ -1,6 +1,28 @@ -export * from 'src/sql-tools/decorators'; -export { schemaDiff } from 'src/sql-tools/schema-diff'; -export { schemaDiffToSql } from 'src/sql-tools/schema-diff-to-sql'; -export { schemaFromDatabase } from 'src/sql-tools/schema-from-database'; -export { schemaFromDecorators } from 'src/sql-tools/schema-from-decorators'; +export { schemaDiff } from 'src/sql-tools/diff'; +export { schemaFromCode } from 'src/sql-tools/from-code'; +export * from 'src/sql-tools/from-code/decorators/after-delete.decorator'; +export * from 'src/sql-tools/from-code/decorators/before-update.decorator'; +export * from 'src/sql-tools/from-code/decorators/check.decorator'; +export * from 'src/sql-tools/from-code/decorators/column-index.decorator'; +export * from 'src/sql-tools/from-code/decorators/column.decorator'; +export * from 'src/sql-tools/from-code/decorators/configuration-parameter.decorator'; +export * from 'src/sql-tools/from-code/decorators/create-date-column.decorator'; +export * from 'src/sql-tools/from-code/decorators/database.decorator'; +export * from 'src/sql-tools/from-code/decorators/delete-date-column.decorator'; +export * from 'src/sql-tools/from-code/decorators/extension.decorator'; +export * from 'src/sql-tools/from-code/decorators/extensions.decorator'; +export * from 'src/sql-tools/from-code/decorators/foreign-key-column.decorator'; +export * from 'src/sql-tools/from-code/decorators/generated-column.decorator'; +export * from 'src/sql-tools/from-code/decorators/index.decorator'; +export * from 'src/sql-tools/from-code/decorators/primary-column.decorator'; +export * from 'src/sql-tools/from-code/decorators/primary-generated-column.decorator'; +export * from 'src/sql-tools/from-code/decorators/table.decorator'; +export * from 'src/sql-tools/from-code/decorators/trigger-function.decorator'; +export * from 'src/sql-tools/from-code/decorators/trigger.decorator'; +export * from 'src/sql-tools/from-code/decorators/unique.decorator'; +export * from 'src/sql-tools/from-code/decorators/update-date-column.decorator'; +export * from 'src/sql-tools/from-code/register-enum'; +export * from 'src/sql-tools/from-code/register-function'; +export { schemaFromDatabase } from 'src/sql-tools/from-database'; +export { schemaDiffToSql } from 'src/sql-tools/to-sql'; export * from 'src/sql-tools/types'; diff --git a/server/src/sql-tools/schema-diff-to-sql.spec.ts b/server/src/sql-tools/schema-diff-to-sql.spec.ts deleted file mode 100644 index c44d87e6bd..0000000000 --- a/server/src/sql-tools/schema-diff-to-sql.spec.ts +++ /dev/null @@ -1,473 +0,0 @@ -import { DatabaseConstraintType, schemaDiffToSql } from 'src/sql-tools'; -import { describe, expect, it } from 'vitest'; - -describe('diffToSql', () => { - describe('table.drop', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'table.drop', - tableName: 'table1', - reason: 'unknown', - }, - ]), - ).toEqual([`DROP TABLE "table1";`]); - }); - }); - - describe('table.create', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'table.create', - tableName: 'table1', - columns: [ - { - tableName: 'table1', - name: 'column1', - type: 'character varying', - nullable: true, - isArray: false, - synchronize: true, - }, - ], - reason: 'unknown', - }, - ]), - ).toEqual([`CREATE TABLE "table1" ("column1" character varying);`]); - }); - - it('should handle a non-nullable column', () => { - expect( - schemaDiffToSql([ - { - type: 'table.create', - tableName: 'table1', - columns: [ - { - tableName: 'table1', - name: 'column1', - type: 'character varying', - isArray: false, - nullable: false, - synchronize: true, - }, - ], - reason: 'unknown', - }, - ]), - ).toEqual([`CREATE TABLE "table1" ("column1" character varying NOT NULL);`]); - }); - - it('should handle a default value', () => { - expect( - schemaDiffToSql([ - { - type: 'table.create', - tableName: 'table1', - columns: [ - { - tableName: 'table1', - name: 'column1', - type: 'character varying', - isArray: false, - nullable: true, - default: 'uuid_generate_v4()', - synchronize: true, - }, - ], - reason: 'unknown', - }, - ]), - ).toEqual([`CREATE TABLE "table1" ("column1" character varying DEFAULT uuid_generate_v4());`]); - }); - - it('should handle an array type', () => { - expect( - schemaDiffToSql([ - { - type: 'table.create', - tableName: 'table1', - columns: [ - { - tableName: 'table1', - name: 'column1', - type: 'character varying', - isArray: true, - nullable: true, - synchronize: true, - }, - ], - reason: 'unknown', - }, - ]), - ).toEqual([`CREATE TABLE "table1" ("column1" character varying[]);`]); - }); - }); - - describe('column.add', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'column.add', - column: { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['ALTER TABLE "table1" ADD "column1" character varying NOT NULL;']); - }); - - it('should add a nullable column', () => { - expect( - schemaDiffToSql([ - { - type: 'column.add', - column: { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: true, - isArray: false, - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['ALTER TABLE "table1" ADD "column1" character varying;']); - }); - - it('should add a column with an enum type', () => { - expect( - schemaDiffToSql([ - { - type: 'column.add', - column: { - name: 'column1', - tableName: 'table1', - type: 'character varying', - enumName: 'table1_column1_enum', - nullable: true, - isArray: false, - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['ALTER TABLE "table1" ADD "column1" table1_column1_enum;']); - }); - - it('should add a column that is an array type', () => { - expect( - schemaDiffToSql([ - { - type: 'column.add', - column: { - name: 'column1', - tableName: 'table1', - type: 'boolean', - nullable: true, - isArray: true, - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['ALTER TABLE "table1" ADD "column1" boolean[];']); - }); - }); - - describe('column.alter', () => { - it('should make a column nullable', () => { - expect( - schemaDiffToSql([ - { - type: 'column.alter', - tableName: 'table1', - columnName: 'column1', - changes: { nullable: true }, - reason: 'unknown', - }, - ]), - ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" DROP NOT NULL;`]); - }); - - it('should make a column non-nullable', () => { - expect( - schemaDiffToSql([ - { - type: 'column.alter', - tableName: 'table1', - columnName: 'column1', - changes: { nullable: false }, - reason: 'unknown', - }, - ]), - ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET NOT NULL;`]); - }); - - it('should update the default value', () => { - expect( - schemaDiffToSql([ - { - type: 'column.alter', - tableName: 'table1', - columnName: 'column1', - changes: { default: 'uuid_generate_v4()' }, - reason: 'unknown', - }, - ]), - ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET DEFAULT uuid_generate_v4();`]); - }); - }); - - describe('column.drop', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'column.drop', - tableName: 'table1', - columnName: 'column1', - reason: 'unknown', - }, - ]), - ).toEqual([`ALTER TABLE "table1" DROP COLUMN "column1";`]); - }); - }); - - describe('constraint.add', () => { - describe('primary keys', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'constraint.add', - constraint: { - type: DatabaseConstraintType.PRIMARY_KEY, - name: 'PK_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['ALTER TABLE "table1" ADD CONSTRAINT "PK_test" PRIMARY KEY ("id");']); - }); - }); - - describe('foreign keys', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'constraint.add', - constraint: { - type: DatabaseConstraintType.FOREIGN_KEY, - name: 'FK_test', - tableName: 'table1', - columnNames: ['parentId'], - referenceColumnNames: ['id'], - referenceTableName: 'table2', - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual([ - 'ALTER TABLE "table1" ADD CONSTRAINT "FK_test" FOREIGN KEY ("parentId") REFERENCES "table2" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION;', - ]); - }); - }); - - describe('unique', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'constraint.add', - constraint: { - type: DatabaseConstraintType.UNIQUE, - name: 'UQ_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['ALTER TABLE "table1" ADD CONSTRAINT "UQ_test" UNIQUE ("id");']); - }); - }); - - describe('check', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'constraint.add', - constraint: { - type: DatabaseConstraintType.CHECK, - name: 'CHK_test', - tableName: 'table1', - expression: '"id" IS NOT NULL', - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['ALTER TABLE "table1" ADD CONSTRAINT "CHK_test" CHECK ("id" IS NOT NULL);']); - }); - }); - }); - - describe('constraint.drop', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'constraint.drop', - tableName: 'table1', - constraintName: 'PK_test', - reason: 'unknown', - }, - ]), - ).toEqual([`ALTER TABLE "table1" DROP CONSTRAINT "PK_test";`]); - }); - }); - - describe('index.create', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'index.create', - index: { - name: 'IDX_test', - tableName: 'table1', - columnNames: ['column1'], - unique: false, - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['CREATE INDEX "IDX_test" ON "table1" ("column1")']); - }); - - it('should create an unique index', () => { - expect( - schemaDiffToSql([ - { - type: 'index.create', - index: { - name: 'IDX_test', - tableName: 'table1', - columnNames: ['column1'], - unique: true, - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['CREATE UNIQUE INDEX "IDX_test" ON "table1" ("column1")']); - }); - - it('should create an index with a custom expression', () => { - expect( - schemaDiffToSql([ - { - type: 'index.create', - index: { - name: 'IDX_test', - tableName: 'table1', - unique: false, - expression: '"id" IS NOT NULL', - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['CREATE INDEX "IDX_test" ON "table1" ("id" IS NOT NULL)']); - }); - - it('should create an index with a where clause', () => { - expect( - schemaDiffToSql([ - { - type: 'index.create', - index: { - name: 'IDX_test', - tableName: 'table1', - columnNames: ['id'], - unique: false, - where: '("id" IS NOT NULL)', - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['CREATE INDEX "IDX_test" ON "table1" ("id") WHERE ("id" IS NOT NULL)']); - }); - - it('should create an index with a custom expression', () => { - expect( - schemaDiffToSql([ - { - type: 'index.create', - index: { - name: 'IDX_test', - tableName: 'table1', - unique: false, - using: 'gin', - expression: '"id" IS NOT NULL', - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['CREATE INDEX "IDX_test" ON "table1" USING gin ("id" IS NOT NULL)']); - }); - }); - - describe('index.drop', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'index.drop', - indexName: 'IDX_test', - reason: 'unknown', - }, - ]), - ).toEqual([`DROP INDEX "IDX_test";`]); - }); - }); - - describe('comments', () => { - it('should include the reason in a SQL comment', () => { - expect( - schemaDiffToSql( - [ - { - type: 'index.drop', - indexName: 'IDX_test', - reason: 'unknown', - }, - ], - { comments: true }, - ), - ).toEqual([`DROP INDEX "IDX_test"; -- unknown`]); - }); - }); -}); diff --git a/server/src/sql-tools/schema-diff-to-sql.ts b/server/src/sql-tools/schema-diff-to-sql.ts deleted file mode 100644 index 0a537c600b..0000000000 --- a/server/src/sql-tools/schema-diff-to-sql.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { - DatabaseActionType, - DatabaseColumn, - DatabaseColumnChanges, - DatabaseConstraint, - DatabaseConstraintType, - DatabaseIndex, - SchemaDiff, - SchemaDiffToSqlOptions, -} from 'src/sql-tools/types'; - -const asColumnList = (columns: string[]) => - columns - .toSorted() - .map((column) => `"${column}"`) - .join(', '); -const withNull = (column: DatabaseColumn) => (column.nullable ? '' : ' NOT NULL'); -const withDefault = (column: DatabaseColumn) => (column.default ? ` DEFAULT ${column.default}` : ''); -const withAction = (constraint: { onDelete?: DatabaseActionType; onUpdate?: DatabaseActionType }) => - ` ON UPDATE ${constraint.onUpdate ?? DatabaseActionType.NO_ACTION} ON DELETE ${constraint.onDelete ?? DatabaseActionType.NO_ACTION}`; - -const withComments = (comments: boolean | undefined, item: SchemaDiff): string => { - if (!comments) { - return ''; - } - - return ` -- ${item.reason}`; -}; - -const asArray = (items: T | T[]): T[] => { - if (Array.isArray(items)) { - return items; - } - - return [items]; -}; - -export const getColumnType = (column: DatabaseColumn) => { - let type = column.enumName || column.type; - if (column.isArray) { - type += '[]'; - } - - return type; -}; - -/** - * Convert schema diffs into SQL statements - */ -export const schemaDiffToSql = (items: SchemaDiff[], options: SchemaDiffToSqlOptions = {}): string[] => { - return items.flatMap((item) => asArray(asSql(item)).map((result) => result + withComments(options.comments, item))); -}; - -const asSql = (item: SchemaDiff): string | string[] => { - switch (item.type) { - case 'table.create': { - return asTableCreate(item.tableName, item.columns); - } - - case 'table.drop': { - return asTableDrop(item.tableName); - } - - case 'column.add': { - return asColumnAdd(item.column); - } - - case 'column.alter': { - return asColumnAlter(item.tableName, item.columnName, item.changes); - } - - case 'column.drop': { - return asColumnDrop(item.tableName, item.columnName); - } - - case 'constraint.add': { - return asConstraintAdd(item.constraint); - } - - case 'constraint.drop': { - return asConstraintDrop(item.tableName, item.constraintName); - } - - case 'index.create': { - return asIndexCreate(item.index); - } - - case 'index.drop': { - return asIndexDrop(item.indexName); - } - - default: { - return []; - } - } -}; - -const asTableCreate = (tableName: string, tableColumns: DatabaseColumn[]): string => { - const columns = tableColumns - .map((column) => `"${column.name}" ${getColumnType(column)}` + withNull(column) + withDefault(column)) - .join(', '); - return `CREATE TABLE "${tableName}" (${columns});`; -}; - -const asTableDrop = (tableName: string): string => { - return `DROP TABLE "${tableName}";`; -}; - -const asColumnAdd = (column: DatabaseColumn): string => { - return ( - `ALTER TABLE "${column.tableName}" ADD "${column.name}" ${getColumnType(column)}` + - withNull(column) + - withDefault(column) + - ';' - ); -}; - -const asColumnAlter = (tableName: string, columnName: string, changes: DatabaseColumnChanges): string[] => { - const base = `ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}"`; - const items: string[] = []; - if (changes.nullable !== undefined) { - items.push(changes.nullable ? `${base} DROP NOT NULL;` : `${base} SET NOT NULL;`); - } - - if (changes.default !== undefined) { - items.push(`${base} SET DEFAULT ${changes.default};`); - } - - return items; -}; - -const asColumnDrop = (tableName: string, columnName: string): string => { - return `ALTER TABLE "${tableName}" DROP COLUMN "${columnName}";`; -}; - -const asConstraintAdd = (constraint: DatabaseConstraint): string | string[] => { - const base = `ALTER TABLE "${constraint.tableName}" ADD CONSTRAINT "${constraint.name}"`; - switch (constraint.type) { - case DatabaseConstraintType.PRIMARY_KEY: { - const columnNames = asColumnList(constraint.columnNames); - return `${base} PRIMARY KEY (${columnNames});`; - } - - case DatabaseConstraintType.FOREIGN_KEY: { - const columnNames = asColumnList(constraint.columnNames); - const referenceColumnNames = asColumnList(constraint.referenceColumnNames); - return ( - `${base} FOREIGN KEY (${columnNames}) REFERENCES "${constraint.referenceTableName}" (${referenceColumnNames})` + - withAction(constraint) + - ';' - ); - } - - case DatabaseConstraintType.UNIQUE: { - const columnNames = asColumnList(constraint.columnNames); - return `${base} UNIQUE (${columnNames});`; - } - - case DatabaseConstraintType.CHECK: { - return `${base} CHECK (${constraint.expression});`; - } - - default: { - return []; - } - } -}; - -const asConstraintDrop = (tableName: string, constraintName: string): string => { - return `ALTER TABLE "${tableName}" DROP CONSTRAINT "${constraintName}";`; -}; - -const asIndexCreate = (index: DatabaseIndex): string => { - let sql = `CREATE`; - - if (index.unique) { - sql += ' UNIQUE'; - } - - sql += ` INDEX "${index.name}" ON "${index.tableName}"`; - - if (index.columnNames) { - const columnNames = asColumnList(index.columnNames); - sql += ` (${columnNames})`; - } - - if (index.using && index.using !== 'btree') { - sql += ` USING ${index.using}`; - } - - if (index.expression) { - sql += ` (${index.expression})`; - } - - if (index.where) { - sql += ` WHERE ${index.where}`; - } - - return sql; -}; - -const asIndexDrop = (indexName: string): string => { - return `DROP INDEX "${indexName}";`; -}; diff --git a/server/src/sql-tools/schema-diff.ts b/server/src/sql-tools/schema-diff.ts deleted file mode 100644 index ca7f35a45f..0000000000 --- a/server/src/sql-tools/schema-diff.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { getColumnType, schemaDiffToSql } from 'src/sql-tools/schema-diff-to-sql'; -import { - DatabaseCheckConstraint, - DatabaseColumn, - DatabaseConstraint, - DatabaseConstraintType, - DatabaseForeignKeyConstraint, - DatabaseIndex, - DatabasePrimaryKeyConstraint, - DatabaseSchema, - DatabaseTable, - DatabaseUniqueConstraint, - SchemaDiff, - SchemaDiffToSqlOptions, -} from 'src/sql-tools/types'; - -enum Reason { - MissingInSource = 'missing in source', - MissingInTarget = 'missing in target', -} - -const setIsEqual = (source: Set, target: Set) => - source.size === target.size && [...source].every((x) => target.has(x)); - -const haveEqualColumns = (sourceColumns?: string[], targetColumns?: string[]) => { - return setIsEqual(new Set(sourceColumns ?? []), new Set(targetColumns ?? [])); -}; - -const isSynchronizeDisabled = (source?: { synchronize?: boolean }, target?: { synchronize?: boolean }) => { - return source?.synchronize === false || target?.synchronize === false; -}; - -const withTypeCast = (value: string, type: string) => { - if (!value.startsWith(`'`)) { - value = `'${value}'`; - } - return `${value}::${type}`; -}; - -const isDefaultEqual = (source: DatabaseColumn, target: DatabaseColumn) => { - if (source.default === target.default) { - return true; - } - - if (source.default === undefined || target.default === undefined) { - return false; - } - - if ( - withTypeCast(source.default, getColumnType(source)) === target.default || - source.default === withTypeCast(target.default, getColumnType(target)) - ) { - return true; - } - - return false; -}; - -/** - * Compute the difference between two database schemas - */ -export const schemaDiff = ( - source: DatabaseSchema, - target: DatabaseSchema, - options: { ignoreExtraTables?: boolean } = {}, -) => { - const items = diffTables(source.tables, target.tables, { - ignoreExtraTables: options.ignoreExtraTables ?? true, - }); - - return { - items, - asSql: (options?: SchemaDiffToSqlOptions) => schemaDiffToSql(items, options), - }; -}; - -export const diffTables = ( - sources: DatabaseTable[], - targets: DatabaseTable[], - options: { ignoreExtraTables: boolean }, -) => { - const items: SchemaDiff[] = []; - const sourceMap = Object.fromEntries(sources.map((table) => [table.name, table])); - const targetMap = Object.fromEntries(targets.map((table) => [table.name, table])); - const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); - - for (const key of keys) { - if (options.ignoreExtraTables && !sourceMap[key]) { - continue; - } - items.push(...diffTable(sourceMap[key], targetMap[key])); - } - - return items; -}; - -const diffTable = (source?: DatabaseTable, target?: DatabaseTable): SchemaDiff[] => { - if (isSynchronizeDisabled(source, target)) { - return []; - } - - if (source && !target) { - return [ - { - type: 'table.create', - tableName: source.name, - columns: Object.values(source.columns), - reason: Reason.MissingInTarget, - }, - ...diffIndexes(source.indexes, []), - // TODO merge constraints into table create record when possible - ...diffConstraints(source.constraints, []), - ]; - } - - if (!source && target) { - return [ - { - type: 'table.drop', - tableName: target.name, - reason: Reason.MissingInSource, - }, - ]; - } - - if (!source || !target) { - return []; - } - - return [ - ...diffColumns(source.columns, target.columns), - ...diffConstraints(source.constraints, target.constraints), - ...diffIndexes(source.indexes, target.indexes), - ]; -}; - -const diffColumns = (sources: DatabaseColumn[], targets: DatabaseColumn[]): SchemaDiff[] => { - const items: SchemaDiff[] = []; - const sourceMap = Object.fromEntries(sources.map((column) => [column.name, column])); - const targetMap = Object.fromEntries(targets.map((column) => [column.name, column])); - const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); - - for (const key of keys) { - items.push(...diffColumn(sourceMap[key], targetMap[key])); - } - - return items; -}; - -const diffColumn = (source?: DatabaseColumn, target?: DatabaseColumn): SchemaDiff[] => { - if (isSynchronizeDisabled(source, target)) { - return []; - } - - if (source && !target) { - return [ - { - type: 'column.add', - column: source, - reason: Reason.MissingInTarget, - }, - ]; - } - - if (!source && target) { - return [ - { - type: 'column.drop', - tableName: target.tableName, - columnName: target.name, - reason: Reason.MissingInSource, - }, - ]; - } - - if (!source || !target) { - return []; - } - - const sourceType = getColumnType(source); - const targetType = getColumnType(target); - - const isTypeChanged = sourceType !== targetType; - - if (isTypeChanged) { - // TODO: convert between types via UPDATE when possible - return dropAndRecreateColumn(source, target, `column type is different (${sourceType} vs ${targetType})`); - } - - const items: SchemaDiff[] = []; - if (source.nullable !== target.nullable) { - items.push({ - type: 'column.alter', - tableName: source.tableName, - columnName: source.name, - changes: { - nullable: source.nullable, - }, - reason: `nullable is different (${source.nullable} vs ${target.nullable})`, - }); - } - - if (!isDefaultEqual(source, target)) { - items.push({ - type: 'column.alter', - tableName: source.tableName, - columnName: source.name, - changes: { - default: String(source.default), - }, - reason: `default is different (${source.default} vs ${target.default})`, - }); - } - - return items; -}; - -const diffConstraints = (sources: DatabaseConstraint[], targets: DatabaseConstraint[]): SchemaDiff[] => { - const items: SchemaDiff[] = []; - - for (const type of Object.values(DatabaseConstraintType)) { - const sourceMap = Object.fromEntries(sources.filter((item) => item.type === type).map((item) => [item.name, item])); - const targetMap = Object.fromEntries(targets.filter((item) => item.type === type).map((item) => [item.name, item])); - const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); - - for (const key of keys) { - items.push(...diffConstraint(sourceMap[key], targetMap[key])); - } - } - - return items; -}; - -const diffConstraint = (source?: T, target?: T): SchemaDiff[] => { - if (isSynchronizeDisabled(source, target)) { - return []; - } - - if (source && !target) { - return [ - { - type: 'constraint.add', - constraint: source, - reason: Reason.MissingInTarget, - }, - ]; - } - - if (!source && target) { - return [ - { - type: 'constraint.drop', - tableName: target.tableName, - constraintName: target.name, - reason: Reason.MissingInSource, - }, - ]; - } - - if (!source || !target) { - return []; - } - - switch (source.type) { - case DatabaseConstraintType.PRIMARY_KEY: { - return diffPrimaryKeyConstraint(source, target as DatabasePrimaryKeyConstraint); - } - - case DatabaseConstraintType.FOREIGN_KEY: { - return diffForeignKeyConstraint(source, target as DatabaseForeignKeyConstraint); - } - - case DatabaseConstraintType.UNIQUE: { - return diffUniqueConstraint(source, target as DatabaseUniqueConstraint); - } - - case DatabaseConstraintType.CHECK: { - return diffCheckConstraint(source, target as DatabaseCheckConstraint); - } - - default: { - return dropAndRecreateConstraint(source, target, `Unknown constraint type: ${(source as any).type}`); - } - } -}; - -const diffPrimaryKeyConstraint = ( - source: DatabasePrimaryKeyConstraint, - target: DatabasePrimaryKeyConstraint, -): SchemaDiff[] => { - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - return dropAndRecreateConstraint( - source, - target, - `Primary key columns are different: (${source.columnNames} vs ${target.columnNames})`, - ); - } - - return []; -}; - -const diffForeignKeyConstraint = ( - source: DatabaseForeignKeyConstraint, - target: DatabaseForeignKeyConstraint, -): SchemaDiff[] => { - let reason = ''; - - const sourceDeleteAction = source.onDelete ?? 'NO ACTION'; - const targetDeleteAction = target.onDelete ?? 'NO ACTION'; - - const sourceUpdateAction = source.onUpdate ?? 'NO ACTION'; - const targetUpdateAction = target.onUpdate ?? 'NO ACTION'; - - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; - } else if (!haveEqualColumns(source.referenceColumnNames, target.referenceColumnNames)) { - reason = `reference columns are different (${source.referenceColumnNames} vs ${target.referenceColumnNames})`; - } else if (source.referenceTableName !== target.referenceTableName) { - reason = `reference table is different (${source.referenceTableName} vs ${target.referenceTableName})`; - } else if (sourceDeleteAction !== targetDeleteAction) { - reason = `ON DELETE action is different (${sourceDeleteAction} vs ${targetDeleteAction})`; - } else if (sourceUpdateAction !== targetUpdateAction) { - reason = `ON UPDATE action is different (${sourceUpdateAction} vs ${targetUpdateAction})`; - } - - if (reason) { - return dropAndRecreateConstraint(source, target, reason); - } - - return []; -}; - -const diffUniqueConstraint = (source: DatabaseUniqueConstraint, target: DatabaseUniqueConstraint): SchemaDiff[] => { - let reason = ''; - - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; - } - - if (reason) { - return dropAndRecreateConstraint(source, target, reason); - } - - return []; -}; - -const diffCheckConstraint = (source: DatabaseCheckConstraint, target: DatabaseCheckConstraint): SchemaDiff[] => { - if (source.expression !== target.expression) { - // comparing expressions is hard because postgres reconstructs it with different formatting - // for now if the constraint exists with the same name, we will just skip it - } - - return []; -}; - -const diffIndexes = (sources: DatabaseIndex[], targets: DatabaseIndex[]) => { - const items: SchemaDiff[] = []; - const sourceMap = Object.fromEntries(sources.map((index) => [index.name, index])); - const targetMap = Object.fromEntries(targets.map((index) => [index.name, index])); - const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); - - for (const key of keys) { - items.push(...diffIndex(sourceMap[key], targetMap[key])); - } - - return items; -}; - -const diffIndex = (source?: DatabaseIndex, target?: DatabaseIndex): SchemaDiff[] => { - if (isSynchronizeDisabled(source, target)) { - return []; - } - - if (source && !target) { - return [{ type: 'index.create', index: source, reason: Reason.MissingInTarget }]; - } - - if (!source && target) { - return [ - { - type: 'index.drop', - indexName: target.name, - reason: Reason.MissingInSource, - }, - ]; - } - - if (!target || !source) { - return []; - } - - const sourceUsing = source.using ?? 'btree'; - const targetUsing = target.using ?? 'btree'; - - let reason = ''; - - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; - } else if (source.unique !== target.unique) { - reason = `uniqueness is different (${source.unique} vs ${target.unique})`; - } else if (sourceUsing !== targetUsing) { - reason = `using method is different (${source.using} vs ${target.using})`; - } else if (source.where !== target.where) { - reason = `where clause is different (${source.where} vs ${target.where})`; - } else if (source.expression !== target.expression) { - reason = `expression is different (${source.expression} vs ${target.expression})`; - } - - if (reason) { - return dropAndRecreateIndex(source, target, reason); - } - - return []; -}; - -const dropAndRecreateColumn = (source: DatabaseColumn, target: DatabaseColumn, reason: string): SchemaDiff[] => { - return [ - { - type: 'column.drop', - tableName: target.tableName, - columnName: target.name, - reason, - }, - { type: 'column.add', column: source, reason }, - ]; -}; - -const dropAndRecreateConstraint = ( - source: DatabaseConstraint, - target: DatabaseConstraint, - reason: string, -): SchemaDiff[] => { - return [ - { - type: 'constraint.drop', - tableName: target.tableName, - constraintName: target.name, - reason, - }, - { type: 'constraint.add', constraint: source, reason }, - ]; -}; - -const dropAndRecreateIndex = (source: DatabaseIndex, target: DatabaseIndex, reason: string): SchemaDiff[] => { - return [ - { type: 'index.drop', indexName: target.name, reason }, - { type: 'index.create', index: source, reason }, - ]; -}; diff --git a/server/src/sql-tools/schema-from-decorators.ts b/server/src/sql-tools/schema-from-decorators.ts deleted file mode 100644 index b11817678e..0000000000 --- a/server/src/sql-tools/schema-from-decorators.ts +++ /dev/null @@ -1,443 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ -import { createHash } from 'node:crypto'; -import 'reflect-metadata'; -import { - CheckOptions, - ColumnDefaultValue, - ColumnIndexOptions, - ColumnOptions, - DatabaseActionType, - DatabaseColumn, - DatabaseConstraintType, - DatabaseSchema, - DatabaseTable, - ForeignKeyColumnOptions, - IndexOptions, - TableOptions, - UniqueOptions, -} from 'src/sql-tools/types'; - -enum SchemaKey { - TableName = 'immich-schema:table-name', - ColumnName = 'immich-schema:column-name', - IndexName = 'immich-schema:index-name', -} - -type SchemaTable = DatabaseTable & { options: TableOptions }; -type SchemaTables = SchemaTable[]; -type ClassBased = { object: Function } & T; -type PropertyBased = { object: object; propertyName: string | symbol } & T; -type RegisterItem = - | { type: 'table'; item: ClassBased<{ options: TableOptions }> } - | { type: 'index'; item: ClassBased<{ options: IndexOptions }> } - | { type: 'uniqueConstraint'; item: ClassBased<{ options: UniqueOptions }> } - | { type: 'checkConstraint'; item: ClassBased<{ options: CheckOptions }> } - | { type: 'column'; item: PropertyBased<{ options: ColumnOptions }> } - | { type: 'columnIndex'; item: PropertyBased<{ options: ColumnIndexOptions }> } - | { type: 'foreignKeyColumn'; item: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }> }; - -const items: RegisterItem[] = []; -export const register = (item: RegisterItem) => void items.push(item); - -const asSnakeCase = (name: string): string => name.replaceAll(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); -const asKey = (prefix: string, tableName: string, values: string[]) => - (prefix + sha1(`${tableName}_${values.toSorted().join('_')}`)).slice(0, 30); -const asPrimaryKeyConstraintName = (table: string, columns: string[]) => asKey('PK_', table, columns); -const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, columns); -const asRelationKeyConstraintName = (table: string, columns: string[]) => asKey('REL_', table, columns); -const asUniqueConstraintName = (table: string, columns: string[]) => asKey('UQ_', table, columns); -const asCheckConstraintName = (table: string, expression: string) => asKey('CHK_', table, [expression]); -const asIndexName = (table: string, columns: string[] | undefined, where: string | undefined) => { - const items: string[] = []; - for (const columnName of columns ?? []) { - items.push(columnName); - } - - if (where) { - items.push(where); - } - - return asKey('IDX_', table, items); -}; - -const makeColumn = ({ - name, - tableName, - options, -}: { - name: string; - tableName: string; - options: ColumnOptions; -}): DatabaseColumn => { - const columnName = options.name ?? name; - const enumName = options.enumName ?? `${tableName}_${columnName}_enum`.toLowerCase(); - let defaultValue = asDefaultValue(options); - let nullable = options.nullable ?? false; - - if (defaultValue === null) { - nullable = true; - defaultValue = undefined; - } - - const isEnum = !!options.enum; - - return { - name: columnName, - tableName, - primary: options.primary ?? false, - default: defaultValue, - nullable, - enumName: isEnum ? enumName : undefined, - enumValues: isEnum ? Object.values(options.enum as object) : undefined, - isArray: options.array ?? false, - type: isEnum ? 'enum' : options.type || 'character varying', - synchronize: options.synchronize ?? true, - }; -}; - -const asDefaultValue = (options: { nullable?: boolean; default?: ColumnDefaultValue }) => { - if (typeof options.default === 'function') { - return options.default() as string; - } - - if (options.default === undefined) { - return; - } - - const value = options.default; - - if (value === null) { - return value; - } - - if (typeof value === 'number') { - return String(value); - } - - if (typeof value === 'boolean') { - return value ? 'true' : 'false'; - } - - if (value instanceof Date) { - return `'${value.toISOString()}'`; - } - - return `'${String(value)}'`; -}; - -const missingTableError = (context: string, object: object, propertyName?: string | symbol) => { - const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : ''); - return `[${context}] Unable to find table (${label})`; -}; - -// match TypeORM -const sha1 = (value: string) => createHash('sha1').update(value).digest('hex'); - -const findByName = (items: T[], name?: string) => - name ? items.find((item) => item.name === name) : undefined; -const resolveTable = (tables: SchemaTables, object: object) => - findByName(tables, Reflect.getMetadata(SchemaKey.TableName, object)); - -let initialized = false; -let schema: DatabaseSchema; - -export const reset = () => { - initialized = false; - items.length = 0; -}; - -export const schemaFromDecorators = () => { - if (!initialized) { - const schemaTables: SchemaTables = []; - - const warnings: string[] = []; - const warn = (message: string) => void warnings.push(message); - - for (const { item } of items.filter((item) => item.type === 'table')) { - processTable(schemaTables, item); - } - - for (const { item } of items.filter((item) => item.type === 'column')) { - processColumn(schemaTables, item, { warn }); - } - - for (const { item } of items.filter((item) => item.type === 'foreignKeyColumn')) { - processForeignKeyColumn(schemaTables, item, { warn }); - } - - for (const { item } of items.filter((item) => item.type === 'uniqueConstraint')) { - processUniqueConstraint(schemaTables, item, { warn }); - } - - for (const { item } of items.filter((item) => item.type === 'checkConstraint')) { - processCheckConstraint(schemaTables, item, { warn }); - } - - for (const table of schemaTables) { - processPrimaryKeyConstraint(table); - } - - for (const { item } of items.filter((item) => item.type === 'index')) { - processIndex(schemaTables, item, { warn }); - } - - for (const { item } of items.filter((item) => item.type === 'columnIndex')) { - processColumnIndex(schemaTables, item, { warn }); - } - - for (const { item } of items.filter((item) => item.type === 'foreignKeyColumn')) { - processForeignKeyConstraint(schemaTables, item, { warn }); - } - - schema = { - name: 'public', - tables: schemaTables.map(({ options: _, ...table }) => table), - warnings, - }; - - initialized = true; - } - - return schema; -}; - -const processTable = (tables: SchemaTables, { object, options }: ClassBased<{ options: TableOptions }>) => { - const tableName = options.name || asSnakeCase(object.name); - Reflect.defineMetadata(SchemaKey.TableName, tableName, object); - tables.push({ - name: tableName, - columns: [], - constraints: [], - indexes: [], - options, - synchronize: options.synchronize ?? true, - }); -}; - -type OnWarn = (message: string) => void; - -const processColumn = ( - tables: SchemaTables, - { object, propertyName, options }: PropertyBased<{ options: ColumnOptions }>, - { warn }: { warn: OnWarn }, -) => { - const table = resolveTable(tables, object.constructor); - if (!table) { - warn(missingTableError('@Column', object, propertyName)); - return; - } - - // TODO make sure column name is unique - - const column = makeColumn({ name: String(propertyName), tableName: table.name, options }); - - Reflect.defineMetadata(SchemaKey.ColumnName, column.name, object, propertyName); - - table.columns.push(column); - - if (!options.primary && options.unique) { - table.constraints.push({ - type: DatabaseConstraintType.UNIQUE, - name: options.uniqueConstraintName || asUniqueConstraintName(table.name, [column.name]), - tableName: table.name, - columnNames: [column.name], - synchronize: options.synchronize ?? true, - }); - } -}; - -const processUniqueConstraint = ( - tables: SchemaTables, - { object, options }: ClassBased<{ options: UniqueOptions }>, - { warn }: { warn: OnWarn }, -) => { - const table = resolveTable(tables, object); - if (!table) { - warn(missingTableError('@Unique', object)); - return; - } - - const tableName = table.name; - const columnNames = options.columns; - - table.constraints.push({ - type: DatabaseConstraintType.UNIQUE, - name: options.name || asUniqueConstraintName(tableName, columnNames), - tableName, - columnNames, - synchronize: options.synchronize ?? true, - }); -}; - -const processCheckConstraint = ( - tables: SchemaTables, - { object, options }: ClassBased<{ options: CheckOptions }>, - { warn }: { warn: OnWarn }, -) => { - const table = resolveTable(tables, object); - if (!table) { - warn(missingTableError('@Check', object)); - return; - } - - const tableName = table.name; - - table.constraints.push({ - type: DatabaseConstraintType.CHECK, - name: options.name || asCheckConstraintName(tableName, options.expression), - tableName, - expression: options.expression, - synchronize: options.synchronize ?? true, - }); -}; - -const processPrimaryKeyConstraint = (table: SchemaTable) => { - const columnNames: string[] = []; - - for (const column of table.columns) { - if (column.primary) { - columnNames.push(column.name); - } - } - - if (columnNames.length > 0) { - table.constraints.push({ - type: DatabaseConstraintType.PRIMARY_KEY, - name: table.options.primaryConstraintName || asPrimaryKeyConstraintName(table.name, columnNames), - tableName: table.name, - columnNames, - synchronize: table.options.synchronize ?? true, - }); - } -}; - -const processIndex = ( - tables: SchemaTables, - { object, options }: ClassBased<{ options: IndexOptions }>, - { warn }: { warn: OnWarn }, -) => { - const table = resolveTable(tables, object); - if (!table) { - warn(missingTableError('@Index', object)); - return; - } - - table.indexes.push({ - name: options.name || asIndexName(table.name, options.columns, options.where), - tableName: table.name, - unique: options.unique ?? false, - expression: options.expression, - using: options.using, - where: options.where, - columnNames: options.columns, - synchronize: options.synchronize ?? true, - }); -}; - -const processColumnIndex = ( - tables: SchemaTables, - { object, propertyName, options }: PropertyBased<{ options: ColumnIndexOptions }>, - { warn }: { warn: OnWarn }, -) => { - const table = resolveTable(tables, object.constructor); - if (!table) { - warn(missingTableError('@ColumnIndex', object, propertyName)); - return; - } - - const column = findByName(table.columns, Reflect.getMetadata(SchemaKey.ColumnName, object, propertyName)); - if (!column) { - return; - } - - table.indexes.push({ - name: options.name || asIndexName(table.name, [column.name], options.where), - tableName: table.name, - unique: options.unique ?? false, - expression: options.expression, - using: options.using, - where: options.where, - columnNames: [column.name], - synchronize: options.synchronize ?? true, - }); -}; - -const processForeignKeyColumn = ( - tables: SchemaTables, - { object, propertyName, options }: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }>, - { warn }: { warn: OnWarn }, -) => { - const table = resolveTable(tables, object.constructor); - if (!table) { - warn(missingTableError('@ForeignKeyColumn', object)); - return; - } - - const columnName = String(propertyName); - const existingColumn = table.columns.find((column) => column.name === columnName); - if (existingColumn) { - // TODO log warnings if column options and `@Column` is also used - return; - } - - const column = makeColumn({ name: columnName, tableName: table.name, options }); - - Reflect.defineMetadata(SchemaKey.ColumnName, columnName, object, propertyName); - - table.columns.push(column); -}; - -const processForeignKeyConstraint = ( - tables: SchemaTables, - { object, propertyName, options, target }: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }>, - { warn }: { warn: OnWarn }, -) => { - const childTable = resolveTable(tables, object.constructor); - if (!childTable) { - warn(missingTableError('@ForeignKeyColumn', object)); - return; - } - - const parentTable = resolveTable(tables, target()); - if (!parentTable) { - warn(missingTableError('@ForeignKeyColumn', object, propertyName)); - return; - } - - const columnName = String(propertyName); - const column = childTable.columns.find((column) => column.name === columnName); - if (!column) { - warn('@ForeignKeyColumn: Column not found, creating a new one'); - return; - } - - const columnNames = [column.name]; - const referenceColumns = parentTable.columns.filter((column) => column.primary); - - // infer FK column type from reference table - if (referenceColumns.length === 1) { - column.type = referenceColumns[0].type; - } - - childTable.constraints.push({ - name: options.constraintName || asForeignKeyConstraintName(childTable.name, columnNames), - tableName: childTable.name, - columnNames, - type: DatabaseConstraintType.FOREIGN_KEY, - referenceTableName: parentTable.name, - referenceColumnNames: referenceColumns.map((column) => column.name), - onUpdate: options.onUpdate as DatabaseActionType, - onDelete: options.onDelete as DatabaseActionType, - synchronize: options.synchronize ?? true, - }); - - if (options.unique) { - childTable.constraints.push({ - name: options.uniqueConstraintName || asRelationKeyConstraintName(childTable.name, columnNames), - tableName: childTable.name, - columnNames, - type: DatabaseConstraintType.UNIQUE, - synchronize: options.synchronize ?? true, - }); - } -}; diff --git a/server/src/sql-tools/to-sql/index.spec.ts b/server/src/sql-tools/to-sql/index.spec.ts new file mode 100644 index 0000000000..509f44ebe5 --- /dev/null +++ b/server/src/sql-tools/to-sql/index.spec.ts @@ -0,0 +1,21 @@ +import { schemaDiffToSql } from 'src/sql-tools'; +import { describe, expect, it } from 'vitest'; + +describe(schemaDiffToSql.name, () => { + describe('comments', () => { + it('should include the reason in a SQL comment', () => { + expect( + schemaDiffToSql( + [ + { + type: 'index.drop', + indexName: 'IDX_test', + reason: 'unknown', + }, + ], + { comments: true }, + ), + ).toEqual([`DROP INDEX "IDX_test"; -- unknown`]); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/index.ts b/server/src/sql-tools/to-sql/index.ts new file mode 100644 index 0000000000..973c7ef287 --- /dev/null +++ b/server/src/sql-tools/to-sql/index.ts @@ -0,0 +1,59 @@ +import { transformColumns } from 'src/sql-tools/to-sql/transformers/column.transformer'; +import { transformConstraints } from 'src/sql-tools/to-sql/transformers/constraint.transformer'; +import { transformEnums } from 'src/sql-tools/to-sql/transformers/enum.transformer'; +import { transformExtensions } from 'src/sql-tools/to-sql/transformers/extension.transformer'; +import { transformFunctions } from 'src/sql-tools/to-sql/transformers/function.transformer'; +import { transformIndexes } from 'src/sql-tools/to-sql/transformers/index.transformer'; +import { transformParameters } from 'src/sql-tools/to-sql/transformers/parameter.transformer'; +import { transformTables } from 'src/sql-tools/to-sql/transformers/table.transformer'; +import { transformTriggers } from 'src/sql-tools/to-sql/transformers/trigger.transformer'; +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { SchemaDiff, SchemaDiffToSqlOptions } from 'src/sql-tools/types'; + +/** + * Convert schema diffs into SQL statements + */ +export const schemaDiffToSql = (items: SchemaDiff[], options: SchemaDiffToSqlOptions = {}): string[] => { + return items.flatMap((item) => asSql(item).map((result) => result + withComments(options.comments, item))); +}; + +const transformers: SqlTransformer[] = [ + transformColumns, + transformConstraints, + transformEnums, + transformExtensions, + transformFunctions, + transformIndexes, + transformParameters, + transformTables, + transformTriggers, +]; + +const asSql = (item: SchemaDiff): string[] => { + for (const transform of transformers) { + const result = transform(item); + if (!result) { + continue; + } + + return asArray(result); + } + + throw new Error(`Unhandled schema diff type: ${item.type}`); +}; + +const withComments = (comments: boolean | undefined, item: SchemaDiff): string => { + if (!comments) { + return ''; + } + + return ` -- ${item.reason}`; +}; + +const asArray = (items: T | T[]): T[] => { + if (Array.isArray(items)) { + return items; + } + + return [items]; +}; diff --git a/server/src/sql-tools/to-sql/transformers/column.transformer.spec.ts b/server/src/sql-tools/to-sql/transformers/column.transformer.spec.ts new file mode 100644 index 0000000000..8bf5ac3bc4 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/column.transformer.spec.ts @@ -0,0 +1,126 @@ +import { transformColumns } from 'src/sql-tools/to-sql/transformers/column.transformer'; +import { describe, expect, it } from 'vitest'; + +describe(transformColumns.name, () => { + describe('column.add', () => { + it('should work', () => { + expect( + transformColumns({ + type: 'column.add', + column: { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: false, + isArray: false, + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('ALTER TABLE "table1" ADD "column1" character varying NOT NULL;'); + }); + + it('should add a nullable column', () => { + expect( + transformColumns({ + type: 'column.add', + column: { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: true, + isArray: false, + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('ALTER TABLE "table1" ADD "column1" character varying;'); + }); + + it('should add a column with an enum type', () => { + expect( + transformColumns({ + type: 'column.add', + column: { + name: 'column1', + tableName: 'table1', + type: 'character varying', + enumName: 'table1_column1_enum', + nullable: true, + isArray: false, + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('ALTER TABLE "table1" ADD "column1" table1_column1_enum;'); + }); + + it('should add a column that is an array type', () => { + expect( + transformColumns({ + type: 'column.add', + column: { + name: 'column1', + tableName: 'table1', + type: 'boolean', + nullable: true, + isArray: true, + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('ALTER TABLE "table1" ADD "column1" boolean[];'); + }); + }); + + describe('column.alter', () => { + it('should make a column nullable', () => { + expect( + transformColumns({ + type: 'column.alter', + tableName: 'table1', + columnName: 'column1', + changes: { nullable: true }, + reason: 'unknown', + }), + ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" DROP NOT NULL;`]); + }); + + it('should make a column non-nullable', () => { + expect( + transformColumns({ + type: 'column.alter', + tableName: 'table1', + columnName: 'column1', + changes: { nullable: false }, + reason: 'unknown', + }), + ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET NOT NULL;`]); + }); + + it('should update the default value', () => { + expect( + transformColumns({ + type: 'column.alter', + tableName: 'table1', + columnName: 'column1', + changes: { default: 'uuid_generate_v4()' }, + reason: 'unknown', + }), + ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET DEFAULT uuid_generate_v4();`]); + }); + }); + + describe('column.drop', () => { + it('should work', () => { + expect( + transformColumns({ + type: 'column.drop', + tableName: 'table1', + columnName: 'column1', + reason: 'unknown', + }), + ).toEqual(`ALTER TABLE "table1" DROP COLUMN "column1";`); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/transformers/column.transformer.ts b/server/src/sql-tools/to-sql/transformers/column.transformer.ts new file mode 100644 index 0000000000..117b460938 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/column.transformer.ts @@ -0,0 +1,55 @@ +import { asColumnComment, getColumnModifiers, getColumnType } from 'src/sql-tools/helpers'; +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { ColumnChanges, DatabaseColumn, SchemaDiff } from 'src/sql-tools/types'; + +export const transformColumns: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'column.add': { + return asColumnAdd(item.column); + } + + case 'column.alter': { + return asColumnAlter(item.tableName, item.columnName, item.changes); + } + + case 'column.drop': { + return asColumnDrop(item.tableName, item.columnName); + } + + default: { + return false; + } + } +}; + +const asColumnAdd = (column: DatabaseColumn): string => { + return ( + `ALTER TABLE "${column.tableName}" ADD "${column.name}" ${getColumnType(column)}` + getColumnModifiers(column) + ';' + ); +}; + +const asColumnDrop = (tableName: string, columnName: string): string => { + return `ALTER TABLE "${tableName}" DROP COLUMN "${columnName}";`; +}; + +export const asColumnAlter = (tableName: string, columnName: string, changes: ColumnChanges): string[] => { + const base = `ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}"`; + const items: string[] = []; + if (changes.nullable !== undefined) { + items.push(changes.nullable ? `${base} DROP NOT NULL;` : `${base} SET NOT NULL;`); + } + + if (changes.default !== undefined) { + items.push(`${base} SET DEFAULT ${changes.default};`); + } + + if (changes.storage !== undefined) { + items.push(`${base} SET STORAGE ${changes.storage.toUpperCase()};`); + } + + if (changes.comment !== undefined) { + items.push(asColumnComment(tableName, columnName, changes.comment)); + } + + return items; +}; diff --git a/server/src/sql-tools/to-sql/transformers/constraint.transformer.spec.ts b/server/src/sql-tools/to-sql/transformers/constraint.transformer.spec.ts new file mode 100644 index 0000000000..59d21e7b50 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/constraint.transformer.spec.ts @@ -0,0 +1,96 @@ +import { transformConstraints } from 'src/sql-tools/to-sql/transformers/constraint.transformer'; +import { DatabaseConstraintType } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +describe(transformConstraints.name, () => { + describe('constraint.add', () => { + describe('primary keys', () => { + it('should work', () => { + expect( + transformConstraints({ + type: 'constraint.add', + constraint: { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_test', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('ALTER TABLE "table1" ADD CONSTRAINT "PK_test" PRIMARY KEY ("id");'); + }); + }); + + describe('foreign keys', () => { + it('should work', () => { + expect( + transformConstraints({ + type: 'constraint.add', + constraint: { + type: DatabaseConstraintType.FOREIGN_KEY, + name: 'FK_test', + tableName: 'table1', + columnNames: ['parentId'], + referenceColumnNames: ['id'], + referenceTableName: 'table2', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual( + 'ALTER TABLE "table1" ADD CONSTRAINT "FK_test" FOREIGN KEY ("parentId") REFERENCES "table2" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION;', + ); + }); + }); + + describe('unique', () => { + it('should work', () => { + expect( + transformConstraints({ + type: 'constraint.add', + constraint: { + type: DatabaseConstraintType.UNIQUE, + name: 'UQ_test', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('ALTER TABLE "table1" ADD CONSTRAINT "UQ_test" UNIQUE ("id");'); + }); + }); + + describe('check', () => { + it('should work', () => { + expect( + transformConstraints({ + type: 'constraint.add', + constraint: { + type: DatabaseConstraintType.CHECK, + name: 'CHK_test', + tableName: 'table1', + expression: '"id" IS NOT NULL', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('ALTER TABLE "table1" ADD CONSTRAINT "CHK_test" CHECK ("id" IS NOT NULL);'); + }); + }); + }); + + describe('constraint.drop', () => { + it('should work', () => { + expect( + transformConstraints({ + type: 'constraint.drop', + tableName: 'table1', + constraintName: 'PK_test', + reason: 'unknown', + }), + ).toEqual(`ALTER TABLE "table1" DROP CONSTRAINT "PK_test";`); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/transformers/constraint.transformer.ts b/server/src/sql-tools/to-sql/transformers/constraint.transformer.ts new file mode 100644 index 0000000000..ec65143eba --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/constraint.transformer.ts @@ -0,0 +1,58 @@ +import { asColumnList } from 'src/sql-tools/helpers'; +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseActionType, DatabaseConstraint, DatabaseConstraintType, SchemaDiff } from 'src/sql-tools/types'; + +export const transformConstraints: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'constraint.add': { + return asConstraintAdd(item.constraint); + } + + case 'constraint.drop': { + return asConstraintDrop(item.tableName, item.constraintName); + } + default: { + return false; + } + } +}; + +const withAction = (constraint: { onDelete?: DatabaseActionType; onUpdate?: DatabaseActionType }) => + ` ON UPDATE ${constraint.onUpdate ?? DatabaseActionType.NO_ACTION} ON DELETE ${constraint.onDelete ?? DatabaseActionType.NO_ACTION}`; + +export const asConstraintAdd = (constraint: DatabaseConstraint): string | string[] => { + const base = `ALTER TABLE "${constraint.tableName}" ADD CONSTRAINT "${constraint.name}"`; + switch (constraint.type) { + case DatabaseConstraintType.PRIMARY_KEY: { + const columnNames = asColumnList(constraint.columnNames); + return `${base} PRIMARY KEY (${columnNames});`; + } + + case DatabaseConstraintType.FOREIGN_KEY: { + const columnNames = asColumnList(constraint.columnNames); + const referenceColumnNames = asColumnList(constraint.referenceColumnNames); + return ( + `${base} FOREIGN KEY (${columnNames}) REFERENCES "${constraint.referenceTableName}" (${referenceColumnNames})` + + withAction(constraint) + + ';' + ); + } + + case DatabaseConstraintType.UNIQUE: { + const columnNames = asColumnList(constraint.columnNames); + return `${base} UNIQUE (${columnNames});`; + } + + case DatabaseConstraintType.CHECK: { + return `${base} CHECK (${constraint.expression});`; + } + + default: { + return []; + } + } +}; + +export const asConstraintDrop = (tableName: string, constraintName: string): string => { + return `ALTER TABLE "${tableName}" DROP CONSTRAINT "${constraintName}";`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/enum.transformer.ts b/server/src/sql-tools/to-sql/transformers/enum.transformer.ts new file mode 100644 index 0000000000..d5764d9b16 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/enum.transformer.ts @@ -0,0 +1,26 @@ +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseEnum, SchemaDiff } from 'src/sql-tools/types'; + +export const transformEnums: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'enum.create': { + return asEnumCreate(item.enum); + } + + case 'enum.drop': { + return asEnumDrop(item.enumName); + } + + default: { + return false; + } + } +}; + +const asEnumCreate = ({ name, values }: DatabaseEnum): string => { + return `CREATE TYPE "${name}" AS ENUM (${values.map((value) => `'${value}'`)});`; +}; + +const asEnumDrop = (enumName: string): string => { + return `DROP TYPE "${enumName}";`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/extension.transformer.spec.ts b/server/src/sql-tools/to-sql/transformers/extension.transformer.spec.ts new file mode 100644 index 0000000000..81b2db4d27 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/extension.transformer.spec.ts @@ -0,0 +1,31 @@ +import { transformExtensions } from 'src/sql-tools/to-sql/transformers/extension.transformer'; +import { describe, expect, it } from 'vitest'; + +describe(transformExtensions.name, () => { + describe('extension.drop', () => { + it('should work', () => { + expect( + transformExtensions({ + type: 'extension.drop', + extensionName: 'cube', + reason: 'unknown', + }), + ).toEqual(`DROP EXTENSION "cube";`); + }); + }); + + describe('extension.create', () => { + it('should work', () => { + expect( + transformExtensions({ + type: 'extension.create', + extension: { + name: 'cube', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual(`CREATE EXTENSION IF NOT EXISTS "cube";`); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/transformers/extension.transformer.ts b/server/src/sql-tools/to-sql/transformers/extension.transformer.ts new file mode 100644 index 0000000000..2d51a26444 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/extension.transformer.ts @@ -0,0 +1,26 @@ +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseExtension, SchemaDiff } from 'src/sql-tools/types'; + +export const transformExtensions: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'extension.create': { + return asExtensionCreate(item.extension); + } + + case 'extension.drop': { + return asExtensionDrop(item.extensionName); + } + + default: { + return false; + } + } +}; + +const asExtensionCreate = (extension: DatabaseExtension): string => { + return `CREATE EXTENSION IF NOT EXISTS "${extension.name}";`; +}; + +const asExtensionDrop = (extensionName: string): string => { + return `DROP EXTENSION "${extensionName}";`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/function.transformer.spec.ts b/server/src/sql-tools/to-sql/transformers/function.transformer.spec.ts new file mode 100644 index 0000000000..6e9a5bac56 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/function.transformer.spec.ts @@ -0,0 +1,16 @@ +import { transformFunctions } from 'src/sql-tools/to-sql/transformers/function.transformer'; +import { describe, expect, it } from 'vitest'; + +describe(transformFunctions.name, () => { + describe('function.drop', () => { + it('should work', () => { + expect( + transformFunctions({ + type: 'function.drop', + functionName: 'test_func', + reason: 'unknown', + }), + ).toEqual(`DROP FUNCTION test_func;`); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/transformers/function.transformer.ts b/server/src/sql-tools/to-sql/transformers/function.transformer.ts new file mode 100644 index 0000000000..f05eca099a --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/function.transformer.ts @@ -0,0 +1,26 @@ +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseFunction, SchemaDiff } from 'src/sql-tools/types'; + +export const transformFunctions: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'function.create': { + return asFunctionCreate(item.function); + } + + case 'function.drop': { + return asFunctionDrop(item.functionName); + } + + default: { + return false; + } + } +}; + +const asFunctionCreate = (func: DatabaseFunction): string => { + return func.expression; +}; + +const asFunctionDrop = (functionName: string): string => { + return `DROP FUNCTION ${functionName};`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/index.transformer.spec.ts b/server/src/sql-tools/to-sql/transformers/index.transformer.spec.ts new file mode 100644 index 0000000000..af3cc0286c --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/index.transformer.spec.ts @@ -0,0 +1,100 @@ +import { transformIndexes } from 'src/sql-tools/to-sql/transformers/index.transformer'; +import { describe, expect, it } from 'vitest'; + +describe(transformIndexes.name, () => { + describe('index.create', () => { + it('should work', () => { + expect( + transformIndexes({ + type: 'index.create', + index: { + name: 'IDX_test', + tableName: 'table1', + columnNames: ['column1'], + unique: false, + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('CREATE INDEX "IDX_test" ON "table1" ("column1")'); + }); + + it('should create an unique index', () => { + expect( + transformIndexes({ + type: 'index.create', + index: { + name: 'IDX_test', + tableName: 'table1', + columnNames: ['column1'], + unique: true, + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('CREATE UNIQUE INDEX "IDX_test" ON "table1" ("column1")'); + }); + + it('should create an index with a custom expression', () => { + expect( + transformIndexes({ + type: 'index.create', + index: { + name: 'IDX_test', + tableName: 'table1', + unique: false, + expression: '"id" IS NOT NULL', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('CREATE INDEX "IDX_test" ON "table1" ("id" IS NOT NULL)'); + }); + + it('should create an index with a where clause', () => { + expect( + transformIndexes({ + type: 'index.create', + index: { + name: 'IDX_test', + tableName: 'table1', + columnNames: ['id'], + unique: false, + where: '("id" IS NOT NULL)', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('CREATE INDEX "IDX_test" ON "table1" ("id") WHERE ("id" IS NOT NULL)'); + }); + + it('should create an index with a custom expression', () => { + expect( + transformIndexes({ + type: 'index.create', + index: { + name: 'IDX_test', + tableName: 'table1', + unique: false, + using: 'gin', + expression: '"id" IS NOT NULL', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('CREATE INDEX "IDX_test" ON "table1" USING gin ("id" IS NOT NULL)'); + }); + }); + + describe('index.drop', () => { + it('should work', () => { + expect( + transformIndexes({ + type: 'index.drop', + indexName: 'IDX_test', + reason: 'unknown', + }), + ).toEqual(`DROP INDEX "IDX_test";`); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/transformers/index.transformer.ts b/server/src/sql-tools/to-sql/transformers/index.transformer.ts new file mode 100644 index 0000000000..73d9ac9615 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/index.transformer.ts @@ -0,0 +1,56 @@ +import { asColumnList } from 'src/sql-tools/helpers'; +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseIndex, SchemaDiff } from 'src/sql-tools/types'; + +export const transformIndexes: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'index.create': { + return asIndexCreate(item.index); + } + + case 'index.drop': { + return asIndexDrop(item.indexName); + } + + default: { + return false; + } + } +}; + +export const asIndexCreate = (index: DatabaseIndex): string => { + let sql = `CREATE`; + + if (index.unique) { + sql += ' UNIQUE'; + } + + sql += ` INDEX "${index.name}" ON "${index.tableName}"`; + + if (index.columnNames) { + const columnNames = asColumnList(index.columnNames); + sql += ` (${columnNames})`; + } + + if (index.using && index.using !== 'btree') { + sql += ` USING ${index.using}`; + } + + if (index.expression) { + sql += ` (${index.expression})`; + } + + if (index.with) { + sql += ` WITH (${index.with})`; + } + + if (index.where) { + sql += ` WHERE ${index.where}`; + } + + return sql; +}; + +export const asIndexDrop = (indexName: string): string => { + return `DROP INDEX "${indexName}";`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/parameter.transformer.ts b/server/src/sql-tools/to-sql/transformers/parameter.transformer.ts new file mode 100644 index 0000000000..0b12cdb27b --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/parameter.transformer.ts @@ -0,0 +1,33 @@ +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseParameter, SchemaDiff } from 'src/sql-tools/types'; + +export const transformParameters: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'parameter.set': { + return asParameterSet(item.parameter); + } + + case 'parameter.reset': { + return asParameterReset(item.databaseName, item.parameterName); + } + + default: { + return false; + } + } +}; + +const asParameterSet = (parameter: DatabaseParameter): string => { + let sql = ''; + if (parameter.scope === 'database') { + sql += `ALTER DATABASE "${parameter.databaseName}" `; + } + + sql += `SET ${parameter.name} TO ${parameter.value}`; + + return sql; +}; + +const asParameterReset = (databaseName: string, parameterName: string): string => { + return `ALTER DATABASE "${databaseName}" RESET "${parameterName}"`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/table.transformer.spec.ts b/server/src/sql-tools/to-sql/transformers/table.transformer.spec.ts new file mode 100644 index 0000000000..db3ffa22ec --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/table.transformer.spec.ts @@ -0,0 +1,150 @@ +import { transformTables } from 'src/sql-tools/to-sql/transformers/table.transformer'; +import { describe, expect, it } from 'vitest'; + +describe(transformTables.name, () => { + describe('table.drop', () => { + it('should work', () => { + expect( + transformTables({ + type: 'table.drop', + tableName: 'table1', + reason: 'unknown', + }), + ).toEqual(`DROP TABLE "table1";`); + }); + }); + + describe('table.create', () => { + it('should work', () => { + expect( + transformTables({ + type: 'table.create', + table: { + name: 'table1', + columns: [ + { + tableName: 'table1', + name: 'column1', + type: 'character varying', + nullable: true, + isArray: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [], + triggers: [], + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual([`CREATE TABLE "table1" ("column1" character varying);`]); + }); + + it('should handle a non-nullable column', () => { + expect( + transformTables({ + type: 'table.create', + table: { + name: 'table1', + columns: [ + { + tableName: 'table1', + name: 'column1', + type: 'character varying', + isArray: false, + nullable: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [], + triggers: [], + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual([`CREATE TABLE "table1" ("column1" character varying NOT NULL);`]); + }); + + it('should handle a default value', () => { + expect( + transformTables({ + type: 'table.create', + table: { + name: 'table1', + columns: [ + { + tableName: 'table1', + name: 'column1', + type: 'character varying', + isArray: false, + nullable: true, + default: 'uuid_generate_v4()', + synchronize: true, + }, + ], + indexes: [], + constraints: [], + triggers: [], + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual([`CREATE TABLE "table1" ("column1" character varying DEFAULT uuid_generate_v4());`]); + }); + + it('should handle a string with a fixed length', () => { + expect( + transformTables({ + type: 'table.create', + table: { + name: 'table1', + columns: [ + { + tableName: 'table1', + name: 'column1', + type: 'character varying', + length: 2, + isArray: false, + nullable: true, + synchronize: true, + }, + ], + indexes: [], + constraints: [], + triggers: [], + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual([`CREATE TABLE "table1" ("column1" character varying(2));`]); + }); + + it('should handle an array type', () => { + expect( + transformTables({ + type: 'table.create', + table: { + name: 'table1', + columns: [ + { + tableName: 'table1', + name: 'column1', + type: 'character varying', + isArray: true, + nullable: true, + synchronize: true, + }, + ], + indexes: [], + constraints: [], + triggers: [], + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual([`CREATE TABLE "table1" ("column1" character varying[]);`]); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/transformers/table.transformer.ts b/server/src/sql-tools/to-sql/transformers/table.transformer.ts new file mode 100644 index 0000000000..f376b65274 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/table.transformer.ts @@ -0,0 +1,44 @@ +import { asColumnComment, getColumnModifiers, getColumnType } from 'src/sql-tools/helpers'; +import { asColumnAlter } from 'src/sql-tools/to-sql/transformers/column.transformer'; +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseTable, SchemaDiff } from 'src/sql-tools/types'; + +export const transformTables: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'table.create': { + return asTableCreate(item.table); + } + + case 'table.drop': { + return asTableDrop(item.tableName); + } + + default: { + return false; + } + } +}; + +const asTableCreate = (table: DatabaseTable): string[] => { + const tableName = table.name; + const columnsTypes = table.columns + .map((column) => `"${column.name}" ${getColumnType(column)}` + getColumnModifiers(column)) + .join(', '); + const items = [`CREATE TABLE "${tableName}" (${columnsTypes});`]; + + for (const column of table.columns) { + if (column.comment) { + items.push(asColumnComment(tableName, column.name, column.comment)); + } + + if (column.storage) { + items.push(...asColumnAlter(tableName, column.name, { storage: column.storage })); + } + } + + return items; +}; + +const asTableDrop = (tableName: string): string => { + return `DROP TABLE "${tableName}";`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/trigger.transformer.spec.ts b/server/src/sql-tools/to-sql/transformers/trigger.transformer.spec.ts new file mode 100644 index 0000000000..778de88cba --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/trigger.transformer.spec.ts @@ -0,0 +1,91 @@ +import { transformTriggers } from 'src/sql-tools/to-sql/transformers/trigger.transformer'; +import { describe, expect, it } from 'vitest'; + +describe(transformTriggers.name, () => { + describe('trigger.create', () => { + it('should work', () => { + expect( + transformTriggers({ + type: 'trigger.create', + trigger: { + name: 'trigger1', + tableName: 'table1', + timing: 'before', + actions: ['update'], + scope: 'row', + functionName: 'function1', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual( + `CREATE OR REPLACE TRIGGER "trigger1" + BEFORE UPDATE ON "table1" + FOR EACH ROW + EXECUTE FUNCTION function1();`, + ); + }); + + it('should work with multiple actions', () => { + expect( + transformTriggers({ + type: 'trigger.create', + trigger: { + name: 'trigger1', + tableName: 'table1', + timing: 'before', + actions: ['update', 'delete'], + scope: 'row', + functionName: 'function1', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual( + `CREATE OR REPLACE TRIGGER "trigger1" + BEFORE UPDATE OR DELETE ON "table1" + FOR EACH ROW + EXECUTE FUNCTION function1();`, + ); + }); + + it('should work with old/new reference table aliases', () => { + expect( + transformTriggers({ + type: 'trigger.create', + trigger: { + name: 'trigger1', + tableName: 'table1', + timing: 'before', + actions: ['update'], + referencingNewTableAs: 'new', + referencingOldTableAs: 'old', + scope: 'row', + functionName: 'function1', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual( + `CREATE OR REPLACE TRIGGER "trigger1" + BEFORE UPDATE ON "table1" + REFERENCING OLD TABLE AS "old" NEW TABLE AS "new" + FOR EACH ROW + EXECUTE FUNCTION function1();`, + ); + }); + }); + + describe('trigger.drop', () => { + it('should work', () => { + expect( + transformTriggers({ + type: 'trigger.drop', + tableName: 'table1', + triggerName: 'trigger1', + reason: 'unknown', + }), + ).toEqual(`DROP TRIGGER "trigger1" ON "table1";`); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/transformers/trigger.transformer.ts b/server/src/sql-tools/to-sql/transformers/trigger.transformer.ts new file mode 100644 index 0000000000..c104a2ed6b --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/trigger.transformer.ts @@ -0,0 +1,52 @@ +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseTrigger, SchemaDiff } from 'src/sql-tools/types'; + +export const transformTriggers: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'trigger.create': { + return asTriggerCreate(item.trigger); + } + + case 'trigger.drop': { + return asTriggerDrop(item.tableName, item.triggerName); + } + + default: { + return false; + } + } +}; + +export const asTriggerCreate = (trigger: DatabaseTrigger): string => { + const sql: string[] = [ + `CREATE OR REPLACE TRIGGER "${trigger.name}"`, + `${trigger.timing.toUpperCase()} ${trigger.actions.map((action) => action.toUpperCase()).join(' OR ')} ON "${trigger.tableName}"`, + ]; + + if (trigger.referencingOldTableAs || trigger.referencingNewTableAs) { + let statement = `REFERENCING`; + if (trigger.referencingOldTableAs) { + statement += ` OLD TABLE AS "${trigger.referencingOldTableAs}"`; + } + if (trigger.referencingNewTableAs) { + statement += ` NEW TABLE AS "${trigger.referencingNewTableAs}"`; + } + sql.push(statement); + } + + if (trigger.scope) { + sql.push(`FOR EACH ${trigger.scope.toUpperCase()}`); + } + + if (trigger.when) { + sql.push(`WHEN (${trigger.when})`); + } + + sql.push(`EXECUTE FUNCTION ${trigger.functionName}();`); + + return sql.join('\n '); +}; + +export const asTriggerDrop = (tableName: string, triggerName: string): string => { + return `DROP TRIGGER "${triggerName}" ON "${tableName}";`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/types.ts b/server/src/sql-tools/to-sql/transformers/types.ts new file mode 100644 index 0000000000..9aa1031f85 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/types.ts @@ -0,0 +1,3 @@ +import { SchemaDiff } from 'src/sql-tools/types'; + +export type SqlTransformer = (item: SchemaDiff) => string | string[] | false; diff --git a/server/src/sql-tools/types.ts b/server/src/sql-tools/types.ts index 64813ca348..aea1288f3d 100644 --- a/server/src/sql-tools/types.ts +++ b/server/src/sql-tools/types.ts @@ -55,6 +55,42 @@ export type PostgresDB = { conindid: number; }; + pg_description: { + objoid: string; + classoid: string; + objsubid: number; + description: string; + }; + + pg_trigger: { + oid: string; + tgisinternal: boolean; + tginitdeferred: boolean; + tgdeferrable: boolean; + tgrelid: string; + tgfoid: string; + tgname: string; + tgenabled: string; + tgtype: number; + tgconstraint: string; + tgdeferred: boolean; + tgargs: Buffer; + tgoldtable: string; + tgnewtable: string; + tgqual: string; + }; + + 'pg_catalog.pg_extension': { + oid: string; + extname: string; + extowner: string; + extnamespace: string; + extrelocatable: boolean; + extversion: string; + extconfig: string[]; + extcondition: string[]; + }; + pg_enum: { oid: string; enumtypid: string; @@ -99,6 +135,38 @@ export type PostgresDB = { typarray: string; }; + pg_depend: { + objid: string; + deptype: string; + }; + + pg_proc: { + oid: string; + proname: string; + pronamespace: string; + prokind: string; + }; + + pg_settings: { + name: string; + setting: string; + unit: string | null; + category: string; + short_desc: string | null; + extra_desc: string | null; + context: string; + vartype: string; + source: string; + min_val: string | null; + max_val: string | null; + enumvals: string[] | null; + boot_val: string | null; + reset_val: string | null; + sourcefile: string | null; + sourceline: number | null; + pending_restart: PostgresYesOrNo; + }; + 'information_schema.tables': { table_catalog: string; table_schema: string; @@ -142,12 +210,31 @@ export type PostgresDB = { collection_type_identifier: string; data_type: string; }; + + 'information_schema.routines': { + specific_catalog: string; + specific_schema: string; + specific_name: string; + routine_catalog: string; + routine_schema: string; + routine_name: string; + routine_type: string; + data_type: string; + type_udt_catalog: string; + type_udt_schema: string; + type_udt_name: string; + dtd_identifier: string; + routine_body: string; + routine_definition: string; + external_name: string; + external_language: string; + is_deterministic: PostgresYesOrNo; + security_type: string; + }; }; type PostgresYesOrNo = 'YES' | 'NO'; -export type ColumnDefaultValue = null | boolean | string | number | object | Date | (() => string); - export type DatabaseClient = Kysely; export enum DatabaseConstraintType { @@ -165,7 +252,9 @@ export enum DatabaseActionType { SET_DEFAULT = 'SET DEFAULT', } -export type DatabaseColumnType = +export type ColumnStorage = 'default' | 'external' | 'extended' | 'main' | 'default'; + +export type ColumnType = | 'bigint' | 'boolean' | 'bytea' @@ -188,71 +277,63 @@ export type DatabaseColumnType = | 'enum' | 'serial'; -export type TableOptions = { - name?: string; - primaryConstraintName?: string; - synchronize?: boolean; -}; - -type ColumnBaseOptions = { - name?: string; - primary?: boolean; - type?: DatabaseColumnType; - nullable?: boolean; - length?: number; - default?: ColumnDefaultValue; - synchronize?: boolean; -}; - -export type ColumnOptions = ColumnBaseOptions & { - enum?: object; - enumName?: string; - array?: boolean; - unique?: boolean; - uniqueConstraintName?: string; -}; - -export type GenerateColumnOptions = Omit & { - type?: 'v4' | 'v7'; -}; - -export type ColumnIndexOptions = { - name?: string; - unique?: boolean; - expression?: string; - using?: string; - where?: string; - synchronize?: boolean; -}; - -export type IndexOptions = ColumnIndexOptions & { - columns?: string[]; - synchronize?: boolean; -}; - -export type UniqueOptions = { - name?: string; - columns: string[]; - synchronize?: boolean; -}; - -export type CheckOptions = { - name?: string; - expression: string; - synchronize?: boolean; -}; - export type DatabaseSchema = { name: string; + schemaName: string; + functions: DatabaseFunction[]; + enums: DatabaseEnum[]; tables: DatabaseTable[]; + extensions: DatabaseExtension[]; + parameters: DatabaseParameter[]; warnings: string[]; }; +export type SchemaDiffOptions = { + tables?: DiffOptions; + functions?: DiffOptions; + enums?: DiffOptions; + extension?: DiffOptions; + parameters?: DiffOptions; +}; + +export type DiffOptions = { + ignoreExtra?: boolean; + ignoreMissing?: boolean; +}; + +export type DatabaseParameter = { + name: string; + databaseName: string; + value: string | number | null | undefined; + scope: ParameterScope; + synchronize: boolean; +}; + +export type ParameterScope = 'database' | 'user'; + +export type DatabaseEnum = { + name: string; + values: string[]; + synchronize: boolean; +}; + +export type DatabaseFunction = { + name: string; + expression: string; + synchronize: boolean; +}; + +export type DatabaseExtension = { + name: string; + synchronize: boolean; +}; + export type DatabaseTable = { name: string; columns: DatabaseColumn[]; indexes: DatabaseIndex[]; constraints: DatabaseConstraint[]; + triggers: DatabaseTrigger[]; synchronize: boolean; }; @@ -266,17 +347,19 @@ export type DatabaseColumn = { primary?: boolean; name: string; tableName: string; + comment?: string; - type: DatabaseColumnType; + type: ColumnType; nullable: boolean; isArray: boolean; synchronize: boolean; default?: string; length?: number; + storage?: ColumnStorage; + identity?: boolean; // enum values - enumValues?: string[]; enumName?: string; // numeric types @@ -284,9 +367,11 @@ export type DatabaseColumn = { numericScale?: number; }; -export type DatabaseColumnChanges = { +export type ColumnChanges = { nullable?: boolean; default?: string; + comment?: string; + storage?: ColumnStorage; }; type ColumBasedConstraint = { @@ -322,6 +407,22 @@ export type DatabaseCheckConstraint = { synchronize: boolean; }; +export type DatabaseTrigger = { + name: string; + tableName: string; + timing: TriggerTiming; + actions: TriggerAction[]; + scope: TriggerScope; + referencingNewTableAs?: string; + referencingOldTableAs?: string; + when?: string; + functionName: string; + synchronize: boolean; +}; +export type TriggerTiming = 'before' | 'after' | 'instead of'; +export type TriggerAction = 'insert' | 'update' | 'delete' | 'truncate'; +export type TriggerScope = 'row' | 'statement'; + export type DatabaseIndex = { name: string; tableName: string; @@ -329,6 +430,7 @@ export type DatabaseIndex = { expression?: string; unique: boolean; using?: string; + with?: string; where?: string; synchronize: boolean; }; @@ -342,22 +444,35 @@ export type SchemaDiffToSqlOptions = { }; export type SchemaDiff = { reason: string } & ( - | { type: 'table.create'; tableName: string; columns: DatabaseColumn[] } + | { type: 'extension.create'; extension: DatabaseExtension } + | { type: 'extension.drop'; extensionName: string } + | { type: 'function.create'; function: DatabaseFunction } + | { type: 'function.drop'; functionName: string } + | { type: 'table.create'; table: DatabaseTable } | { type: 'table.drop'; tableName: string } | { type: 'column.add'; column: DatabaseColumn } - | { type: 'column.alter'; tableName: string; columnName: string; changes: DatabaseColumnChanges } + | { type: 'column.alter'; tableName: string; columnName: string; changes: ColumnChanges } | { type: 'column.drop'; tableName: string; columnName: string } | { type: 'constraint.add'; constraint: DatabaseConstraint } | { type: 'constraint.drop'; tableName: string; constraintName: string } | { type: 'index.create'; index: DatabaseIndex } | { type: 'index.drop'; indexName: string } + | { type: 'trigger.create'; trigger: DatabaseTrigger } + | { type: 'trigger.drop'; tableName: string; triggerName: string } + | { type: 'parameter.set'; parameter: DatabaseParameter } + | { type: 'parameter.reset'; databaseName: string; parameterName: string } + | { type: 'enum.create'; enum: DatabaseEnum } + | { type: 'enum.drop'; enumName: string } ); -type Action = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION'; -export type ForeignKeyColumnOptions = ColumnBaseOptions & { - onUpdate?: Action; - onDelete?: Action; - constraintName?: string; - unique?: boolean; - uniqueConstraintName?: string; +export type CompareFunction = (source: T, target: T) => SchemaDiff[]; +export type Comparer = { + onMissing: (source: T) => SchemaDiff[]; + onExtra: (target: T) => SchemaDiff[]; + onCompare: CompareFunction; }; + +export enum Reason { + MissingInSource = 'missing in source', + MissingInTarget = 'missing in target', +} diff --git a/server/test/sql-tools/check-constraint-default-name.stub.ts b/server/test/sql-tools/check-constraint-default-name.stub.ts index 42ee336b94..af03e02a2e 100644 --- a/server/test/sql-tools/check-constraint-default-name.stub.ts +++ b/server/test/sql-tools/check-constraint-default-name.stub.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should create a check constraint with a default name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.CHECK, diff --git a/server/test/sql-tools/check-constraint-override-name.stub.ts b/server/test/sql-tools/check-constraint-override-name.stub.ts index 89db6044a2..b30025e2fc 100644 --- a/server/test/sql-tools/check-constraint-override-name.stub.ts +++ b/server/test/sql-tools/check-constraint-override-name.stub.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should create a check constraint with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.CHECK, diff --git a/server/test/sql-tools/column-create-date.stub.ts b/server/test/sql-tools/column-create-date.stub.ts new file mode 100644 index 0000000000..7a284c674c --- /dev/null +++ b/server/test/sql-tools/column-create-date.stub.ts @@ -0,0 +1,39 @@ +import { CreateDateColumn, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @CreateDateColumn() + createdAt!: string; +} + +export const description = 'should register a table with an created at date column'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'createdAt', + tableName: 'table1', + type: 'timestamp with time zone', + default: 'now()', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-default-boolean.stub.ts b/server/test/sql-tools/column-default-boolean.stub.ts index 464a34b26e..962b023a25 100644 --- a/server/test/sql-tools/column-default-boolean.stub.ts +++ b/server/test/sql-tools/column-default-boolean.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a table with a column with a default value (boolean)'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-default-date.stub.ts b/server/test/sql-tools/column-default-date.stub.ts index 72c06b3bd9..00f2db2c27 100644 --- a/server/test/sql-tools/column-default-date.stub.ts +++ b/server/test/sql-tools/column-default-date.stub.ts @@ -10,7 +10,12 @@ export class Table1 { export const description = 'should register a table with a column with a default value (date)'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -27,6 +32,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-default-function.stub.ts b/server/test/sql-tools/column-default-function.stub.ts index ceb03b50f0..b13bd14c93 100644 --- a/server/test/sql-tools/column-default-function.stub.ts +++ b/server/test/sql-tools/column-default-function.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a table with a column with a default function'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-default-null.stub.ts b/server/test/sql-tools/column-default-null.stub.ts index b4aa83788b..c88ed218b3 100644 --- a/server/test/sql-tools/column-default-null.stub.ts +++ b/server/test/sql-tools/column-default-null.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a nullable column from a default of null'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-default-number.stub.ts b/server/test/sql-tools/column-default-number.stub.ts index f3fac229c7..36d0af5273 100644 --- a/server/test/sql-tools/column-default-number.stub.ts +++ b/server/test/sql-tools/column-default-number.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a table with a column with a default value (number)'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-default-string.stub.ts b/server/test/sql-tools/column-default-string.stub.ts index 36aa584eeb..04a00a4dfe 100644 --- a/server/test/sql-tools/column-default-string.stub.ts +++ b/server/test/sql-tools/column-default-string.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a table with a column with a default value (string)'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-delete-date.stub.ts b/server/test/sql-tools/column-delete-date.stub.ts new file mode 100644 index 0000000000..facbfb0328 --- /dev/null +++ b/server/test/sql-tools/column-delete-date.stub.ts @@ -0,0 +1,38 @@ +import { DatabaseSchema, DeleteDateColumn, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @DeleteDateColumn() + deletedAt!: string; +} + +export const description = 'should register a table with a deleted at date column'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'deletedAt', + tableName: 'table1', + type: 'timestamp with time zone', + nullable: true, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-enum-type.stub.ts b/server/test/sql-tools/column-enum-type.stub.ts new file mode 100644 index 0000000000..878910dcdb --- /dev/null +++ b/server/test/sql-tools/column-enum-type.stub.ts @@ -0,0 +1,52 @@ +import { Column, DatabaseSchema, registerEnum, Table } from 'src/sql-tools'; + +enum Test { + Foo = 'foo', + Bar = 'bar', +} + +const test_enum = registerEnum({ name: 'test_enum', values: Object.values(Test) }); + +@Table() +export class Table1 { + @Column({ enum: test_enum }) + column1!: string; +} + +export const description = 'should accept an enum type'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [ + { + name: 'test_enum', + values: ['foo', 'bar'], + synchronize: true, + }, + ], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'enum', + enumName: 'test_enum', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-generated-identity.ts b/server/test/sql-tools/column-generated-identity.ts new file mode 100644 index 0000000000..98b0f582a6 --- /dev/null +++ b/server/test/sql-tools/column-generated-identity.ts @@ -0,0 +1,47 @@ +import { DatabaseConstraintType, DatabaseSchema, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @PrimaryGeneratedColumn({ strategy: 'identity' }) + column1!: string; +} + +export const description = 'should register a table with a generated identity column'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'integer', + identity: true, + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [ + { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_50c4f9905061b1e506d38a2a380', + tableName: 'table1', + columnNames: ['column1'], + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-generated-uuid.stub.ts b/server/test/sql-tools/column-generated-uuid.stub.ts new file mode 100644 index 0000000000..69cc59530e --- /dev/null +++ b/server/test/sql-tools/column-generated-uuid.stub.ts @@ -0,0 +1,47 @@ +import { DatabaseConstraintType, DatabaseSchema, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @PrimaryGeneratedColumn({ strategy: 'uuid' }) + column1!: string; +} + +export const description = 'should register a table with a primary generated uuid column'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'uuid', + default: 'uuid_generate_v4()', + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [ + { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_50c4f9905061b1e506d38a2a380', + tableName: 'table1', + columnNames: ['column1'], + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-index-name-default.ts b/server/test/sql-tools/column-index-name-default.ts index d3b5aba112..e8b36ec119 100644 --- a/server/test/sql-tools/column-index-name-default.ts +++ b/server/test/sql-tools/column-index-name-default.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should create a column with an index'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -33,6 +38,7 @@ export const schema: DatabaseSchema = { synchronize: true, }, ], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-inferred-nullable.stub.ts b/server/test/sql-tools/column-inferred-nullable.stub.ts index d866b59093..70495db800 100644 --- a/server/test/sql-tools/column-inferred-nullable.stub.ts +++ b/server/test/sql-tools/column-inferred-nullable.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should infer nullable from the default value'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-name-default.stub.ts b/server/test/sql-tools/column-name-default.stub.ts index 3c6df97fe4..e1db458e4b 100644 --- a/server/test/sql-tools/column-name-default.stub.ts +++ b/server/test/sql-tools/column-name-default.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a table with a column with a default name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-name-override.stub.ts b/server/test/sql-tools/column-name-override.stub.ts index b5e86e47d0..250e295280 100644 --- a/server/test/sql-tools/column-name-override.stub.ts +++ b/server/test/sql-tools/column-name-override.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a table with a column with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-name-string.stub.ts b/server/test/sql-tools/column-name-string.stub.ts index 013e74e7da..12f8b4a537 100644 --- a/server/test/sql-tools/column-name-string.stub.ts +++ b/server/test/sql-tools/column-name-string.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a table with a column with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-nullable.stub.ts b/server/test/sql-tools/column-nullable.stub.ts index 2704fb7cf6..2b82a3de13 100644 --- a/server/test/sql-tools/column-nullable.stub.ts +++ b/server/test/sql-tools/column-nullable.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should set nullable correctly'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-enum-name.stub.ts b/server/test/sql-tools/column-string-length.stub.ts similarity index 63% rename from server/test/sql-tools/column-enum-name.stub.ts rename to server/test/sql-tools/column-string-length.stub.ts index 9ae1b4310d..47400f25e0 100644 --- a/server/test/sql-tools/column-enum-name.stub.ts +++ b/server/test/sql-tools/column-string-length.stub.ts @@ -1,19 +1,19 @@ import { Column, DatabaseSchema, Table } from 'src/sql-tools'; -enum Test { - Foo = 'foo', - Bar = 'bar', -} - @Table() export class Table1 { - @Column({ enum: Test }) + @Column({ length: 2 }) column1!: string; } -export const description = 'should use a default enum naming convention'; +export const description = 'should use create a string column with a fixed length'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -21,9 +21,8 @@ export const schema: DatabaseSchema = { { name: 'column1', tableName: 'table1', - type: 'enum', - enumName: 'table1_column1_enum', - enumValues: ['foo', 'bar'], + type: 'character varying', + length: 2, nullable: false, isArray: false, primary: false, @@ -31,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-unique-constraint-name-default.stub.ts b/server/test/sql-tools/column-unique-constraint-name-default.stub.ts index 6446a2069d..e1e1619679 100644 --- a/server/test/sql-tools/column-unique-constraint-name-default.stub.ts +++ b/server/test/sql-tools/column-unique-constraint-name-default.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should create a unique key constraint with a default name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.UNIQUE, diff --git a/server/test/sql-tools/column-unique-constraint-name-override.stub.ts b/server/test/sql-tools/column-unique-constraint-name-override.stub.ts index fb96ff06b2..36ce80efb6 100644 --- a/server/test/sql-tools/column-unique-constraint-name-override.stub.ts +++ b/server/test/sql-tools/column-unique-constraint-name-override.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should create a unique key constraint with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.UNIQUE, diff --git a/server/test/sql-tools/column-update-date.stub.ts b/server/test/sql-tools/column-update-date.stub.ts new file mode 100644 index 0000000000..bbdb6df923 --- /dev/null +++ b/server/test/sql-tools/column-update-date.stub.ts @@ -0,0 +1,39 @@ +import { DatabaseSchema, Table, UpdateDateColumn } from 'src/sql-tools'; + +@Table() +export class Table1 { + @UpdateDateColumn() + updatedAt!: string; +} + +export const description = 'should register a table with an updated at date column'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'updatedAt', + tableName: 'table1', + type: 'timestamp with time zone', + default: 'now()', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/foreign-key-inferred-type.stub.ts b/server/test/sql-tools/foreign-key-inferred-type.stub.ts index b88d834a76..2ecaafdcad 100644 --- a/server/test/sql-tools/foreign-key-inferred-type.stub.ts +++ b/server/test/sql-tools/foreign-key-inferred-type.stub.ts @@ -14,7 +14,12 @@ export class Table2 { export const description = 'should infer the column type from the reference column'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -30,6 +35,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.PRIMARY_KEY, @@ -55,6 +61,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.FOREIGN_KEY, diff --git a/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts b/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts index 8bf2328fc3..0601a02d42 100644 --- a/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts +++ b/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts @@ -14,7 +14,12 @@ export class Table2 { export const description = 'should create a foreign key constraint with a unique constraint'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -30,6 +35,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.PRIMARY_KEY, @@ -55,6 +61,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.FOREIGN_KEY, diff --git a/server/test/sql-tools/index-name-default.stub.ts b/server/test/sql-tools/index-name-default.stub.ts index ffadfb0b32..06ccd7e173 100644 --- a/server/test/sql-tools/index-name-default.stub.ts +++ b/server/test/sql-tools/index-name-default.stub.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should create an index with a default name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -33,6 +38,7 @@ export const schema: DatabaseSchema = { synchronize: true, }, ], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/index-name-override.stub.ts b/server/test/sql-tools/index-name-override.stub.ts index f72a0cbeb1..afdc26dcc0 100644 --- a/server/test/sql-tools/index-name-override.stub.ts +++ b/server/test/sql-tools/index-name-override.stub.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should create an index with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -33,6 +38,7 @@ export const schema: DatabaseSchema = { synchronize: true, }, ], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/index-with-where.stub copy.ts b/server/test/sql-tools/index-with-where.stub copy.ts index 0d22f4e115..dec31ebe02 100644 --- a/server/test/sql-tools/index-with-where.stub copy.ts +++ b/server/test/sql-tools/index-with-where.stub copy.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should create an index based off of an expression'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -33,6 +38,7 @@ export const schema: DatabaseSchema = { synchronize: true, }, ], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/index-with-where.stub.ts b/server/test/sql-tools/index-with-where.stub.ts index e59d2ec36b..ce4236e490 100644 --- a/server/test/sql-tools/index-with-where.stub.ts +++ b/server/test/sql-tools/index-with-where.stub.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should create an index with a where clause'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -34,6 +39,7 @@ export const schema: DatabaseSchema = { synchronize: true, }, ], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/primary-key-constraint-name-default.stub.ts b/server/test/sql-tools/primary-key-constraint-name-default.stub.ts index d4b426b9f1..22a515735a 100644 --- a/server/test/sql-tools/primary-key-constraint-name-default.stub.ts +++ b/server/test/sql-tools/primary-key-constraint-name-default.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should add a primary key constraint to the table with a default name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.PRIMARY_KEY, diff --git a/server/test/sql-tools/primary-key-constraint-name-override.stub.ts b/server/test/sql-tools/primary-key-constraint-name-override.stub.ts index 717d9165b3..e1e0daa82e 100644 --- a/server/test/sql-tools/primary-key-constraint-name-override.stub.ts +++ b/server/test/sql-tools/primary-key-constraint-name-override.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should add a primary key constraint to the table with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.PRIMARY_KEY, diff --git a/server/test/sql-tools/table-name-default.stub.ts b/server/test/sql-tools/table-name-default.stub.ts index a76a5b6dbb..6ecc042a58 100644 --- a/server/test/sql-tools/table-name-default.stub.ts +++ b/server/test/sql-tools/table-name-default.stub.ts @@ -5,12 +5,18 @@ export class Table1 {} export const description = 'should register a table with a default name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', columns: [], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/table-name-override.stub.ts b/server/test/sql-tools/table-name-override.stub.ts index 3290fab6a4..929a4c4b28 100644 --- a/server/test/sql-tools/table-name-override.stub.ts +++ b/server/test/sql-tools/table-name-override.stub.ts @@ -5,12 +5,18 @@ export class Table1 {} export const description = 'should register a table with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table-1', columns: [], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/table-name-string-option.stub.ts b/server/test/sql-tools/table-name-string-option.stub.ts index 0c9a045d5b..33e582fb6b 100644 --- a/server/test/sql-tools/table-name-string-option.stub.ts +++ b/server/test/sql-tools/table-name-string-option.stub.ts @@ -5,12 +5,18 @@ export class Table1 {} export const description = 'should register a table with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table-1', columns: [], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/trigger-after-delete.stub.ts b/server/test/sql-tools/trigger-after-delete.stub.ts new file mode 100644 index 0000000000..903fe2179e --- /dev/null +++ b/server/test/sql-tools/trigger-after-delete.stub.ts @@ -0,0 +1,46 @@ +import { AfterDeleteTrigger, DatabaseSchema, registerFunction, Table } from 'src/sql-tools'; + +const test_fn = registerFunction({ + name: 'test_fn', + body: 'SELECT 1;', + returnType: 'character varying', +}); + +@Table() +@AfterDeleteTrigger({ + name: 'my_trigger', + function: test_fn, + scope: 'row', +}) +export class Table1 {} + +export const description = 'should create a trigger '; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [expect.any(Object)], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [], + indexes: [], + triggers: [ + { + name: 'my_trigger', + functionName: 'test_fn', + tableName: 'table1', + timing: 'after', + scope: 'row', + actions: ['delete'], + synchronize: true, + }, + ], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/trigger-before-update.stub.ts b/server/test/sql-tools/trigger-before-update.stub.ts new file mode 100644 index 0000000000..a88675a9ef --- /dev/null +++ b/server/test/sql-tools/trigger-before-update.stub.ts @@ -0,0 +1,46 @@ +import { BeforeUpdateTrigger, DatabaseSchema, registerFunction, Table } from 'src/sql-tools'; + +const test_fn = registerFunction({ + name: 'test_fn', + body: 'SELECT 1;', + returnType: 'character varying', +}); + +@Table() +@BeforeUpdateTrigger({ + name: 'my_trigger', + function: test_fn, + scope: 'row', +}) +export class Table1 {} + +export const description = 'should create a trigger '; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [expect.any(Object)], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [], + indexes: [], + triggers: [ + { + name: 'my_trigger', + functionName: 'test_fn', + tableName: 'table1', + timing: 'before', + scope: 'row', + actions: ['update'], + synchronize: true, + }, + ], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/trigger-name-default.stub.ts b/server/test/sql-tools/trigger-name-default.stub.ts new file mode 100644 index 0000000000..a9951aef18 --- /dev/null +++ b/server/test/sql-tools/trigger-name-default.stub.ts @@ -0,0 +1,41 @@ +import { DatabaseSchema, Table, Trigger } from 'src/sql-tools'; + +@Table() +@Trigger({ + timing: 'before', + actions: ['insert'], + scope: 'row', + functionName: 'function1', +}) +export class Table1 {} + +export const description = 'should register a trigger with a default name'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [], + indexes: [], + triggers: [ + { + name: 'TR_ca71832b10b77ed600ef05df631', + tableName: 'table1', + functionName: 'function1', + actions: ['insert'], + scope: 'row', + timing: 'before', + synchronize: true, + }, + ], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/trigger-name-override.stub.ts b/server/test/sql-tools/trigger-name-override.stub.ts new file mode 100644 index 0000000000..3fba0e12ab --- /dev/null +++ b/server/test/sql-tools/trigger-name-override.stub.ts @@ -0,0 +1,42 @@ +import { DatabaseSchema, Table, Trigger } from 'src/sql-tools'; + +@Table() +@Trigger({ + name: 'trigger1', + timing: 'before', + actions: ['insert'], + scope: 'row', + functionName: 'function1', +}) +export class Table1 {} + +export const description = 'should a trigger with a specific name'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [], + indexes: [], + triggers: [ + { + name: 'trigger1', + tableName: 'table1', + functionName: 'function1', + actions: ['insert'], + scope: 'row', + timing: 'before', + synchronize: true, + }, + ], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/unique-constraint-name-default.stub.ts b/server/test/sql-tools/unique-constraint-name-default.stub.ts index 42fc63bc46..a3b9c512c5 100644 --- a/server/test/sql-tools/unique-constraint-name-default.stub.ts +++ b/server/test/sql-tools/unique-constraint-name-default.stub.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should add a unique constraint to the table with a default name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.UNIQUE, diff --git a/server/test/sql-tools/unique-constraint-name-override.stub.ts b/server/test/sql-tools/unique-constraint-name-override.stub.ts index e7f6fcf83c..4def45043f 100644 --- a/server/test/sql-tools/unique-constraint-name-override.stub.ts +++ b/server/test/sql-tools/unique-constraint-name-override.stub.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should add a unique constraint to the table with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.UNIQUE, diff --git a/server/test/vitest.config.mjs b/server/test/vitest.config.mjs index d3d1c98f5d..a6929bf806 100644 --- a/server/test/vitest.config.mjs +++ b/server/test/vitest.config.mjs @@ -12,13 +12,13 @@ export default defineConfig({ include: ['src/**/*.spec.ts'], coverage: { provider: 'v8', - include: ['src/cores/**', 'src/interfaces/**', 'src/services/**', 'src/utils/**', 'src/sql-tools/**'], + include: ['src/cores/**', 'src/services/**', 'src/utils/**', 'src/sql-tools/**'], exclude: [ 'src/services/*.spec.ts', 'src/services/api.service.ts', 'src/services/microservices.service.ts', 'src/services/index.ts', - 'src/sql-tools/schema-from-database.ts', + 'src/sql-tools/from-database/index.ts', ], thresholds: { lines: 85,