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

feat: sql-tools extension, triggers, functions, comments, parameters
This commit is contained in:
Jason Rasmussen 2025-04-07 15:12:12 -04:00 committed by GitHub
parent 51c2c60231
commit e7a5b96ed0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
170 changed files with 5205 additions and 2295 deletions

View File

@ -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: |

View File

@ -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",

View File

@ -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 <name>
node dist/bin/migrations.js generate <name>
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<any>({
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 };
};

View File

@ -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<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) =>
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.

View File

@ -0,0 +1,12 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class TableCleanup1743595393000 implements MigrationInterface {
name = 'TableCleanup1743595393000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "system_config"`);
await queryRunner.query(`DROP TABLE IF EXISTS "socket_io_attachments"`);
}
public async down(): Promise<void> {}
}

View File

@ -197,58 +197,62 @@ export class DatabaseRepository {
return dimSize;
}
async runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise<void> {
async runMigrations(options?: { transaction?: 'all' | 'none' | 'each'; only?: 'kysely' | 'typeorm' }): Promise<void> {
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<R>(lock: DatabaseLock, callback: () => Promise<R>): Promise<R> {

View File

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

View File

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

View File

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

109
server/src/schema/index.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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')

View File

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

View File

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

View File

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

View File

@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<T extends MemoryType = MemoryType> {
@PrimaryGeneratedColumn()
id!: string;
@ -24,10 +25,6 @@ export class MemoryTable<T extends MemoryType = MemoryType> {
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_memories_update_id')
@UpdateIdColumn()
updateId?: string;
@DeleteDateColumn()
deletedAt?: Date;
@ -48,13 +45,17 @@ export class MemoryTable<T extends MemoryType = MemoryType> {
@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;
}

View File

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

View File

@ -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 })

View File

@ -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')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Date, Date | string, Date | string>;
@ -17,50 +19,51 @@ type Generated<T> =
T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
@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<string>;
@Column({ unique: true })
email!: string;
@Column({ default: '' })
name!: Generated<string>;
password!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@Column({ default: '' })
profileImagePath!: Generated<string>;
@Column({ type: 'boolean', default: false })
isAdmin!: Generated<boolean>;
@Column({ unique: true })
email!: string;
@Column({ type: 'boolean', default: true })
shouldChangePassword!: Generated<boolean>;
@DeleteDateColumn()
deletedAt!: Timestamp | null;
@Column({ default: '' })
oauthId!: Generated<string>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@Column({ unique: true, nullable: true, default: null })
storageLabel!: string | null;
@Column({ default: '' })
password!: Generated<string>;
@Column({ default: '' })
oauthId!: Generated<string>;
@Column({ default: '' })
profileImagePath!: Generated<string>;
@Column({ type: 'boolean', default: true })
shouldChangePassword!: Generated<boolean>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@DeleteDateColumn()
deletedAt!: Timestamp | null;
@Column({ type: 'character varying', default: UserStatus.ACTIVE })
status!: Generated<UserStatus>;
@ColumnIndex({ name: 'IDX_users_update_id' })
@UpdateIdColumn()
updateId!: Generated<string>;
name!: Generated<string>;
@Column({ type: 'bigint', nullable: true })
quotaSizeInBytes!: ColumnType<number> | null;
@ -68,6 +71,13 @@ export class UserTable {
@Column({ type: 'bigint', default: 0 })
quotaUsageInBytes!: Generated<ColumnType<number>>;
@Column({ type: 'character varying', default: UserStatus.ACTIVE })
status!: Generated<UserStatus>;
@Column({ type: 'timestamp with time zone', default: () => 'now()' })
profileChangedAt!: Generated<Timestamp>;
@ColumnIndex({ name: 'IDX_users_update_id' })
@UpdateIdColumn()
updateId!: Generated<string>;
}

View File

@ -1,107 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { register } from 'src/sql-tools/schema-from-decorators';
import {
CheckOptions,
ColumnDefaultValue,
ColumnIndexOptions,
ColumnOptions,
ForeignKeyColumnOptions,
GenerateColumnOptions,
IndexOptions,
TableOptions,
UniqueOptions,
} from 'src/sql-tools/types';
export const Table = (options: string | TableOptions = {}): ClassDecorator => {
return (object: Function) => void register({ type: 'table', item: { object, options: asOptions(options) } });
};
export const Column = (options: string | ColumnOptions = {}): PropertyDecorator => {
return (object: object, propertyName: string | symbol) =>
void register({ type: 'column', item: { object, propertyName, options: asOptions(options) } });
};
export const Index = (options: string | IndexOptions = {}): ClassDecorator => {
return (object: Function) => void register({ type: 'index', item: { object, options: asOptions(options) } });
};
export const Unique = (options: UniqueOptions): ClassDecorator => {
return (object: Function) => void register({ type: 'uniqueConstraint', item: { object, options } });
};
export const Check = (options: CheckOptions): ClassDecorator => {
return (object: Function) => void register({ type: 'checkConstraint', item: { object, options } });
};
export const ColumnIndex = (options: string | ColumnIndexOptions = {}): PropertyDecorator => {
return (object: object, propertyName: string | symbol) =>
void register({ type: 'columnIndex', item: { object, propertyName, options: asOptions(options) } });
};
export const ForeignKeyColumn = (target: () => object, options: ForeignKeyColumnOptions): PropertyDecorator => {
return (object: object, propertyName: string | symbol) => {
register({ type: 'foreignKeyColumn', item: { object, propertyName, options, target } });
};
};
export const CreateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
return Column({
type: 'timestamp with time zone',
default: () => 'now()',
...options,
});
};
export const UpdateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
return Column({
type: 'timestamp with time zone',
default: () => 'now()',
...options,
});
};
export const DeleteDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
return Column({
type: 'timestamp with time zone',
nullable: true,
...options,
});
};
export const PrimaryGeneratedColumn = (options: Omit<GenerateColumnOptions, 'primary'> = {}) =>
GeneratedColumn({ type: 'v4', ...options, primary: true });
export const PrimaryColumn = (options: Omit<ColumnOptions, 'primary'> = {}) => Column({ ...options, primary: true });
export const GeneratedColumn = ({ type = 'v4', ...options }: GenerateColumnOptions): PropertyDecorator => {
const columnType = type === 'v4' || type === 'v7' ? 'uuid' : type;
let columnDefault: ColumnDefaultValue | undefined;
switch (type) {
case 'v4': {
columnDefault = () => 'uuid_generate_v4()';
break;
}
case 'v7': {
columnDefault = () => 'immich_uuid_v7()';
break;
}
}
return Column({
type: columnType,
default: columnDefault,
...options,
});
};
export const UpdateIdColumn = () => GeneratedColumn({ type: 'v7', nullable: false });
const asOptions = <T extends { name?: string }>(options: string | T): T => {
if (typeof options === 'string') {
return { name: options } as T;
}
return options;
};

View File

@ -0,0 +1,81 @@
import { compareColumns } from 'src/sql-tools/diff/comparers/column.comparer';
import { DatabaseColumn, Reason } from 'src/sql-tools/types';
import { describe, expect, it } from 'vitest';
const testColumn: DatabaseColumn = {
name: 'test',
tableName: 'table1',
nullable: false,
isArray: false,
type: 'character varying',
synchronize: true,
};
describe('compareColumns', () => {
describe('onExtra', () => {
it('should work', () => {
expect(compareColumns.onExtra(testColumn)).toEqual([
{
tableName: 'table1',
columnName: 'test',
type: 'column.drop',
reason: Reason.MissingInSource,
},
]);
});
});
describe('onMissing', () => {
it('should work', () => {
expect(compareColumns.onMissing(testColumn)).toEqual([
{
type: 'column.add',
column: testColumn,
reason: Reason.MissingInTarget,
},
]);
});
});
describe('onCompare', () => {
it('should work', () => {
expect(compareColumns.onCompare(testColumn, testColumn)).toEqual([]);
});
it('should detect a change in type', () => {
const source: DatabaseColumn = { ...testColumn };
const target: DatabaseColumn = { ...testColumn, type: 'text' };
const reason = 'column type is different (character varying vs text)';
expect(compareColumns.onCompare(source, target)).toEqual([
{
columnName: 'test',
tableName: 'table1',
type: 'column.drop',
reason,
},
{
type: 'column.add',
column: source,
reason,
},
]);
});
it('should detect a comment change', () => {
const source: DatabaseColumn = { ...testColumn, comment: 'new comment' };
const target: DatabaseColumn = { ...testColumn, comment: 'old comment' };
const reason = 'comment is different (new comment vs old comment)';
expect(compareColumns.onCompare(source, target)).toEqual([
{
columnName: 'test',
tableName: 'table1',
type: 'column.alter',
changes: {
comment: 'new comment',
},
reason,
},
]);
});
});
});

View File

@ -0,0 +1,82 @@
import { getColumnType, isDefaultEqual } from 'src/sql-tools/helpers';
import { Comparer, DatabaseColumn, Reason, SchemaDiff } from 'src/sql-tools/types';
export const compareColumns: Comparer<DatabaseColumn> = {
onMissing: (source) => [
{
type: 'column.add',
column: source,
reason: Reason.MissingInTarget,
},
],
onExtra: (target) => [
{
type: 'column.drop',
tableName: target.tableName,
columnName: target.name,
reason: Reason.MissingInSource,
},
],
onCompare: (source, target) => {
const sourceType = getColumnType(source);
const targetType = getColumnType(target);
const isTypeChanged = sourceType !== targetType;
if (isTypeChanged) {
// TODO: convert between types via UPDATE when possible
return dropAndRecreateColumn(source, target, `column type is different (${sourceType} vs ${targetType})`);
}
const items: SchemaDiff[] = [];
if (source.nullable !== target.nullable) {
items.push({
type: 'column.alter',
tableName: source.tableName,
columnName: source.name,
changes: {
nullable: source.nullable,
},
reason: `nullable is different (${source.nullable} vs ${target.nullable})`,
});
}
if (!isDefaultEqual(source, target)) {
items.push({
type: 'column.alter',
tableName: source.tableName,
columnName: source.name,
changes: {
default: String(source.default),
},
reason: `default is different (${source.default} vs ${target.default})`,
});
}
if (source.comment !== target.comment) {
items.push({
type: 'column.alter',
tableName: source.tableName,
columnName: source.name,
changes: {
comment: String(source.comment),
},
reason: `comment is different (${source.comment} vs ${target.comment})`,
});
}
return items;
},
};
const dropAndRecreateColumn = (source: DatabaseColumn, target: DatabaseColumn, reason: string): SchemaDiff[] => {
return [
{
type: 'column.drop',
tableName: target.tableName,
columnName: target.name,
reason,
},
{ type: 'column.add', column: source, reason },
];
};

View File

@ -0,0 +1,63 @@
import { compareConstraints } from 'src/sql-tools/diff/comparers/constraint.comparer';
import { DatabaseConstraint, DatabaseConstraintType, Reason } from 'src/sql-tools/types';
import { describe, expect, it } from 'vitest';
const testConstraint: DatabaseConstraint = {
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'test',
tableName: 'table1',
columnNames: ['column1'],
synchronize: true,
};
describe('compareConstraints', () => {
describe('onExtra', () => {
it('should work', () => {
expect(compareConstraints.onExtra(testConstraint)).toEqual([
{
type: 'constraint.drop',
constraintName: 'test',
tableName: 'table1',
reason: Reason.MissingInSource,
},
]);
});
});
describe('onMissing', () => {
it('should work', () => {
expect(compareConstraints.onMissing(testConstraint)).toEqual([
{
type: 'constraint.add',
constraint: testConstraint,
reason: Reason.MissingInTarget,
},
]);
});
});
describe('onCompare', () => {
it('should work', () => {
expect(compareConstraints.onCompare(testConstraint, testConstraint)).toEqual([]);
});
it('should detect a change in type', () => {
const source: DatabaseConstraint = { ...testConstraint };
const target: DatabaseConstraint = { ...testConstraint, columnNames: ['column1', 'column2'] };
const reason = 'Primary key columns are different: (column1 vs column1,column2)';
expect(compareConstraints.onCompare(source, target)).toEqual([
{
constraintName: 'test',
tableName: 'table1',
type: 'constraint.drop',
reason,
},
{
type: 'constraint.add',
constraint: source,
reason,
},
]);
});
});
});

View File

@ -0,0 +1,133 @@
import { haveEqualColumns } from 'src/sql-tools/helpers';
import {
CompareFunction,
Comparer,
DatabaseCheckConstraint,
DatabaseConstraint,
DatabaseConstraintType,
DatabaseForeignKeyConstraint,
DatabasePrimaryKeyConstraint,
DatabaseUniqueConstraint,
Reason,
SchemaDiff,
} from 'src/sql-tools/types';
export const compareConstraints: Comparer<DatabaseConstraint> = {
onMissing: (source) => [
{
type: 'constraint.add',
constraint: source,
reason: Reason.MissingInTarget,
},
],
onExtra: (target) => [
{
type: 'constraint.drop',
tableName: target.tableName,
constraintName: target.name,
reason: Reason.MissingInSource,
},
],
onCompare: (source, target) => {
switch (source.type) {
case DatabaseConstraintType.PRIMARY_KEY: {
return comparePrimaryKeyConstraint(source, target as DatabasePrimaryKeyConstraint);
}
case DatabaseConstraintType.FOREIGN_KEY: {
return compareForeignKeyConstraint(source, target as DatabaseForeignKeyConstraint);
}
case DatabaseConstraintType.UNIQUE: {
return compareUniqueConstraint(source, target as DatabaseUniqueConstraint);
}
case DatabaseConstraintType.CHECK: {
return compareCheckConstraint(source, target as DatabaseCheckConstraint);
}
default: {
return [];
}
}
},
};
const comparePrimaryKeyConstraint: CompareFunction<DatabasePrimaryKeyConstraint> = (source, target) => {
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
return dropAndRecreateConstraint(
source,
target,
`Primary key columns are different: (${source.columnNames} vs ${target.columnNames})`,
);
}
return [];
};
const compareForeignKeyConstraint: CompareFunction<DatabaseForeignKeyConstraint> = (source, target) => {
let reason = '';
const sourceDeleteAction = source.onDelete ?? 'NO ACTION';
const targetDeleteAction = target.onDelete ?? 'NO ACTION';
const sourceUpdateAction = source.onUpdate ?? 'NO ACTION';
const targetUpdateAction = target.onUpdate ?? 'NO ACTION';
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
reason = `columns are different (${source.columnNames} vs ${target.columnNames})`;
} else if (!haveEqualColumns(source.referenceColumnNames, target.referenceColumnNames)) {
reason = `reference columns are different (${source.referenceColumnNames} vs ${target.referenceColumnNames})`;
} else if (source.referenceTableName !== target.referenceTableName) {
reason = `reference table is different (${source.referenceTableName} vs ${target.referenceTableName})`;
} else if (sourceDeleteAction !== targetDeleteAction) {
reason = `ON DELETE action is different (${sourceDeleteAction} vs ${targetDeleteAction})`;
} else if (sourceUpdateAction !== targetUpdateAction) {
reason = `ON UPDATE action is different (${sourceUpdateAction} vs ${targetUpdateAction})`;
}
if (reason) {
return dropAndRecreateConstraint(source, target, reason);
}
return [];
};
const compareUniqueConstraint: CompareFunction<DatabaseUniqueConstraint> = (source, target) => {
let reason = '';
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
reason = `columns are different (${source.columnNames} vs ${target.columnNames})`;
}
if (reason) {
return dropAndRecreateConstraint(source, target, reason);
}
return [];
};
const compareCheckConstraint: CompareFunction<DatabaseCheckConstraint> = (source, target) => {
if (source.expression !== target.expression) {
// comparing expressions is hard because postgres reconstructs it with different formatting
// for now if the constraint exists with the same name, we will just skip it
}
return [];
};
const dropAndRecreateConstraint = (
source: DatabaseConstraint,
target: DatabaseConstraint,
reason: string,
): SchemaDiff[] => {
return [
{
type: 'constraint.drop',
tableName: target.tableName,
constraintName: target.name,
reason,
},
{ type: 'constraint.add', constraint: source, reason },
];
};

View File

@ -0,0 +1,54 @@
import { compareEnums } from 'src/sql-tools/diff/comparers/enum.comparer';
import { DatabaseEnum, Reason } from 'src/sql-tools/types';
import { describe, expect, it } from 'vitest';
const testEnum: DatabaseEnum = { name: 'test', values: ['foo', 'bar'], synchronize: true };
describe('compareEnums', () => {
describe('onExtra', () => {
it('should work', () => {
expect(compareEnums.onExtra(testEnum)).toEqual([
{
enumName: 'test',
type: 'enum.drop',
reason: Reason.MissingInSource,
},
]);
});
});
describe('onMissing', () => {
it('should work', () => {
expect(compareEnums.onMissing(testEnum)).toEqual([
{
type: 'enum.create',
enum: testEnum,
reason: Reason.MissingInTarget,
},
]);
});
});
describe('onCompare', () => {
it('should work', () => {
expect(compareEnums.onCompare(testEnum, testEnum)).toEqual([]);
});
it('should drop and recreate when values list is different', () => {
const source = { name: 'test', values: ['foo', 'bar'], synchronize: true };
const target = { name: 'test', values: ['foo', 'bar', 'world'], synchronize: true };
expect(compareEnums.onCompare(source, target)).toEqual([
{
enumName: 'test',
type: 'enum.drop',
reason: 'enum values has changed (foo,bar vs foo,bar,world)',
},
{
type: 'enum.create',
enum: source,
reason: 'enum values has changed (foo,bar vs foo,bar,world)',
},
]);
});
});
});

View File

@ -0,0 +1,38 @@
import { Comparer, DatabaseEnum, Reason } from 'src/sql-tools/types';
export const compareEnums: Comparer<DatabaseEnum> = {
onMissing: (source) => [
{
type: 'enum.create',
enum: source,
reason: Reason.MissingInTarget,
},
],
onExtra: (target) => [
{
type: 'enum.drop',
enumName: target.name,
reason: Reason.MissingInSource,
},
],
onCompare: (source, target) => {
if (source.values.toString() !== target.values.toString()) {
// TODO add or remove values if the lists are different or the order has changed
const reason = `enum values has changed (${source.values} vs ${target.values})`;
return [
{
type: 'enum.drop',
enumName: source.name,
reason,
},
{
type: 'enum.create',
enum: source,
reason,
},
];
}
return [];
},
};

View File

@ -0,0 +1,37 @@
import { compareExtensions } from 'src/sql-tools/diff/comparers/extension.comparer';
import { Reason } from 'src/sql-tools/types';
import { describe, expect, it } from 'vitest';
const testExtension = { name: 'test', synchronize: true };
describe('compareExtensions', () => {
describe('onExtra', () => {
it('should work', () => {
expect(compareExtensions.onExtra(testExtension)).toEqual([
{
extensionName: 'test',
type: 'extension.drop',
reason: Reason.MissingInSource,
},
]);
});
});
describe('onMissing', () => {
it('should work', () => {
expect(compareExtensions.onMissing(testExtension)).toEqual([
{
type: 'extension.create',
extension: testExtension,
reason: Reason.MissingInTarget,
},
]);
});
});
describe('onCompare', () => {
it('should work', () => {
expect(compareExtensions.onCompare(testExtension, testExtension)).toEqual([]);
});
});
});

View File

@ -0,0 +1,22 @@
import { Comparer, DatabaseExtension, Reason } from 'src/sql-tools/types';
export const compareExtensions: Comparer<DatabaseExtension> = {
onMissing: (source) => [
{
type: 'extension.create',
extension: source,
reason: Reason.MissingInTarget,
},
],
onExtra: (target) => [
{
type: 'extension.drop',
extensionName: target.name,
reason: Reason.MissingInSource,
},
],
onCompare: () => {
// if the name matches they are the same
return [];
},
};

View File

@ -0,0 +1,53 @@
import { compareFunctions } from 'src/sql-tools/diff/comparers/function.comparer';
import { DatabaseFunction, Reason } from 'src/sql-tools/types';
import { describe, expect, it } from 'vitest';
const testFunction: DatabaseFunction = {
name: 'test',
expression: 'CREATE FUNCTION something something something',
synchronize: true,
};
describe('compareFunctions', () => {
describe('onExtra', () => {
it('should work', () => {
expect(compareFunctions.onExtra(testFunction)).toEqual([
{
functionName: 'test',
type: 'function.drop',
reason: Reason.MissingInSource,
},
]);
});
});
describe('onMissing', () => {
it('should work', () => {
expect(compareFunctions.onMissing(testFunction)).toEqual([
{
type: 'function.create',
function: testFunction,
reason: Reason.MissingInTarget,
},
]);
});
});
describe('onCompare', () => {
it('should ignore functions with the same hash', () => {
expect(compareFunctions.onCompare(testFunction, testFunction)).toEqual([]);
});
it('should report differences if functions have different hashes', () => {
const source: DatabaseFunction = { ...testFunction, expression: 'SELECT 1' };
const target: DatabaseFunction = { ...testFunction, expression: 'SELECT 2' };
expect(compareFunctions.onCompare(source, target)).toEqual([
{
type: 'function.create',
reason: 'function expression has changed (SELECT 1 vs SELECT 2)',
function: source,
},
]);
});
});
});

View File

@ -0,0 +1,32 @@
import { Comparer, DatabaseFunction, Reason } from 'src/sql-tools/types';
export const compareFunctions: Comparer<DatabaseFunction> = {
onMissing: (source) => [
{
type: 'function.create',
function: source,
reason: Reason.MissingInTarget,
},
],
onExtra: (target) => [
{
type: 'function.drop',
functionName: target.name,
reason: Reason.MissingInSource,
},
],
onCompare: (source, target) => {
if (source.expression !== target.expression) {
const reason = `function expression has changed (${source.expression} vs ${target.expression})`;
return [
{
type: 'function.create',
function: source,
reason,
},
];
}
return [];
},
};

View File

@ -0,0 +1,72 @@
import { compareIndexes } from 'src/sql-tools/diff/comparers/index.comparer';
import { DatabaseIndex, Reason } from 'src/sql-tools/types';
import { describe, expect, it } from 'vitest';
const testIndex: DatabaseIndex = {
name: 'test',
tableName: 'table1',
columnNames: ['column1', 'column2'],
unique: false,
synchronize: true,
};
describe('compareIndexes', () => {
describe('onExtra', () => {
it('should work', () => {
expect(compareIndexes.onExtra(testIndex)).toEqual([
{
type: 'index.drop',
indexName: 'test',
reason: Reason.MissingInSource,
},
]);
});
});
describe('onMissing', () => {
it('should work', () => {
expect(compareIndexes.onMissing(testIndex)).toEqual([
{
type: 'index.create',
index: testIndex,
reason: Reason.MissingInTarget,
},
]);
});
});
describe('onCompare', () => {
it('should work', () => {
expect(compareIndexes.onCompare(testIndex, testIndex)).toEqual([]);
});
it('should drop and recreate when column list is different', () => {
const source = {
name: 'test',
tableName: 'table1',
columnNames: ['column1'],
unique: true,
synchronize: true,
};
const target = {
name: 'test',
tableName: 'table1',
columnNames: ['column1', 'column2'],
unique: true,
synchronize: true,
};
expect(compareIndexes.onCompare(source, target)).toEqual([
{
indexName: 'test',
type: 'index.drop',
reason: 'columns are different (column1 vs column1,column2)',
},
{
type: 'index.create',
index: source,
reason: 'columns are different (column1 vs column1,column2)',
},
]);
});
});
});

View File

@ -0,0 +1,46 @@
import { haveEqualColumns } from 'src/sql-tools/helpers';
import { Comparer, DatabaseIndex, Reason } from 'src/sql-tools/types';
export const compareIndexes: Comparer<DatabaseIndex> = {
onMissing: (source) => [
{
type: 'index.create',
index: source,
reason: Reason.MissingInTarget,
},
],
onExtra: (target) => [
{
type: 'index.drop',
indexName: target.name,
reason: Reason.MissingInSource,
},
],
onCompare: (source, target) => {
const sourceUsing = source.using ?? 'btree';
const targetUsing = target.using ?? 'btree';
let reason = '';
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
reason = `columns are different (${source.columnNames} vs ${target.columnNames})`;
} else if (source.unique !== target.unique) {
reason = `uniqueness is different (${source.unique} vs ${target.unique})`;
} else if (sourceUsing !== targetUsing) {
reason = `using method is different (${source.using} vs ${target.using})`;
} else if (source.where !== target.where) {
reason = `where clause is different (${source.where} vs ${target.where})`;
} else if (source.expression !== target.expression) {
reason = `expression is different (${source.expression} vs ${target.expression})`;
}
if (reason) {
return [
{ type: 'index.drop', indexName: target.name, reason },
{ type: 'index.create', index: source, reason },
];
}
return [];
},
};

View File

@ -0,0 +1,44 @@
import { compareParameters } from 'src/sql-tools/diff/comparers/parameter.comparer';
import { DatabaseParameter, Reason } from 'src/sql-tools/types';
import { describe, expect, it } from 'vitest';
const testParameter: DatabaseParameter = {
name: 'test',
databaseName: 'immich',
value: 'on',
scope: 'database',
synchronize: true,
};
describe('compareParameters', () => {
describe('onExtra', () => {
it('should work', () => {
expect(compareParameters.onExtra(testParameter)).toEqual([
{
type: 'parameter.reset',
databaseName: 'immich',
parameterName: 'test',
reason: Reason.MissingInSource,
},
]);
});
});
describe('onMissing', () => {
it('should work', () => {
expect(compareParameters.onMissing(testParameter)).toEqual([
{
type: 'parameter.set',
parameter: testParameter,
reason: Reason.MissingInTarget,
},
]);
});
});
describe('onCompare', () => {
it('should work', () => {
expect(compareParameters.onCompare(testParameter, testParameter)).toEqual([]);
});
});
});

View File

@ -0,0 +1,23 @@
import { Comparer, DatabaseParameter, Reason } from 'src/sql-tools/types';
export const compareParameters: Comparer<DatabaseParameter> = {
onMissing: (source) => [
{
type: 'parameter.set',
parameter: source,
reason: Reason.MissingInTarget,
},
],
onExtra: (target) => [
{
type: 'parameter.reset',
databaseName: target.databaseName,
parameterName: target.name,
reason: Reason.MissingInSource,
},
],
onCompare: () => {
// TODO
return [];
},
};

View File

@ -0,0 +1,44 @@
import { compareTables } from 'src/sql-tools/diff/comparers/table.comparer';
import { DatabaseTable, Reason } from 'src/sql-tools/types';
import { describe, expect, it } from 'vitest';
const testTable: DatabaseTable = {
name: 'test',
columns: [],
constraints: [],
indexes: [],
triggers: [],
synchronize: true,
};
describe('compareParameters', () => {
describe('onExtra', () => {
it('should work', () => {
expect(compareTables.onExtra(testTable)).toEqual([
{
type: 'table.drop',
tableName: 'test',
reason: Reason.MissingInSource,
},
]);
});
});
describe('onMissing', () => {
it('should work', () => {
expect(compareTables.onMissing(testTable)).toEqual([
{
type: 'table.create',
table: testTable,
reason: Reason.MissingInTarget,
},
]);
});
});
describe('onCompare', () => {
it('should work', () => {
expect(compareTables.onCompare(testTable, testTable)).toEqual([]);
});
});
});

View File

@ -0,0 +1,59 @@
import { compareColumns } from 'src/sql-tools/diff/comparers/column.comparer';
import { compareConstraints } from 'src/sql-tools/diff/comparers/constraint.comparer';
import { compareIndexes } from 'src/sql-tools/diff/comparers/index.comparer';
import { compareTriggers } from 'src/sql-tools/diff/comparers/trigger.comparer';
import { compare } from 'src/sql-tools/helpers';
import { Comparer, DatabaseTable, Reason, SchemaDiff } from 'src/sql-tools/types';
export const compareTables: Comparer<DatabaseTable> = {
onMissing: (source) => [
{
type: 'table.create',
table: source,
reason: Reason.MissingInTarget,
},
// TODO merge constraints into table create record when possible
...compareTable(
source,
{
name: source.name,
columns: [],
indexes: [],
constraints: [],
triggers: [],
synchronize: true,
},
{ columns: false },
),
],
onExtra: (target) => [
...compareTable(
{
name: target.name,
columns: [],
indexes: [],
constraints: [],
triggers: [],
synchronize: true,
},
target,
{ columns: false },
),
{
type: 'table.drop',
tableName: target.name,
reason: Reason.MissingInSource,
},
],
onCompare: (source, target) => compareTable(source, target, { columns: true }),
};
const compareTable = (source: DatabaseTable, target: DatabaseTable, options: { columns?: boolean }): SchemaDiff[] => {
return [
...(options.columns ? compare(source.columns, target.columns, {}, compareColumns) : []),
...compare(source.indexes, target.indexes, {}, compareIndexes),
...compare(source.constraints, target.constraints, {}, compareConstraints),
...compare(source.triggers, target.triggers, {}, compareTriggers),
];
};

View File

@ -0,0 +1,88 @@
import { compareTriggers } from 'src/sql-tools/diff/comparers/trigger.comparer';
import { DatabaseTrigger, Reason } from 'src/sql-tools/types';
import { describe, expect, it } from 'vitest';
const testTrigger: DatabaseTrigger = {
name: 'test',
tableName: 'table1',
timing: 'before',
actions: ['delete'],
scope: 'row',
functionName: 'my_trigger_function',
synchronize: true,
};
describe('compareTriggers', () => {
describe('onExtra', () => {
it('should work', () => {
expect(compareTriggers.onExtra(testTrigger)).toEqual([
{
type: 'trigger.drop',
tableName: 'table1',
triggerName: 'test',
reason: Reason.MissingInSource,
},
]);
});
});
describe('onMissing', () => {
it('should work', () => {
expect(compareTriggers.onMissing(testTrigger)).toEqual([
{
type: 'trigger.create',
trigger: testTrigger,
reason: Reason.MissingInTarget,
},
]);
});
});
describe('onCompare', () => {
it('should work', () => {
expect(compareTriggers.onCompare(testTrigger, testTrigger)).toEqual([]);
});
it('should detect a change in function name', () => {
const source: DatabaseTrigger = { ...testTrigger, functionName: 'my_new_name' };
const target: DatabaseTrigger = { ...testTrigger, functionName: 'my_old_name' };
const reason = `function is different (my_new_name vs my_old_name)`;
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
});
it('should detect a change in actions', () => {
const source: DatabaseTrigger = { ...testTrigger, actions: ['delete'] };
const target: DatabaseTrigger = { ...testTrigger, actions: ['delete', 'insert'] };
const reason = `action is different (delete vs delete,insert)`;
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
});
it('should detect a change in timing', () => {
const source: DatabaseTrigger = { ...testTrigger, timing: 'before' };
const target: DatabaseTrigger = { ...testTrigger, timing: 'after' };
const reason = `timing method is different (before vs after)`;
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
});
it('should detect a change in scope', () => {
const source: DatabaseTrigger = { ...testTrigger, scope: 'row' };
const target: DatabaseTrigger = { ...testTrigger, scope: 'statement' };
const reason = `scope is different (row vs statement)`;
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
});
it('should detect a change in new table reference', () => {
const source: DatabaseTrigger = { ...testTrigger, referencingNewTableAs: 'new_table' };
const target: DatabaseTrigger = { ...testTrigger, referencingNewTableAs: undefined };
const reason = `new table reference is different (new_table vs undefined)`;
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
});
it('should detect a change in old table reference', () => {
const source: DatabaseTrigger = { ...testTrigger, referencingOldTableAs: 'old_table' };
const target: DatabaseTrigger = { ...testTrigger, referencingOldTableAs: undefined };
const reason = `old table reference is different (old_table vs undefined)`;
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
});
});
});

View File

@ -0,0 +1,41 @@
import { Comparer, DatabaseTrigger, Reason } from 'src/sql-tools/types';
export const compareTriggers: Comparer<DatabaseTrigger> = {
onMissing: (source) => [
{
type: 'trigger.create',
trigger: source,
reason: Reason.MissingInTarget,
},
],
onExtra: (target) => [
{
type: 'trigger.drop',
tableName: target.tableName,
triggerName: target.name,
reason: Reason.MissingInSource,
},
],
onCompare: (source, target) => {
let reason = '';
if (source.functionName !== target.functionName) {
reason = `function is different (${source.functionName} vs ${target.functionName})`;
} else if (source.actions.join(' OR ') !== target.actions.join(' OR ')) {
reason = `action is different (${source.actions} vs ${target.actions})`;
} else if (source.timing !== target.timing) {
reason = `timing method is different (${source.timing} vs ${target.timing})`;
} else if (source.scope !== target.scope) {
reason = `scope is different (${source.scope} vs ${target.scope})`;
} else if (source.referencingNewTableAs !== target.referencingNewTableAs) {
reason = `new table reference is different (${source.referencingNewTableAs} vs ${target.referencingNewTableAs})`;
} else if (source.referencingOldTableAs !== target.referencingOldTableAs) {
reason = `old table reference is different (${source.referencingOldTableAs} vs ${target.referencingOldTableAs})`;
}
if (reason) {
return [{ type: 'trigger.create', trigger: source, reason }];
}
return [];
},
};

View File

@ -1,8 +1,8 @@
import { schemaDiff } from 'src/sql-tools/schema-diff';
import { schemaDiff } from 'src/sql-tools/diff';
import {
ColumnType,
DatabaseActionType,
DatabaseColumn,
DatabaseColumnType,
DatabaseConstraint,
DatabaseConstraintType,
DatabaseIndex,
@ -15,7 +15,12 @@ const fromColumn = (column: Partial<Omit<DatabaseColumn, 'tableName'>>): Databas
const tableName = 'table1';
return {
name: 'public',
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: tableName,
@ -31,6 +36,7 @@ const fromColumn = (column: Partial<Omit<DatabaseColumn, 'tableName'>>): Databas
},
],
indexes: [],
triggers: [],
constraints: [],
synchronize: true,
},
@ -43,7 +49,12 @@ const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => {
const tableName = constraint?.tableName || 'table1';
return {
name: 'public',
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: tableName,
@ -58,6 +69,7 @@ const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => {
},
],
indexes: [],
triggers: [],
constraints: constraint ? [constraint] : [],
synchronize: true,
},
@ -70,7 +82,12 @@ const fromIndex = (index?: DatabaseIndex): DatabaseSchema => {
const tableName = index?.tableName || 'table1';
return {
name: 'public',
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: tableName,
@ -86,6 +103,7 @@ const fromIndex = (index?: DatabaseIndex): DatabaseSchema => {
],
indexes: index ? [index] : [],
constraints: [],
triggers: [],
synchronize: true,
},
],
@ -99,7 +117,7 @@ const newSchema = (schema: {
name: string;
columns?: Array<{
name: string;
type?: DatabaseColumnType;
type?: ColumnType;
nullable?: boolean;
isArray?: boolean;
}>;
@ -131,12 +149,18 @@ const newSchema = (schema: {
columns,
indexes: table.indexes ?? [],
constraints: table.constraints ?? [],
triggers: [],
synchronize: true,
});
}
return {
name: schema?.name || 'public',
name: 'immich',
schemaName: schema?.name || 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables,
warnings: [],
};
@ -167,8 +191,14 @@ describe('schemaDiff', () => {
expect(diff.items).toHaveLength(1);
expect(diff.items[0]).toEqual({
type: 'table.create',
tableName: 'table1',
columns: [column],
table: {
name: 'table1',
columns: [column],
constraints: [],
indexes: [],
triggers: [],
synchronize: true,
},
reason: 'missing in target',
});
});
@ -181,7 +211,7 @@ describe('schemaDiff', () => {
newSchema({
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
}),
{ ignoreExtraTables: false },
{ tables: { ignoreExtra: false } },
);
expect(diff.items).toHaveLength(1);

View File

@ -0,0 +1,85 @@
import { compareEnums } from 'src/sql-tools/diff/comparers/enum.comparer';
import { compareExtensions } from 'src/sql-tools/diff/comparers/extension.comparer';
import { compareFunctions } from 'src/sql-tools/diff/comparers/function.comparer';
import { compareParameters } from 'src/sql-tools/diff/comparers/parameter.comparer';
import { compareTables } from 'src/sql-tools/diff/comparers/table.comparer';
import { compare } from 'src/sql-tools/helpers';
import { schemaDiffToSql } from 'src/sql-tools/to-sql';
import {
DatabaseConstraintType,
DatabaseSchema,
SchemaDiff,
SchemaDiffOptions,
SchemaDiffToSqlOptions,
} from 'src/sql-tools/types';
/**
* Compute the difference between two database schemas
*/
export const schemaDiff = (source: DatabaseSchema, target: DatabaseSchema, options: SchemaDiffOptions = {}) => {
const items = [
...compare(source.parameters, target.parameters, options.parameters, compareParameters),
...compare(source.extensions, target.extensions, options.extension, compareExtensions),
...compare(source.functions, target.functions, options.functions, compareFunctions),
...compare(source.enums, target.enums, options.enums, compareEnums),
...compare(source.tables, target.tables, options.tables, compareTables),
];
type SchemaName = SchemaDiff['type'];
const itemMap: Record<SchemaName, SchemaDiff[]> = {
'enum.create': [],
'enum.drop': [],
'extension.create': [],
'extension.drop': [],
'function.create': [],
'function.drop': [],
'table.create': [],
'table.drop': [],
'column.add': [],
'column.alter': [],
'column.drop': [],
'constraint.add': [],
'constraint.drop': [],
'index.create': [],
'index.drop': [],
'trigger.create': [],
'trigger.drop': [],
'parameter.set': [],
'parameter.reset': [],
};
for (const item of items) {
itemMap[item.type].push(item);
}
const constraintAdds = itemMap['constraint.add'].filter((item) => item.type === 'constraint.add');
const orderedItems = [
...itemMap['extension.create'],
...itemMap['function.create'],
...itemMap['parameter.set'],
...itemMap['parameter.reset'],
...itemMap['enum.create'],
...itemMap['trigger.drop'],
...itemMap['index.drop'],
...itemMap['constraint.drop'],
...itemMap['table.create'],
...itemMap['column.alter'],
...itemMap['column.add'],
...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.PRIMARY_KEY),
...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.FOREIGN_KEY),
...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.UNIQUE),
...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.CHECK),
...itemMap['index.create'],
...itemMap['trigger.create'],
...itemMap['column.drop'],
...itemMap['table.drop'],
...itemMap['enum.drop'],
...itemMap['function.drop'],
];
return {
items: orderedItems,
asSql: (options?: SchemaDiffToSqlOptions) => schemaDiffToSql(orderedItems, options),
};
};

View File

@ -0,0 +1,8 @@
import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/from-code/decorators/trigger-function.decorator';
export const AfterDeleteTrigger = (options: Omit<TriggerFunctionOptions, 'timing' | 'actions'>) =>
TriggerFunction({
timing: 'after',
actions: ['delete'],
...options,
});

View File

@ -0,0 +1,8 @@
import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/from-code/decorators/trigger-function.decorator';
export const BeforeUpdateTrigger = (options: Omit<TriggerFunctionOptions, 'timing' | 'actions'>) =>
TriggerFunction({
timing: 'before',
actions: ['update'],
...options,
});

View File

@ -0,0 +1,11 @@
import { register } from 'src/sql-tools/from-code/register';
export type CheckOptions = {
name?: string;
expression: string;
synchronize?: boolean;
};
export const Check = (options: CheckOptions): ClassDecorator => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
return (object: Function) => void register({ type: 'checkConstraint', item: { object, options } });
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,15 @@
import { register } from 'src/sql-tools/from-code/register';
import { asOptions } from 'src/sql-tools/helpers';
export type ExtensionsOptions = {
name: string;
synchronize?: boolean;
};
export const Extensions = (options: Array<string | ExtensionsOptions>): ClassDecorator => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
return (object: Function) => {
for (const option of options) {
register({ type: 'extension', item: { object, options: asOptions(option) } });
}
};
};

View File

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

View File

@ -0,0 +1,37 @@
import { Column, ColumnOptions, ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator';
import { ColumnType } from 'src/sql-tools/types';
export type GeneratedColumnStrategy = 'uuid' | 'identity';
export type GenerateColumnOptions = Omit<ColumnOptions, 'type'> & {
strategy?: GeneratedColumnStrategy;
};
export const GeneratedColumn = ({ strategy = 'uuid', ...options }: GenerateColumnOptions): PropertyDecorator => {
let columnType: ColumnType | undefined;
let columnDefault: ColumnValue | undefined;
switch (strategy) {
case 'uuid': {
columnType = 'uuid';
columnDefault = () => 'uuid_generate_v4()';
break;
}
case 'identity': {
columnType = 'integer';
options.identity = true;
break;
}
default: {
throw new Error(`Unsupported strategy for @GeneratedColumn ${strategy}`);
}
}
return Column({
type: columnType,
default: columnDefault,
...options,
});
};

View File

@ -0,0 +1,12 @@
import { ColumnIndexOptions } from 'src/sql-tools/from-code/decorators/column-index.decorator';
import { register } from 'src/sql-tools/from-code/register';
import { asOptions } from 'src/sql-tools/helpers';
export type IndexOptions = ColumnIndexOptions & {
columns?: string[];
synchronize?: boolean;
};
export const Index = (options: string | IndexOptions = {}): ClassDecorator => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
return (object: Function) => void register({ type: 'index', item: { object, options: asOptions(options) } });
};

View File

@ -0,0 +1,3 @@
import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
export const PrimaryColumn = (options: Omit<ColumnOptions, 'primary'> = {}) => Column({ ...options, primary: true });

View File

@ -0,0 +1,4 @@
import { GenerateColumnOptions, GeneratedColumn } from 'src/sql-tools/from-code/decorators/generated-column.decorator';
export const PrimaryGeneratedColumn = (options: Omit<GenerateColumnOptions, 'primary'> = {}) =>
GeneratedColumn({ ...options, primary: true });

View File

@ -0,0 +1,14 @@
import { register } from 'src/sql-tools/from-code/register';
import { asOptions } from 'src/sql-tools/helpers';
export type TableOptions = {
name?: string;
primaryConstraintName?: string;
synchronize?: boolean;
};
/** Table comments here */
export const Table = (options: string | TableOptions = {}): ClassDecorator => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
return (object: Function) => void register({ type: 'table', item: { object, options: asOptions(options) } });
};

View File

@ -0,0 +1,6 @@
import { Trigger, TriggerOptions } from 'src/sql-tools/from-code/decorators/trigger.decorator';
import { DatabaseFunction } from 'src/sql-tools/types';
export type TriggerFunctionOptions = Omit<TriggerOptions, 'functionName'> & { function: DatabaseFunction };
export const TriggerFunction = (options: TriggerFunctionOptions) =>
Trigger({ ...options, functionName: options.function.name });

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,69 @@
import 'reflect-metadata';
import { processCheckConstraints } from 'src/sql-tools/from-code/processors/check-constraint.processor';
import { processColumnIndexes } from 'src/sql-tools/from-code/processors/column-index.processor';
import { processColumns } from 'src/sql-tools/from-code/processors/column.processor';
import { processConfigurationParameters } from 'src/sql-tools/from-code/processors/configuration-parameter.processor';
import { processDatabases } from 'src/sql-tools/from-code/processors/database.processor';
import { processEnums } from 'src/sql-tools/from-code/processors/enum.processor';
import { processExtensions } from 'src/sql-tools/from-code/processors/extension.processor';
import { processForeignKeyConstraints } from 'src/sql-tools/from-code/processors/foreign-key-constriant.processor';
import { processFunctions } from 'src/sql-tools/from-code/processors/function.processor';
import { processIndexes } from 'src/sql-tools/from-code/processors/index.processor';
import { processPrimaryKeyConstraints } from 'src/sql-tools/from-code/processors/primary-key-contraint.processor';
import { processTables } from 'src/sql-tools/from-code/processors/table.processor';
import { processTriggers } from 'src/sql-tools/from-code/processors/trigger.processor';
import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
import { processUniqueConstraints } from 'src/sql-tools/from-code/processors/unique-constraint.processor';
import { getRegisteredItems, resetRegisteredItems } from 'src/sql-tools/from-code/register';
import { DatabaseSchema } from 'src/sql-tools/types';
let initialized = false;
let schema: DatabaseSchema;
export const reset = () => {
initialized = false;
resetRegisteredItems();
};
const processors: Processor[] = [
processDatabases,
processConfigurationParameters,
processEnums,
processExtensions,
processFunctions,
processTables,
processColumns,
processUniqueConstraints,
processCheckConstraints,
processPrimaryKeyConstraints,
processIndexes,
processColumnIndexes,
processForeignKeyConstraints,
processTriggers,
];
export const schemaFromCode = () => {
if (!initialized) {
const builder: SchemaBuilder = {
name: 'postgres',
schemaName: 'public',
tables: [],
functions: [],
enums: [],
extensions: [],
parameters: [],
warnings: [],
};
const items = getRegisteredItems();
for (const processor of processors) {
processor(builder, items);
}
schema = { ...builder, tables: builder.tables.map(({ metadata: _, ...table }) => table) };
initialized = true;
}
return schema;
};

View File

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

View File

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

View File

@ -0,0 +1,103 @@
import { ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
import { asMetadataKey, asUniqueConstraintName, fromColumnValue } from 'src/sql-tools/helpers';
import { DatabaseColumn, DatabaseConstraintType } from 'src/sql-tools/types';
export const processColumns: Processor = (builder, items) => {
for (const {
type,
item: { object, propertyName, options },
} of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) {
const table = resolveTable(builder, object.constructor);
if (!table) {
onMissingTable(builder, type === 'column' ? '@Column' : '@ForeignKeyColumn', object, propertyName);
continue;
}
const columnName = options.name ?? String(propertyName);
const existingColumn = table.columns.find((column) => column.name === columnName);
if (existingColumn) {
// TODO log warnings if column name is not unique
continue;
}
const tableName = table.name;
let defaultValue = fromColumnValue(options.default);
let nullable = options.nullable ?? false;
// map `{ default: null }` to `{ nullable: true }`
if (defaultValue === null) {
nullable = true;
defaultValue = undefined;
}
const isEnum = !!(options as ColumnOptions).enum;
const column: DatabaseColumn = {
name: columnName,
tableName,
primary: options.primary ?? false,
default: defaultValue,
nullable,
isArray: (options as ColumnOptions).array ?? false,
length: options.length,
type: isEnum ? 'enum' : options.type || 'character varying',
enumName: isEnum ? (options as ColumnOptions).enum!.name : undefined,
comment: options.comment,
storage: options.storage,
identity: options.identity,
synchronize: options.synchronize ?? true,
};
writeMetadata(object, propertyName, { name: column.name, options });
table.columns.push(column);
if (type === 'column' && !options.primary && options.unique) {
table.constraints.push({
type: DatabaseConstraintType.UNIQUE,
name: options.uniqueConstraintName || asUniqueConstraintName(table.name, [column.name]),
tableName: table.name,
columnNames: [column.name],
synchronize: options.synchronize ?? true,
});
}
}
};
type ColumnMetadata = { name: string; options: ColumnOptions };
export const resolveColumn = (builder: SchemaBuilder, object: object, propertyName: string | symbol) => {
const table = resolveTable(builder, object.constructor);
if (!table) {
return {};
}
const metadata = readMetadata(object, propertyName);
if (!metadata) {
return { table };
}
const column = table.columns.find((column) => column.name === metadata.name);
return { table, column };
};
export const onMissingColumn = (
builder: SchemaBuilder,
context: string,
object: object,
propertyName?: symbol | string,
) => {
const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : '');
builder.warnings.push(`[${context}] Unable to find column (${label})`);
};
const METADATA_KEY = asMetadataKey('table-metadata');
const writeMetadata = (object: object, propertyName: symbol | string, metadata: ColumnMetadata) =>
Reflect.defineMetadata(METADATA_KEY, metadata, object, propertyName);
const readMetadata = (object: object, propertyName: symbol | string): ColumnMetadata | undefined =>
Reflect.getMetadata(METADATA_KEY, object, propertyName);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,27 @@
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
import { Processor } from 'src/sql-tools/from-code/processors/type';
import { asIndexName } from 'src/sql-tools/helpers';
export const processIndexes: Processor = (builder, items) => {
for (const {
item: { object, options },
} of items.filter((item) => item.type === 'index')) {
const table = resolveTable(builder, object);
if (!table) {
onMissingTable(builder, '@Check', object);
continue;
}
table.indexes.push({
name: options.name || asIndexName(table.name, options.columns, options.where),
tableName: table.name,
unique: options.unique ?? false,
expression: options.expression,
using: options.using,
with: options.with,
where: options.where,
columnNames: options.columns,
synchronize: options.synchronize ?? true,
});
}
};

View File

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

View File

@ -0,0 +1,51 @@
import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator';
import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
import { asMetadataKey, asSnakeCase } from 'src/sql-tools/helpers';
export const processTables: Processor = (builder, items) => {
for (const {
item: { options, object },
} of items.filter((item) => item.type === 'table')) {
const tableName = options.name || asSnakeCase(object.name);
writeMetadata(object, { name: tableName, options });
builder.tables.push({
name: tableName,
columns: [],
constraints: [],
indexes: [],
triggers: [],
synchronize: options.synchronize ?? true,
metadata: { options, object },
});
}
};
export const resolveTable = (builder: SchemaBuilder, object: object) => {
const metadata = readMetadata(object);
if (!metadata) {
return;
}
return builder.tables.find((table) => table.name === metadata.name);
};
export const onMissingTable = (
builder: SchemaBuilder,
context: string,
object: object,
propertyName?: symbol | string,
) => {
const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : '');
builder.warnings.push(`[${context}] Unable to find table (${label})`);
};
const METADATA_KEY = asMetadataKey('table-metadata');
type TableMetadata = { name: string; options: TableOptions };
const readMetadata = (object: object): TableMetadata | undefined => Reflect.getMetadata(METADATA_KEY, object);
const writeMetadata = (object: object, metadata: TableMetadata): void =>
Reflect.defineMetadata(METADATA_KEY, metadata, object);

View File

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

View File

@ -0,0 +1,9 @@
import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator';
import { RegisterItem } from 'src/sql-tools/from-code/register-item';
import { DatabaseSchema, DatabaseTable } from 'src/sql-tools/types';
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export type TableWithMetadata = DatabaseTable & { metadata: { options: TableOptions; object: Function } };
export type SchemaBuilder = Omit<DatabaseSchema, 'tables'> & { tables: TableWithMetadata[] };
export type Processor = (builder: SchemaBuilder, items: RegisterItem[]) => void;

View File

@ -0,0 +1,27 @@
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
import { Processor } from 'src/sql-tools/from-code/processors/type';
import { 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,
});
}
};

View File

@ -0,0 +1,20 @@
import { register } from 'src/sql-tools/from-code/register';
import { DatabaseEnum } from 'src/sql-tools/types';
export type EnumOptions = {
name: string;
values: string[];
synchronize?: boolean;
};
export const registerEnum = (options: EnumOptions) => {
const item: DatabaseEnum = {
name: options.name,
values: options.values,
synchronize: options.synchronize ?? true,
};
register({ type: 'enum', item });
return item;
};

View File

@ -0,0 +1,29 @@
import { register } from 'src/sql-tools/from-code/register';
import { asFunctionExpression } from 'src/sql-tools/helpers';
import { ColumnType, DatabaseFunction } from 'src/sql-tools/types';
export type FunctionOptions = {
name: string;
arguments?: string[];
returnType: ColumnType | string;
language?: 'SQL' | 'PLPGSQL';
behavior?: 'immutable' | 'stable' | 'volatile';
parallel?: 'safe' | 'unsafe' | 'restricted';
strict?: boolean;
synchronize?: boolean;
} & ({ body: string } | { return: string });
export const registerFunction = (options: FunctionOptions) => {
const name = options.name;
const expression = asFunctionExpression(options);
const item: DatabaseFunction = {
name,
expression,
synchronize: options.synchronize ?? true,
};
register({ type: 'function', item });
return item;
};

Some files were not shown because too many files have changed in this diff Show More