mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
feat: extension, triggers, functions, comments, parameters management in sql-tools (#17269)
feat: sql-tools extension, triggers, functions, comments, parameters
This commit is contained in:
parent
51c2c60231
commit
e7a5b96ed0
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@ -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: |
|
||||
|
@ -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",
|
||||
|
@ -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 };
|
||||
};
|
||||
|
@ -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.
|
||||
|
12
server/src/migrations/1743595393000-TableCleanup.ts
Normal file
12
server/src/migrations/1743595393000-TableCleanup.ts
Normal 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> {}
|
||||
}
|
@ -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> {
|
||||
|
@ -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 });
|
||||
}
|
||||
|
12
server/src/schema/enums.ts
Normal file
12
server/src/schema/enums.ts
Normal 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),
|
||||
});
|
116
server/src/schema/functions.ts
Normal file
116
server/src/schema/functions.ts
Normal 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
109
server/src/schema/index.ts
Normal 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];
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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')
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 })
|
||||
|
@ -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')
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -1,107 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
|
||||
import { register } from 'src/sql-tools/schema-from-decorators';
|
||||
import {
|
||||
CheckOptions,
|
||||
ColumnDefaultValue,
|
||||
ColumnIndexOptions,
|
||||
ColumnOptions,
|
||||
ForeignKeyColumnOptions,
|
||||
GenerateColumnOptions,
|
||||
IndexOptions,
|
||||
TableOptions,
|
||||
UniqueOptions,
|
||||
} from 'src/sql-tools/types';
|
||||
|
||||
export const Table = (options: string | TableOptions = {}): ClassDecorator => {
|
||||
return (object: Function) => void register({ type: 'table', item: { object, options: asOptions(options) } });
|
||||
};
|
||||
|
||||
export const Column = (options: string | ColumnOptions = {}): PropertyDecorator => {
|
||||
return (object: object, propertyName: string | symbol) =>
|
||||
void register({ type: 'column', item: { object, propertyName, options: asOptions(options) } });
|
||||
};
|
||||
|
||||
export const Index = (options: string | IndexOptions = {}): ClassDecorator => {
|
||||
return (object: Function) => void register({ type: 'index', item: { object, options: asOptions(options) } });
|
||||
};
|
||||
|
||||
export const Unique = (options: UniqueOptions): ClassDecorator => {
|
||||
return (object: Function) => void register({ type: 'uniqueConstraint', item: { object, options } });
|
||||
};
|
||||
|
||||
export const Check = (options: CheckOptions): ClassDecorator => {
|
||||
return (object: Function) => void register({ type: 'checkConstraint', item: { object, options } });
|
||||
};
|
||||
|
||||
export const ColumnIndex = (options: string | ColumnIndexOptions = {}): PropertyDecorator => {
|
||||
return (object: object, propertyName: string | symbol) =>
|
||||
void register({ type: 'columnIndex', item: { object, propertyName, options: asOptions(options) } });
|
||||
};
|
||||
|
||||
export const ForeignKeyColumn = (target: () => object, options: ForeignKeyColumnOptions): PropertyDecorator => {
|
||||
return (object: object, propertyName: string | symbol) => {
|
||||
register({ type: 'foreignKeyColumn', item: { object, propertyName, options, target } });
|
||||
};
|
||||
};
|
||||
|
||||
export const CreateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
|
||||
return Column({
|
||||
type: 'timestamp with time zone',
|
||||
default: () => 'now()',
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const UpdateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
|
||||
return Column({
|
||||
type: 'timestamp with time zone',
|
||||
default: () => 'now()',
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const DeleteDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
|
||||
return Column({
|
||||
type: 'timestamp with time zone',
|
||||
nullable: true,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const PrimaryGeneratedColumn = (options: Omit<GenerateColumnOptions, 'primary'> = {}) =>
|
||||
GeneratedColumn({ type: 'v4', ...options, primary: true });
|
||||
|
||||
export const PrimaryColumn = (options: Omit<ColumnOptions, 'primary'> = {}) => Column({ ...options, primary: true });
|
||||
|
||||
export const GeneratedColumn = ({ type = 'v4', ...options }: GenerateColumnOptions): PropertyDecorator => {
|
||||
const columnType = type === 'v4' || type === 'v7' ? 'uuid' : type;
|
||||
|
||||
let columnDefault: ColumnDefaultValue | undefined;
|
||||
switch (type) {
|
||||
case 'v4': {
|
||||
columnDefault = () => 'uuid_generate_v4()';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'v7': {
|
||||
columnDefault = () => 'immich_uuid_v7()';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Column({
|
||||
type: columnType,
|
||||
default: columnDefault,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const UpdateIdColumn = () => GeneratedColumn({ type: 'v7', nullable: false });
|
||||
|
||||
const asOptions = <T extends { name?: string }>(options: string | T): T => {
|
||||
if (typeof options === 'string') {
|
||||
return { name: options } as T;
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
81
server/src/sql-tools/diff/comparers/column.comparer.spec.ts
Normal file
81
server/src/sql-tools/diff/comparers/column.comparer.spec.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { compareColumns } from 'src/sql-tools/diff/comparers/column.comparer';
|
||||
import { DatabaseColumn, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testColumn: DatabaseColumn = {
|
||||
name: 'test',
|
||||
tableName: 'table1',
|
||||
nullable: false,
|
||||
isArray: false,
|
||||
type: 'character varying',
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareColumns', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareColumns.onExtra(testColumn)).toEqual([
|
||||
{
|
||||
tableName: 'table1',
|
||||
columnName: 'test',
|
||||
type: 'column.drop',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareColumns.onMissing(testColumn)).toEqual([
|
||||
{
|
||||
type: 'column.add',
|
||||
column: testColumn,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareColumns.onCompare(testColumn, testColumn)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect a change in type', () => {
|
||||
const source: DatabaseColumn = { ...testColumn };
|
||||
const target: DatabaseColumn = { ...testColumn, type: 'text' };
|
||||
const reason = 'column type is different (character varying vs text)';
|
||||
expect(compareColumns.onCompare(source, target)).toEqual([
|
||||
{
|
||||
columnName: 'test',
|
||||
tableName: 'table1',
|
||||
type: 'column.drop',
|
||||
reason,
|
||||
},
|
||||
{
|
||||
type: 'column.add',
|
||||
column: source,
|
||||
reason,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should detect a comment change', () => {
|
||||
const source: DatabaseColumn = { ...testColumn, comment: 'new comment' };
|
||||
const target: DatabaseColumn = { ...testColumn, comment: 'old comment' };
|
||||
const reason = 'comment is different (new comment vs old comment)';
|
||||
expect(compareColumns.onCompare(source, target)).toEqual([
|
||||
{
|
||||
columnName: 'test',
|
||||
tableName: 'table1',
|
||||
type: 'column.alter',
|
||||
changes: {
|
||||
comment: 'new comment',
|
||||
},
|
||||
reason,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
82
server/src/sql-tools/diff/comparers/column.comparer.ts
Normal file
82
server/src/sql-tools/diff/comparers/column.comparer.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { getColumnType, isDefaultEqual } from 'src/sql-tools/helpers';
|
||||
import { Comparer, DatabaseColumn, Reason, SchemaDiff } from 'src/sql-tools/types';
|
||||
|
||||
export const compareColumns: Comparer<DatabaseColumn> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'column.add',
|
||||
column: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'column.drop',
|
||||
tableName: target.tableName,
|
||||
columnName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => {
|
||||
const sourceType = getColumnType(source);
|
||||
const targetType = getColumnType(target);
|
||||
|
||||
const isTypeChanged = sourceType !== targetType;
|
||||
|
||||
if (isTypeChanged) {
|
||||
// TODO: convert between types via UPDATE when possible
|
||||
return dropAndRecreateColumn(source, target, `column type is different (${sourceType} vs ${targetType})`);
|
||||
}
|
||||
|
||||
const items: SchemaDiff[] = [];
|
||||
if (source.nullable !== target.nullable) {
|
||||
items.push({
|
||||
type: 'column.alter',
|
||||
tableName: source.tableName,
|
||||
columnName: source.name,
|
||||
changes: {
|
||||
nullable: source.nullable,
|
||||
},
|
||||
reason: `nullable is different (${source.nullable} vs ${target.nullable})`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isDefaultEqual(source, target)) {
|
||||
items.push({
|
||||
type: 'column.alter',
|
||||
tableName: source.tableName,
|
||||
columnName: source.name,
|
||||
changes: {
|
||||
default: String(source.default),
|
||||
},
|
||||
reason: `default is different (${source.default} vs ${target.default})`,
|
||||
});
|
||||
}
|
||||
|
||||
if (source.comment !== target.comment) {
|
||||
items.push({
|
||||
type: 'column.alter',
|
||||
tableName: source.tableName,
|
||||
columnName: source.name,
|
||||
changes: {
|
||||
comment: String(source.comment),
|
||||
},
|
||||
reason: `comment is different (${source.comment} vs ${target.comment})`,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
};
|
||||
|
||||
const dropAndRecreateColumn = (source: DatabaseColumn, target: DatabaseColumn, reason: string): SchemaDiff[] => {
|
||||
return [
|
||||
{
|
||||
type: 'column.drop',
|
||||
tableName: target.tableName,
|
||||
columnName: target.name,
|
||||
reason,
|
||||
},
|
||||
{ type: 'column.add', column: source, reason },
|
||||
];
|
||||
};
|
@ -0,0 +1,63 @@
|
||||
import { compareConstraints } from 'src/sql-tools/diff/comparers/constraint.comparer';
|
||||
import { DatabaseConstraint, DatabaseConstraintType, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testConstraint: DatabaseConstraint = {
|
||||
type: DatabaseConstraintType.PRIMARY_KEY,
|
||||
name: 'test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['column1'],
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareConstraints', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareConstraints.onExtra(testConstraint)).toEqual([
|
||||
{
|
||||
type: 'constraint.drop',
|
||||
constraintName: 'test',
|
||||
tableName: 'table1',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareConstraints.onMissing(testConstraint)).toEqual([
|
||||
{
|
||||
type: 'constraint.add',
|
||||
constraint: testConstraint,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareConstraints.onCompare(testConstraint, testConstraint)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect a change in type', () => {
|
||||
const source: DatabaseConstraint = { ...testConstraint };
|
||||
const target: DatabaseConstraint = { ...testConstraint, columnNames: ['column1', 'column2'] };
|
||||
const reason = 'Primary key columns are different: (column1 vs column1,column2)';
|
||||
expect(compareConstraints.onCompare(source, target)).toEqual([
|
||||
{
|
||||
constraintName: 'test',
|
||||
tableName: 'table1',
|
||||
type: 'constraint.drop',
|
||||
reason,
|
||||
},
|
||||
{
|
||||
type: 'constraint.add',
|
||||
constraint: source,
|
||||
reason,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
133
server/src/sql-tools/diff/comparers/constraint.comparer.ts
Normal file
133
server/src/sql-tools/diff/comparers/constraint.comparer.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { haveEqualColumns } from 'src/sql-tools/helpers';
|
||||
import {
|
||||
CompareFunction,
|
||||
Comparer,
|
||||
DatabaseCheckConstraint,
|
||||
DatabaseConstraint,
|
||||
DatabaseConstraintType,
|
||||
DatabaseForeignKeyConstraint,
|
||||
DatabasePrimaryKeyConstraint,
|
||||
DatabaseUniqueConstraint,
|
||||
Reason,
|
||||
SchemaDiff,
|
||||
} from 'src/sql-tools/types';
|
||||
|
||||
export const compareConstraints: Comparer<DatabaseConstraint> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'constraint.add',
|
||||
constraint: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'constraint.drop',
|
||||
tableName: target.tableName,
|
||||
constraintName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => {
|
||||
switch (source.type) {
|
||||
case DatabaseConstraintType.PRIMARY_KEY: {
|
||||
return comparePrimaryKeyConstraint(source, target as DatabasePrimaryKeyConstraint);
|
||||
}
|
||||
|
||||
case DatabaseConstraintType.FOREIGN_KEY: {
|
||||
return compareForeignKeyConstraint(source, target as DatabaseForeignKeyConstraint);
|
||||
}
|
||||
|
||||
case DatabaseConstraintType.UNIQUE: {
|
||||
return compareUniqueConstraint(source, target as DatabaseUniqueConstraint);
|
||||
}
|
||||
|
||||
case DatabaseConstraintType.CHECK: {
|
||||
return compareCheckConstraint(source, target as DatabaseCheckConstraint);
|
||||
}
|
||||
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const comparePrimaryKeyConstraint: CompareFunction<DatabasePrimaryKeyConstraint> = (source, target) => {
|
||||
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
|
||||
return dropAndRecreateConstraint(
|
||||
source,
|
||||
target,
|
||||
`Primary key columns are different: (${source.columnNames} vs ${target.columnNames})`,
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const compareForeignKeyConstraint: CompareFunction<DatabaseForeignKeyConstraint> = (source, target) => {
|
||||
let reason = '';
|
||||
|
||||
const sourceDeleteAction = source.onDelete ?? 'NO ACTION';
|
||||
const targetDeleteAction = target.onDelete ?? 'NO ACTION';
|
||||
|
||||
const sourceUpdateAction = source.onUpdate ?? 'NO ACTION';
|
||||
const targetUpdateAction = target.onUpdate ?? 'NO ACTION';
|
||||
|
||||
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
|
||||
reason = `columns are different (${source.columnNames} vs ${target.columnNames})`;
|
||||
} else if (!haveEqualColumns(source.referenceColumnNames, target.referenceColumnNames)) {
|
||||
reason = `reference columns are different (${source.referenceColumnNames} vs ${target.referenceColumnNames})`;
|
||||
} else if (source.referenceTableName !== target.referenceTableName) {
|
||||
reason = `reference table is different (${source.referenceTableName} vs ${target.referenceTableName})`;
|
||||
} else if (sourceDeleteAction !== targetDeleteAction) {
|
||||
reason = `ON DELETE action is different (${sourceDeleteAction} vs ${targetDeleteAction})`;
|
||||
} else if (sourceUpdateAction !== targetUpdateAction) {
|
||||
reason = `ON UPDATE action is different (${sourceUpdateAction} vs ${targetUpdateAction})`;
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
return dropAndRecreateConstraint(source, target, reason);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const compareUniqueConstraint: CompareFunction<DatabaseUniqueConstraint> = (source, target) => {
|
||||
let reason = '';
|
||||
|
||||
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
|
||||
reason = `columns are different (${source.columnNames} vs ${target.columnNames})`;
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
return dropAndRecreateConstraint(source, target, reason);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const compareCheckConstraint: CompareFunction<DatabaseCheckConstraint> = (source, target) => {
|
||||
if (source.expression !== target.expression) {
|
||||
// comparing expressions is hard because postgres reconstructs it with different formatting
|
||||
// for now if the constraint exists with the same name, we will just skip it
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const dropAndRecreateConstraint = (
|
||||
source: DatabaseConstraint,
|
||||
target: DatabaseConstraint,
|
||||
reason: string,
|
||||
): SchemaDiff[] => {
|
||||
return [
|
||||
{
|
||||
type: 'constraint.drop',
|
||||
tableName: target.tableName,
|
||||
constraintName: target.name,
|
||||
reason,
|
||||
},
|
||||
{ type: 'constraint.add', constraint: source, reason },
|
||||
];
|
||||
};
|
54
server/src/sql-tools/diff/comparers/enum.comparer.spec.ts
Normal file
54
server/src/sql-tools/diff/comparers/enum.comparer.spec.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { compareEnums } from 'src/sql-tools/diff/comparers/enum.comparer';
|
||||
import { DatabaseEnum, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testEnum: DatabaseEnum = { name: 'test', values: ['foo', 'bar'], synchronize: true };
|
||||
|
||||
describe('compareEnums', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareEnums.onExtra(testEnum)).toEqual([
|
||||
{
|
||||
enumName: 'test',
|
||||
type: 'enum.drop',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareEnums.onMissing(testEnum)).toEqual([
|
||||
{
|
||||
type: 'enum.create',
|
||||
enum: testEnum,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareEnums.onCompare(testEnum, testEnum)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should drop and recreate when values list is different', () => {
|
||||
const source = { name: 'test', values: ['foo', 'bar'], synchronize: true };
|
||||
const target = { name: 'test', values: ['foo', 'bar', 'world'], synchronize: true };
|
||||
expect(compareEnums.onCompare(source, target)).toEqual([
|
||||
{
|
||||
enumName: 'test',
|
||||
type: 'enum.drop',
|
||||
reason: 'enum values has changed (foo,bar vs foo,bar,world)',
|
||||
},
|
||||
{
|
||||
type: 'enum.create',
|
||||
enum: source,
|
||||
reason: 'enum values has changed (foo,bar vs foo,bar,world)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
38
server/src/sql-tools/diff/comparers/enum.comparer.ts
Normal file
38
server/src/sql-tools/diff/comparers/enum.comparer.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Comparer, DatabaseEnum, Reason } from 'src/sql-tools/types';
|
||||
|
||||
export const compareEnums: Comparer<DatabaseEnum> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'enum.create',
|
||||
enum: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'enum.drop',
|
||||
enumName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => {
|
||||
if (source.values.toString() !== target.values.toString()) {
|
||||
// TODO add or remove values if the lists are different or the order has changed
|
||||
const reason = `enum values has changed (${source.values} vs ${target.values})`;
|
||||
return [
|
||||
{
|
||||
type: 'enum.drop',
|
||||
enumName: source.name,
|
||||
reason,
|
||||
},
|
||||
{
|
||||
type: 'enum.create',
|
||||
enum: source,
|
||||
reason,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
@ -0,0 +1,37 @@
|
||||
import { compareExtensions } from 'src/sql-tools/diff/comparers/extension.comparer';
|
||||
import { Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testExtension = { name: 'test', synchronize: true };
|
||||
|
||||
describe('compareExtensions', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareExtensions.onExtra(testExtension)).toEqual([
|
||||
{
|
||||
extensionName: 'test',
|
||||
type: 'extension.drop',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareExtensions.onMissing(testExtension)).toEqual([
|
||||
{
|
||||
type: 'extension.create',
|
||||
extension: testExtension,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareExtensions.onCompare(testExtension, testExtension)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
22
server/src/sql-tools/diff/comparers/extension.comparer.ts
Normal file
22
server/src/sql-tools/diff/comparers/extension.comparer.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Comparer, DatabaseExtension, Reason } from 'src/sql-tools/types';
|
||||
|
||||
export const compareExtensions: Comparer<DatabaseExtension> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'extension.create',
|
||||
extension: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'extension.drop',
|
||||
extensionName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: () => {
|
||||
// if the name matches they are the same
|
||||
return [];
|
||||
},
|
||||
};
|
@ -0,0 +1,53 @@
|
||||
import { compareFunctions } from 'src/sql-tools/diff/comparers/function.comparer';
|
||||
import { DatabaseFunction, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testFunction: DatabaseFunction = {
|
||||
name: 'test',
|
||||
expression: 'CREATE FUNCTION something something something',
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareFunctions', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareFunctions.onExtra(testFunction)).toEqual([
|
||||
{
|
||||
functionName: 'test',
|
||||
type: 'function.drop',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareFunctions.onMissing(testFunction)).toEqual([
|
||||
{
|
||||
type: 'function.create',
|
||||
function: testFunction,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should ignore functions with the same hash', () => {
|
||||
expect(compareFunctions.onCompare(testFunction, testFunction)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should report differences if functions have different hashes', () => {
|
||||
const source: DatabaseFunction = { ...testFunction, expression: 'SELECT 1' };
|
||||
const target: DatabaseFunction = { ...testFunction, expression: 'SELECT 2' };
|
||||
expect(compareFunctions.onCompare(source, target)).toEqual([
|
||||
{
|
||||
type: 'function.create',
|
||||
reason: 'function expression has changed (SELECT 1 vs SELECT 2)',
|
||||
function: source,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
32
server/src/sql-tools/diff/comparers/function.comparer.ts
Normal file
32
server/src/sql-tools/diff/comparers/function.comparer.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Comparer, DatabaseFunction, Reason } from 'src/sql-tools/types';
|
||||
|
||||
export const compareFunctions: Comparer<DatabaseFunction> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'function.create',
|
||||
function: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'function.drop',
|
||||
functionName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => {
|
||||
if (source.expression !== target.expression) {
|
||||
const reason = `function expression has changed (${source.expression} vs ${target.expression})`;
|
||||
return [
|
||||
{
|
||||
type: 'function.create',
|
||||
function: source,
|
||||
reason,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
72
server/src/sql-tools/diff/comparers/index.comparer.spec.ts
Normal file
72
server/src/sql-tools/diff/comparers/index.comparer.spec.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { compareIndexes } from 'src/sql-tools/diff/comparers/index.comparer';
|
||||
import { DatabaseIndex, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testIndex: DatabaseIndex = {
|
||||
name: 'test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['column1', 'column2'],
|
||||
unique: false,
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareIndexes', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareIndexes.onExtra(testIndex)).toEqual([
|
||||
{
|
||||
type: 'index.drop',
|
||||
indexName: 'test',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareIndexes.onMissing(testIndex)).toEqual([
|
||||
{
|
||||
type: 'index.create',
|
||||
index: testIndex,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareIndexes.onCompare(testIndex, testIndex)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should drop and recreate when column list is different', () => {
|
||||
const source = {
|
||||
name: 'test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['column1'],
|
||||
unique: true,
|
||||
synchronize: true,
|
||||
};
|
||||
const target = {
|
||||
name: 'test',
|
||||
tableName: 'table1',
|
||||
columnNames: ['column1', 'column2'],
|
||||
unique: true,
|
||||
synchronize: true,
|
||||
};
|
||||
expect(compareIndexes.onCompare(source, target)).toEqual([
|
||||
{
|
||||
indexName: 'test',
|
||||
type: 'index.drop',
|
||||
reason: 'columns are different (column1 vs column1,column2)',
|
||||
},
|
||||
{
|
||||
type: 'index.create',
|
||||
index: source,
|
||||
reason: 'columns are different (column1 vs column1,column2)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
46
server/src/sql-tools/diff/comparers/index.comparer.ts
Normal file
46
server/src/sql-tools/diff/comparers/index.comparer.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { haveEqualColumns } from 'src/sql-tools/helpers';
|
||||
import { Comparer, DatabaseIndex, Reason } from 'src/sql-tools/types';
|
||||
|
||||
export const compareIndexes: Comparer<DatabaseIndex> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'index.create',
|
||||
index: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'index.drop',
|
||||
indexName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => {
|
||||
const sourceUsing = source.using ?? 'btree';
|
||||
const targetUsing = target.using ?? 'btree';
|
||||
|
||||
let reason = '';
|
||||
|
||||
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
|
||||
reason = `columns are different (${source.columnNames} vs ${target.columnNames})`;
|
||||
} else if (source.unique !== target.unique) {
|
||||
reason = `uniqueness is different (${source.unique} vs ${target.unique})`;
|
||||
} else if (sourceUsing !== targetUsing) {
|
||||
reason = `using method is different (${source.using} vs ${target.using})`;
|
||||
} else if (source.where !== target.where) {
|
||||
reason = `where clause is different (${source.where} vs ${target.where})`;
|
||||
} else if (source.expression !== target.expression) {
|
||||
reason = `expression is different (${source.expression} vs ${target.expression})`;
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
return [
|
||||
{ type: 'index.drop', indexName: target.name, reason },
|
||||
{ type: 'index.create', index: source, reason },
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
@ -0,0 +1,44 @@
|
||||
import { compareParameters } from 'src/sql-tools/diff/comparers/parameter.comparer';
|
||||
import { DatabaseParameter, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testParameter: DatabaseParameter = {
|
||||
name: 'test',
|
||||
databaseName: 'immich',
|
||||
value: 'on',
|
||||
scope: 'database',
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareParameters', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareParameters.onExtra(testParameter)).toEqual([
|
||||
{
|
||||
type: 'parameter.reset',
|
||||
databaseName: 'immich',
|
||||
parameterName: 'test',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareParameters.onMissing(testParameter)).toEqual([
|
||||
{
|
||||
type: 'parameter.set',
|
||||
parameter: testParameter,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareParameters.onCompare(testParameter, testParameter)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
23
server/src/sql-tools/diff/comparers/parameter.comparer.ts
Normal file
23
server/src/sql-tools/diff/comparers/parameter.comparer.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Comparer, DatabaseParameter, Reason } from 'src/sql-tools/types';
|
||||
|
||||
export const compareParameters: Comparer<DatabaseParameter> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'parameter.set',
|
||||
parameter: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'parameter.reset',
|
||||
databaseName: target.databaseName,
|
||||
parameterName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: () => {
|
||||
// TODO
|
||||
return [];
|
||||
},
|
||||
};
|
44
server/src/sql-tools/diff/comparers/table.comparer.spec.ts
Normal file
44
server/src/sql-tools/diff/comparers/table.comparer.spec.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { compareTables } from 'src/sql-tools/diff/comparers/table.comparer';
|
||||
import { DatabaseTable, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testTable: DatabaseTable = {
|
||||
name: 'test',
|
||||
columns: [],
|
||||
constraints: [],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareParameters', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareTables.onExtra(testTable)).toEqual([
|
||||
{
|
||||
type: 'table.drop',
|
||||
tableName: 'test',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareTables.onMissing(testTable)).toEqual([
|
||||
{
|
||||
type: 'table.create',
|
||||
table: testTable,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareTables.onCompare(testTable, testTable)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
59
server/src/sql-tools/diff/comparers/table.comparer.ts
Normal file
59
server/src/sql-tools/diff/comparers/table.comparer.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { compareColumns } from 'src/sql-tools/diff/comparers/column.comparer';
|
||||
import { compareConstraints } from 'src/sql-tools/diff/comparers/constraint.comparer';
|
||||
import { compareIndexes } from 'src/sql-tools/diff/comparers/index.comparer';
|
||||
import { compareTriggers } from 'src/sql-tools/diff/comparers/trigger.comparer';
|
||||
import { compare } from 'src/sql-tools/helpers';
|
||||
import { Comparer, DatabaseTable, Reason, SchemaDiff } from 'src/sql-tools/types';
|
||||
|
||||
export const compareTables: Comparer<DatabaseTable> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'table.create',
|
||||
table: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
// TODO merge constraints into table create record when possible
|
||||
...compareTable(
|
||||
source,
|
||||
{
|
||||
name: source.name,
|
||||
columns: [],
|
||||
indexes: [],
|
||||
constraints: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
},
|
||||
|
||||
{ columns: false },
|
||||
),
|
||||
],
|
||||
onExtra: (target) => [
|
||||
...compareTable(
|
||||
{
|
||||
name: target.name,
|
||||
columns: [],
|
||||
indexes: [],
|
||||
constraints: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
},
|
||||
target,
|
||||
{ columns: false },
|
||||
),
|
||||
{
|
||||
type: 'table.drop',
|
||||
tableName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => compareTable(source, target, { columns: true }),
|
||||
};
|
||||
|
||||
const compareTable = (source: DatabaseTable, target: DatabaseTable, options: { columns?: boolean }): SchemaDiff[] => {
|
||||
return [
|
||||
...(options.columns ? compare(source.columns, target.columns, {}, compareColumns) : []),
|
||||
...compare(source.indexes, target.indexes, {}, compareIndexes),
|
||||
...compare(source.constraints, target.constraints, {}, compareConstraints),
|
||||
...compare(source.triggers, target.triggers, {}, compareTriggers),
|
||||
];
|
||||
};
|
88
server/src/sql-tools/diff/comparers/trigger.comparer.spec.ts
Normal file
88
server/src/sql-tools/diff/comparers/trigger.comparer.spec.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { compareTriggers } from 'src/sql-tools/diff/comparers/trigger.comparer';
|
||||
import { DatabaseTrigger, Reason } from 'src/sql-tools/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testTrigger: DatabaseTrigger = {
|
||||
name: 'test',
|
||||
tableName: 'table1',
|
||||
timing: 'before',
|
||||
actions: ['delete'],
|
||||
scope: 'row',
|
||||
functionName: 'my_trigger_function',
|
||||
synchronize: true,
|
||||
};
|
||||
|
||||
describe('compareTriggers', () => {
|
||||
describe('onExtra', () => {
|
||||
it('should work', () => {
|
||||
expect(compareTriggers.onExtra(testTrigger)).toEqual([
|
||||
{
|
||||
type: 'trigger.drop',
|
||||
tableName: 'table1',
|
||||
triggerName: 'test',
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMissing', () => {
|
||||
it('should work', () => {
|
||||
expect(compareTriggers.onMissing(testTrigger)).toEqual([
|
||||
{
|
||||
type: 'trigger.create',
|
||||
trigger: testTrigger,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCompare', () => {
|
||||
it('should work', () => {
|
||||
expect(compareTriggers.onCompare(testTrigger, testTrigger)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect a change in function name', () => {
|
||||
const source: DatabaseTrigger = { ...testTrigger, functionName: 'my_new_name' };
|
||||
const target: DatabaseTrigger = { ...testTrigger, functionName: 'my_old_name' };
|
||||
const reason = `function is different (my_new_name vs my_old_name)`;
|
||||
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
|
||||
});
|
||||
|
||||
it('should detect a change in actions', () => {
|
||||
const source: DatabaseTrigger = { ...testTrigger, actions: ['delete'] };
|
||||
const target: DatabaseTrigger = { ...testTrigger, actions: ['delete', 'insert'] };
|
||||
const reason = `action is different (delete vs delete,insert)`;
|
||||
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
|
||||
});
|
||||
|
||||
it('should detect a change in timing', () => {
|
||||
const source: DatabaseTrigger = { ...testTrigger, timing: 'before' };
|
||||
const target: DatabaseTrigger = { ...testTrigger, timing: 'after' };
|
||||
const reason = `timing method is different (before vs after)`;
|
||||
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
|
||||
});
|
||||
|
||||
it('should detect a change in scope', () => {
|
||||
const source: DatabaseTrigger = { ...testTrigger, scope: 'row' };
|
||||
const target: DatabaseTrigger = { ...testTrigger, scope: 'statement' };
|
||||
const reason = `scope is different (row vs statement)`;
|
||||
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
|
||||
});
|
||||
|
||||
it('should detect a change in new table reference', () => {
|
||||
const source: DatabaseTrigger = { ...testTrigger, referencingNewTableAs: 'new_table' };
|
||||
const target: DatabaseTrigger = { ...testTrigger, referencingNewTableAs: undefined };
|
||||
const reason = `new table reference is different (new_table vs undefined)`;
|
||||
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
|
||||
});
|
||||
|
||||
it('should detect a change in old table reference', () => {
|
||||
const source: DatabaseTrigger = { ...testTrigger, referencingOldTableAs: 'old_table' };
|
||||
const target: DatabaseTrigger = { ...testTrigger, referencingOldTableAs: undefined };
|
||||
const reason = `old table reference is different (old_table vs undefined)`;
|
||||
expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]);
|
||||
});
|
||||
});
|
||||
});
|
41
server/src/sql-tools/diff/comparers/trigger.comparer.ts
Normal file
41
server/src/sql-tools/diff/comparers/trigger.comparer.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { Comparer, DatabaseTrigger, Reason } from 'src/sql-tools/types';
|
||||
|
||||
export const compareTriggers: Comparer<DatabaseTrigger> = {
|
||||
onMissing: (source) => [
|
||||
{
|
||||
type: 'trigger.create',
|
||||
trigger: source,
|
||||
reason: Reason.MissingInTarget,
|
||||
},
|
||||
],
|
||||
onExtra: (target) => [
|
||||
{
|
||||
type: 'trigger.drop',
|
||||
tableName: target.tableName,
|
||||
triggerName: target.name,
|
||||
reason: Reason.MissingInSource,
|
||||
},
|
||||
],
|
||||
onCompare: (source, target) => {
|
||||
let reason = '';
|
||||
if (source.functionName !== target.functionName) {
|
||||
reason = `function is different (${source.functionName} vs ${target.functionName})`;
|
||||
} else if (source.actions.join(' OR ') !== target.actions.join(' OR ')) {
|
||||
reason = `action is different (${source.actions} vs ${target.actions})`;
|
||||
} else if (source.timing !== target.timing) {
|
||||
reason = `timing method is different (${source.timing} vs ${target.timing})`;
|
||||
} else if (source.scope !== target.scope) {
|
||||
reason = `scope is different (${source.scope} vs ${target.scope})`;
|
||||
} else if (source.referencingNewTableAs !== target.referencingNewTableAs) {
|
||||
reason = `new table reference is different (${source.referencingNewTableAs} vs ${target.referencingNewTableAs})`;
|
||||
} else if (source.referencingOldTableAs !== target.referencingOldTableAs) {
|
||||
reason = `old table reference is different (${source.referencingOldTableAs} vs ${target.referencingOldTableAs})`;
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
return [{ type: 'trigger.create', trigger: source, reason }];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
@ -1,8 +1,8 @@
|
||||
import { schemaDiff } from 'src/sql-tools/schema-diff';
|
||||
import { schemaDiff } from 'src/sql-tools/diff';
|
||||
import {
|
||||
ColumnType,
|
||||
DatabaseActionType,
|
||||
DatabaseColumn,
|
||||
DatabaseColumnType,
|
||||
DatabaseConstraint,
|
||||
DatabaseConstraintType,
|
||||
DatabaseIndex,
|
||||
@ -15,7 +15,12 @@ const fromColumn = (column: Partial<Omit<DatabaseColumn, 'tableName'>>): Databas
|
||||
const tableName = 'table1';
|
||||
|
||||
return {
|
||||
name: 'public',
|
||||
name: 'postgres',
|
||||
schemaName: 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
tables: [
|
||||
{
|
||||
name: tableName,
|
||||
@ -31,6 +36,7 @@ const fromColumn = (column: Partial<Omit<DatabaseColumn, 'tableName'>>): Databas
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
constraints: [],
|
||||
synchronize: true,
|
||||
},
|
||||
@ -43,7 +49,12 @@ const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => {
|
||||
const tableName = constraint?.tableName || 'table1';
|
||||
|
||||
return {
|
||||
name: 'public',
|
||||
name: 'postgres',
|
||||
schemaName: 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
tables: [
|
||||
{
|
||||
name: tableName,
|
||||
@ -58,6 +69,7 @@ const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => {
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
constraints: constraint ? [constraint] : [],
|
||||
synchronize: true,
|
||||
},
|
||||
@ -70,7 +82,12 @@ const fromIndex = (index?: DatabaseIndex): DatabaseSchema => {
|
||||
const tableName = index?.tableName || 'table1';
|
||||
|
||||
return {
|
||||
name: 'public',
|
||||
name: 'postgres',
|
||||
schemaName: 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
tables: [
|
||||
{
|
||||
name: tableName,
|
||||
@ -86,6 +103,7 @@ const fromIndex = (index?: DatabaseIndex): DatabaseSchema => {
|
||||
],
|
||||
indexes: index ? [index] : [],
|
||||
constraints: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
@ -99,7 +117,7 @@ const newSchema = (schema: {
|
||||
name: string;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
type?: DatabaseColumnType;
|
||||
type?: ColumnType;
|
||||
nullable?: boolean;
|
||||
isArray?: boolean;
|
||||
}>;
|
||||
@ -131,12 +149,18 @@ const newSchema = (schema: {
|
||||
columns,
|
||||
indexes: table.indexes ?? [],
|
||||
constraints: table.constraints ?? [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: schema?.name || 'public',
|
||||
name: 'immich',
|
||||
schemaName: schema?.name || 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
tables,
|
||||
warnings: [],
|
||||
};
|
||||
@ -167,8 +191,14 @@ describe('schemaDiff', () => {
|
||||
expect(diff.items).toHaveLength(1);
|
||||
expect(diff.items[0]).toEqual({
|
||||
type: 'table.create',
|
||||
tableName: 'table1',
|
||||
columns: [column],
|
||||
table: {
|
||||
name: 'table1',
|
||||
columns: [column],
|
||||
constraints: [],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
synchronize: true,
|
||||
},
|
||||
reason: 'missing in target',
|
||||
});
|
||||
});
|
||||
@ -181,7 +211,7 @@ describe('schemaDiff', () => {
|
||||
newSchema({
|
||||
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
|
||||
}),
|
||||
{ ignoreExtraTables: false },
|
||||
{ tables: { ignoreExtra: false } },
|
||||
);
|
||||
|
||||
expect(diff.items).toHaveLength(1);
|
85
server/src/sql-tools/diff/index.ts
Normal file
85
server/src/sql-tools/diff/index.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { compareEnums } from 'src/sql-tools/diff/comparers/enum.comparer';
|
||||
import { compareExtensions } from 'src/sql-tools/diff/comparers/extension.comparer';
|
||||
import { compareFunctions } from 'src/sql-tools/diff/comparers/function.comparer';
|
||||
import { compareParameters } from 'src/sql-tools/diff/comparers/parameter.comparer';
|
||||
import { compareTables } from 'src/sql-tools/diff/comparers/table.comparer';
|
||||
import { compare } from 'src/sql-tools/helpers';
|
||||
import { schemaDiffToSql } from 'src/sql-tools/to-sql';
|
||||
import {
|
||||
DatabaseConstraintType,
|
||||
DatabaseSchema,
|
||||
SchemaDiff,
|
||||
SchemaDiffOptions,
|
||||
SchemaDiffToSqlOptions,
|
||||
} from 'src/sql-tools/types';
|
||||
|
||||
/**
|
||||
* Compute the difference between two database schemas
|
||||
*/
|
||||
export const schemaDiff = (source: DatabaseSchema, target: DatabaseSchema, options: SchemaDiffOptions = {}) => {
|
||||
const items = [
|
||||
...compare(source.parameters, target.parameters, options.parameters, compareParameters),
|
||||
...compare(source.extensions, target.extensions, options.extension, compareExtensions),
|
||||
...compare(source.functions, target.functions, options.functions, compareFunctions),
|
||||
...compare(source.enums, target.enums, options.enums, compareEnums),
|
||||
...compare(source.tables, target.tables, options.tables, compareTables),
|
||||
];
|
||||
|
||||
type SchemaName = SchemaDiff['type'];
|
||||
const itemMap: Record<SchemaName, SchemaDiff[]> = {
|
||||
'enum.create': [],
|
||||
'enum.drop': [],
|
||||
'extension.create': [],
|
||||
'extension.drop': [],
|
||||
'function.create': [],
|
||||
'function.drop': [],
|
||||
'table.create': [],
|
||||
'table.drop': [],
|
||||
'column.add': [],
|
||||
'column.alter': [],
|
||||
'column.drop': [],
|
||||
'constraint.add': [],
|
||||
'constraint.drop': [],
|
||||
'index.create': [],
|
||||
'index.drop': [],
|
||||
'trigger.create': [],
|
||||
'trigger.drop': [],
|
||||
'parameter.set': [],
|
||||
'parameter.reset': [],
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
itemMap[item.type].push(item);
|
||||
}
|
||||
|
||||
const constraintAdds = itemMap['constraint.add'].filter((item) => item.type === 'constraint.add');
|
||||
|
||||
const orderedItems = [
|
||||
...itemMap['extension.create'],
|
||||
...itemMap['function.create'],
|
||||
...itemMap['parameter.set'],
|
||||
...itemMap['parameter.reset'],
|
||||
...itemMap['enum.create'],
|
||||
...itemMap['trigger.drop'],
|
||||
...itemMap['index.drop'],
|
||||
...itemMap['constraint.drop'],
|
||||
...itemMap['table.create'],
|
||||
...itemMap['column.alter'],
|
||||
...itemMap['column.add'],
|
||||
...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.PRIMARY_KEY),
|
||||
...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.FOREIGN_KEY),
|
||||
...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.UNIQUE),
|
||||
...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.CHECK),
|
||||
...itemMap['index.create'],
|
||||
...itemMap['trigger.create'],
|
||||
...itemMap['column.drop'],
|
||||
...itemMap['table.drop'],
|
||||
...itemMap['enum.drop'],
|
||||
...itemMap['function.drop'],
|
||||
];
|
||||
|
||||
return {
|
||||
items: orderedItems,
|
||||
asSql: (options?: SchemaDiffToSqlOptions) => schemaDiffToSql(orderedItems, options),
|
||||
};
|
||||
};
|
@ -0,0 +1,8 @@
|
||||
import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/from-code/decorators/trigger-function.decorator';
|
||||
|
||||
export const AfterDeleteTrigger = (options: Omit<TriggerFunctionOptions, 'timing' | 'actions'>) =>
|
||||
TriggerFunction({
|
||||
timing: 'after',
|
||||
actions: ['delete'],
|
||||
...options,
|
||||
});
|
@ -0,0 +1,8 @@
|
||||
import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/from-code/decorators/trigger-function.decorator';
|
||||
|
||||
export const BeforeUpdateTrigger = (options: Omit<TriggerFunctionOptions, 'timing' | 'actions'>) =>
|
||||
TriggerFunction({
|
||||
timing: 'before',
|
||||
actions: ['update'],
|
||||
...options,
|
||||
});
|
11
server/src/sql-tools/from-code/decorators/check.decorator.ts
Normal file
11
server/src/sql-tools/from-code/decorators/check.decorator.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
|
||||
export type CheckOptions = {
|
||||
name?: string;
|
||||
expression: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const Check = (options: CheckOptions): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'checkConstraint', item: { object, options } });
|
||||
};
|
@ -0,0 +1,16 @@
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asOptions } from 'src/sql-tools/helpers';
|
||||
|
||||
export type ColumnIndexOptions = {
|
||||
name?: string;
|
||||
unique?: boolean;
|
||||
expression?: string;
|
||||
using?: string;
|
||||
with?: string;
|
||||
where?: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const ColumnIndex = (options: string | ColumnIndexOptions = {}): PropertyDecorator => {
|
||||
return (object: object, propertyName: string | symbol) =>
|
||||
void register({ type: 'columnIndex', item: { object, propertyName, options: asOptions(options) } });
|
||||
};
|
@ -0,0 +1,30 @@
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asOptions } from 'src/sql-tools/helpers';
|
||||
import { ColumnStorage, ColumnType, DatabaseEnum } from 'src/sql-tools/types';
|
||||
|
||||
export type ColumnValue = null | boolean | string | number | object | Date | (() => string);
|
||||
|
||||
export type ColumnBaseOptions = {
|
||||
name?: string;
|
||||
primary?: boolean;
|
||||
type?: ColumnType;
|
||||
nullable?: boolean;
|
||||
length?: number;
|
||||
default?: ColumnValue;
|
||||
comment?: string;
|
||||
synchronize?: boolean;
|
||||
storage?: ColumnStorage;
|
||||
identity?: boolean;
|
||||
};
|
||||
|
||||
export type ColumnOptions = ColumnBaseOptions & {
|
||||
enum?: DatabaseEnum;
|
||||
array?: boolean;
|
||||
unique?: boolean;
|
||||
uniqueConstraintName?: string;
|
||||
};
|
||||
|
||||
export const Column = (options: string | ColumnOptions = {}): PropertyDecorator => {
|
||||
return (object: object, propertyName: string | symbol) =>
|
||||
void register({ type: 'column', item: { object, propertyName, options: asOptions(options) } });
|
||||
};
|
@ -0,0 +1,14 @@
|
||||
import { ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { ParameterScope } from 'src/sql-tools/types';
|
||||
|
||||
export type ConfigurationParameterOptions = {
|
||||
name: string;
|
||||
value: ColumnValue;
|
||||
scope: ParameterScope;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const ConfigurationParameter = (options: ConfigurationParameterOptions): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'configurationParameter', item: { object, options } });
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
|
||||
export const CreateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
|
||||
return Column({
|
||||
type: 'timestamp with time zone',
|
||||
default: () => 'now()',
|
||||
...options,
|
||||
});
|
||||
};
|
@ -0,0 +1,10 @@
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
|
||||
export type DatabaseOptions = {
|
||||
name?: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const Database = (options: DatabaseOptions): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'database', item: { object, options } });
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
|
||||
export const DeleteDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
|
||||
return Column({
|
||||
type: 'timestamp with time zone',
|
||||
nullable: true,
|
||||
...options,
|
||||
});
|
||||
};
|
@ -0,0 +1,11 @@
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asOptions } from 'src/sql-tools/helpers';
|
||||
|
||||
export type ExtensionOptions = {
|
||||
name: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const Extension = (options: string | ExtensionOptions): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'extension', item: { object, options: asOptions(options) } });
|
||||
};
|
@ -0,0 +1,15 @@
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asOptions } from 'src/sql-tools/helpers';
|
||||
|
||||
export type ExtensionsOptions = {
|
||||
name: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const Extensions = (options: Array<string | ExtensionsOptions>): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => {
|
||||
for (const option of options) {
|
||||
register({ type: 'extension', item: { object, options: asOptions(option) } });
|
||||
}
|
||||
};
|
||||
};
|
@ -0,0 +1,18 @@
|
||||
import { ColumnBaseOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
|
||||
type Action = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION';
|
||||
|
||||
export type ForeignKeyColumnOptions = ColumnBaseOptions & {
|
||||
onUpdate?: Action;
|
||||
onDelete?: Action;
|
||||
constraintName?: string;
|
||||
unique?: boolean;
|
||||
uniqueConstraintName?: string;
|
||||
};
|
||||
|
||||
export const ForeignKeyColumn = (target: () => object, options: ForeignKeyColumnOptions): PropertyDecorator => {
|
||||
return (object: object, propertyName: string | symbol) => {
|
||||
register({ type: 'foreignKeyColumn', item: { object, propertyName, options, target } });
|
||||
};
|
||||
};
|
@ -0,0 +1,37 @@
|
||||
import { Column, ColumnOptions, ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
import { ColumnType } from 'src/sql-tools/types';
|
||||
|
||||
export type GeneratedColumnStrategy = 'uuid' | 'identity';
|
||||
|
||||
export type GenerateColumnOptions = Omit<ColumnOptions, 'type'> & {
|
||||
strategy?: GeneratedColumnStrategy;
|
||||
};
|
||||
|
||||
export const GeneratedColumn = ({ strategy = 'uuid', ...options }: GenerateColumnOptions): PropertyDecorator => {
|
||||
let columnType: ColumnType | undefined;
|
||||
let columnDefault: ColumnValue | undefined;
|
||||
|
||||
switch (strategy) {
|
||||
case 'uuid': {
|
||||
columnType = 'uuid';
|
||||
columnDefault = () => 'uuid_generate_v4()';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'identity': {
|
||||
columnType = 'integer';
|
||||
options.identity = true;
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`Unsupported strategy for @GeneratedColumn ${strategy}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Column({
|
||||
type: columnType,
|
||||
default: columnDefault,
|
||||
...options,
|
||||
});
|
||||
};
|
12
server/src/sql-tools/from-code/decorators/index.decorator.ts
Normal file
12
server/src/sql-tools/from-code/decorators/index.decorator.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { ColumnIndexOptions } from 'src/sql-tools/from-code/decorators/column-index.decorator';
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asOptions } from 'src/sql-tools/helpers';
|
||||
|
||||
export type IndexOptions = ColumnIndexOptions & {
|
||||
columns?: string[];
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const Index = (options: string | IndexOptions = {}): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'index', item: { object, options: asOptions(options) } });
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
|
||||
export const PrimaryColumn = (options: Omit<ColumnOptions, 'primary'> = {}) => Column({ ...options, primary: true });
|
@ -0,0 +1,4 @@
|
||||
import { GenerateColumnOptions, GeneratedColumn } from 'src/sql-tools/from-code/decorators/generated-column.decorator';
|
||||
|
||||
export const PrimaryGeneratedColumn = (options: Omit<GenerateColumnOptions, 'primary'> = {}) =>
|
||||
GeneratedColumn({ ...options, primary: true });
|
14
server/src/sql-tools/from-code/decorators/table.decorator.ts
Normal file
14
server/src/sql-tools/from-code/decorators/table.decorator.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asOptions } from 'src/sql-tools/helpers';
|
||||
|
||||
export type TableOptions = {
|
||||
name?: string;
|
||||
primaryConstraintName?: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
|
||||
/** Table comments here */
|
||||
export const Table = (options: string | TableOptions = {}): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'table', item: { object, options: asOptions(options) } });
|
||||
};
|
@ -0,0 +1,6 @@
|
||||
import { Trigger, TriggerOptions } from 'src/sql-tools/from-code/decorators/trigger.decorator';
|
||||
import { DatabaseFunction } from 'src/sql-tools/types';
|
||||
|
||||
export type TriggerFunctionOptions = Omit<TriggerOptions, 'functionName'> & { function: DatabaseFunction };
|
||||
export const TriggerFunction = (options: TriggerFunctionOptions) =>
|
||||
Trigger({ ...options, functionName: options.function.name });
|
@ -0,0 +1,19 @@
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { TriggerAction, TriggerScope, TriggerTiming } from 'src/sql-tools/types';
|
||||
|
||||
export type TriggerOptions = {
|
||||
name?: string;
|
||||
timing: TriggerTiming;
|
||||
actions: TriggerAction[];
|
||||
scope: TriggerScope;
|
||||
functionName: string;
|
||||
referencingNewTableAs?: string;
|
||||
referencingOldTableAs?: string;
|
||||
when?: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
|
||||
export const Trigger = (options: TriggerOptions): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'trigger', item: { object, options } });
|
||||
};
|
@ -0,0 +1,11 @@
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
|
||||
export type UniqueOptions = {
|
||||
name?: string;
|
||||
columns: string[];
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const Unique = (options: UniqueOptions): ClassDecorator => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => void register({ type: 'uniqueConstraint', item: { object, options } });
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
|
||||
export const UpdateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
|
||||
return Column({
|
||||
type: 'timestamp with time zone',
|
||||
default: () => 'now()',
|
||||
...options,
|
||||
});
|
||||
};
|
@ -1,16 +1,21 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { reset, schemaFromDecorators } from 'src/sql-tools/schema-from-decorators';
|
||||
import { reset, schemaFromCode } from 'src/sql-tools/from-code';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('schemaDiff', () => {
|
||||
describe(schemaFromCode.name, () => {
|
||||
beforeEach(() => {
|
||||
reset();
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(schemaFromDecorators()).toEqual({
|
||||
name: 'public',
|
||||
expect(schemaFromCode()).toEqual({
|
||||
name: 'postgres',
|
||||
schemaName: 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
tables: [],
|
||||
warnings: [],
|
||||
});
|
||||
@ -24,7 +29,7 @@ describe('schemaDiff', () => {
|
||||
const module = await import(filePath);
|
||||
expect(module.description).toBeDefined();
|
||||
expect(module.schema).toBeDefined();
|
||||
expect(schemaFromDecorators(), module.description).toEqual(module.schema);
|
||||
expect(schemaFromCode(), module.description).toEqual(module.schema);
|
||||
});
|
||||
}
|
||||
});
|
69
server/src/sql-tools/from-code/index.ts
Normal file
69
server/src/sql-tools/from-code/index.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import 'reflect-metadata';
|
||||
import { processCheckConstraints } from 'src/sql-tools/from-code/processors/check-constraint.processor';
|
||||
import { processColumnIndexes } from 'src/sql-tools/from-code/processors/column-index.processor';
|
||||
import { processColumns } from 'src/sql-tools/from-code/processors/column.processor';
|
||||
import { processConfigurationParameters } from 'src/sql-tools/from-code/processors/configuration-parameter.processor';
|
||||
import { processDatabases } from 'src/sql-tools/from-code/processors/database.processor';
|
||||
import { processEnums } from 'src/sql-tools/from-code/processors/enum.processor';
|
||||
import { processExtensions } from 'src/sql-tools/from-code/processors/extension.processor';
|
||||
import { processForeignKeyConstraints } from 'src/sql-tools/from-code/processors/foreign-key-constriant.processor';
|
||||
import { processFunctions } from 'src/sql-tools/from-code/processors/function.processor';
|
||||
import { processIndexes } from 'src/sql-tools/from-code/processors/index.processor';
|
||||
import { processPrimaryKeyConstraints } from 'src/sql-tools/from-code/processors/primary-key-contraint.processor';
|
||||
import { processTables } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { processTriggers } from 'src/sql-tools/from-code/processors/trigger.processor';
|
||||
import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
|
||||
import { processUniqueConstraints } from 'src/sql-tools/from-code/processors/unique-constraint.processor';
|
||||
import { getRegisteredItems, resetRegisteredItems } from 'src/sql-tools/from-code/register';
|
||||
import { DatabaseSchema } from 'src/sql-tools/types';
|
||||
|
||||
let initialized = false;
|
||||
let schema: DatabaseSchema;
|
||||
|
||||
export const reset = () => {
|
||||
initialized = false;
|
||||
resetRegisteredItems();
|
||||
};
|
||||
|
||||
const processors: Processor[] = [
|
||||
processDatabases,
|
||||
processConfigurationParameters,
|
||||
processEnums,
|
||||
processExtensions,
|
||||
processFunctions,
|
||||
processTables,
|
||||
processColumns,
|
||||
processUniqueConstraints,
|
||||
processCheckConstraints,
|
||||
processPrimaryKeyConstraints,
|
||||
processIndexes,
|
||||
processColumnIndexes,
|
||||
processForeignKeyConstraints,
|
||||
processTriggers,
|
||||
];
|
||||
|
||||
export const schemaFromCode = () => {
|
||||
if (!initialized) {
|
||||
const builder: SchemaBuilder = {
|
||||
name: 'postgres',
|
||||
schemaName: 'public',
|
||||
tables: [],
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const items = getRegisteredItems();
|
||||
|
||||
for (const processor of processors) {
|
||||
processor(builder, items);
|
||||
}
|
||||
|
||||
schema = { ...builder, tables: builder.tables.map(({ metadata: _, ...table }) => table) };
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
return schema;
|
||||
};
|
@ -0,0 +1,26 @@
|
||||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asCheckConstraintName } from 'src/sql-tools/helpers';
|
||||
import { DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
|
||||
export const processCheckConstraints: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'checkConstraint')) {
|
||||
const table = resolveTable(builder, object);
|
||||
if (!table) {
|
||||
onMissingTable(builder, '@Check', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tableName = table.name;
|
||||
|
||||
table.constraints.push({
|
||||
type: DatabaseConstraintType.CHECK,
|
||||
name: options.name || asCheckConstraintName(tableName, options.expression),
|
||||
tableName,
|
||||
expression: options.expression,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
@ -0,0 +1,32 @@
|
||||
import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor';
|
||||
import { onMissingTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asIndexName } from 'src/sql-tools/helpers';
|
||||
|
||||
export const processColumnIndexes: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { object, propertyName, options },
|
||||
} of items.filter((item) => item.type === 'columnIndex')) {
|
||||
const { table, column } = resolveColumn(builder, object, propertyName);
|
||||
if (!table) {
|
||||
onMissingTable(builder, '@ColumnIndex', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!column) {
|
||||
onMissingColumn(builder, `@ColumnIndex`, object, propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
table.indexes.push({
|
||||
name: options.name || asIndexName(table.name, [column.name], options.where),
|
||||
tableName: table.name,
|
||||
unique: options.unique ?? false,
|
||||
expression: options.expression,
|
||||
using: options.using,
|
||||
where: options.where,
|
||||
columnNames: [column.name],
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
103
server/src/sql-tools/from-code/processors/column.processor.ts
Normal file
103
server/src/sql-tools/from-code/processors/column.processor.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asMetadataKey, asUniqueConstraintName, fromColumnValue } from 'src/sql-tools/helpers';
|
||||
import { DatabaseColumn, DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
|
||||
export const processColumns: Processor = (builder, items) => {
|
||||
for (const {
|
||||
type,
|
||||
item: { object, propertyName, options },
|
||||
} of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) {
|
||||
const table = resolveTable(builder, object.constructor);
|
||||
if (!table) {
|
||||
onMissingTable(builder, type === 'column' ? '@Column' : '@ForeignKeyColumn', object, propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const columnName = options.name ?? String(propertyName);
|
||||
const existingColumn = table.columns.find((column) => column.name === columnName);
|
||||
if (existingColumn) {
|
||||
// TODO log warnings if column name is not unique
|
||||
continue;
|
||||
}
|
||||
|
||||
const tableName = table.name;
|
||||
|
||||
let defaultValue = fromColumnValue(options.default);
|
||||
let nullable = options.nullable ?? false;
|
||||
|
||||
// map `{ default: null }` to `{ nullable: true }`
|
||||
if (defaultValue === null) {
|
||||
nullable = true;
|
||||
defaultValue = undefined;
|
||||
}
|
||||
|
||||
const isEnum = !!(options as ColumnOptions).enum;
|
||||
|
||||
const column: DatabaseColumn = {
|
||||
name: columnName,
|
||||
tableName,
|
||||
primary: options.primary ?? false,
|
||||
default: defaultValue,
|
||||
nullable,
|
||||
isArray: (options as ColumnOptions).array ?? false,
|
||||
length: options.length,
|
||||
type: isEnum ? 'enum' : options.type || 'character varying',
|
||||
enumName: isEnum ? (options as ColumnOptions).enum!.name : undefined,
|
||||
comment: options.comment,
|
||||
storage: options.storage,
|
||||
identity: options.identity,
|
||||
synchronize: options.synchronize ?? true,
|
||||
};
|
||||
|
||||
writeMetadata(object, propertyName, { name: column.name, options });
|
||||
|
||||
table.columns.push(column);
|
||||
|
||||
if (type === 'column' && !options.primary && options.unique) {
|
||||
table.constraints.push({
|
||||
type: DatabaseConstraintType.UNIQUE,
|
||||
name: options.uniqueConstraintName || asUniqueConstraintName(table.name, [column.name]),
|
||||
tableName: table.name,
|
||||
columnNames: [column.name],
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
type ColumnMetadata = { name: string; options: ColumnOptions };
|
||||
|
||||
export const resolveColumn = (builder: SchemaBuilder, object: object, propertyName: string | symbol) => {
|
||||
const table = resolveTable(builder, object.constructor);
|
||||
if (!table) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const metadata = readMetadata(object, propertyName);
|
||||
if (!metadata) {
|
||||
return { table };
|
||||
}
|
||||
|
||||
const column = table.columns.find((column) => column.name === metadata.name);
|
||||
return { table, column };
|
||||
};
|
||||
|
||||
export const onMissingColumn = (
|
||||
builder: SchemaBuilder,
|
||||
context: string,
|
||||
object: object,
|
||||
propertyName?: symbol | string,
|
||||
) => {
|
||||
const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : '');
|
||||
builder.warnings.push(`[${context}] Unable to find column (${label})`);
|
||||
};
|
||||
|
||||
const METADATA_KEY = asMetadataKey('table-metadata');
|
||||
|
||||
const writeMetadata = (object: object, propertyName: symbol | string, metadata: ColumnMetadata) =>
|
||||
Reflect.defineMetadata(METADATA_KEY, metadata, object, propertyName);
|
||||
|
||||
const readMetadata = (object: object, propertyName: symbol | string): ColumnMetadata | undefined =>
|
||||
Reflect.getMetadata(METADATA_KEY, object, propertyName);
|
@ -0,0 +1,16 @@
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { fromColumnValue } from 'src/sql-tools/helpers';
|
||||
|
||||
export const processConfigurationParameters: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { options },
|
||||
} of items.filter((item) => item.type === 'configurationParameter')) {
|
||||
builder.parameters.push({
|
||||
databaseName: builder.name,
|
||||
name: options.name,
|
||||
value: fromColumnValue(options.value),
|
||||
scope: options.scope,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
@ -0,0 +1,10 @@
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asSnakeCase } from 'src/sql-tools/helpers';
|
||||
|
||||
export const processDatabases: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'database')) {
|
||||
builder.name = options.name || asSnakeCase(object.name);
|
||||
}
|
||||
};
|
@ -0,0 +1,8 @@
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
|
||||
export const processEnums: Processor = (builder, items) => {
|
||||
for (const { item } of items.filter((item) => item.type === 'enum')) {
|
||||
// TODO log warnings if enum name is not unique
|
||||
builder.enums.push(item);
|
||||
}
|
||||
};
|
@ -0,0 +1,12 @@
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
|
||||
export const processExtensions: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { options },
|
||||
} of items.filter((item) => item.type === 'extension')) {
|
||||
builder.extensions.push({
|
||||
name: options.name,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
@ -0,0 +1,59 @@
|
||||
import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor';
|
||||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asForeignKeyConstraintName, asRelationKeyConstraintName } from 'src/sql-tools/helpers';
|
||||
import { DatabaseActionType, DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
|
||||
export const processForeignKeyConstraints: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { object, propertyName, options, target },
|
||||
} of items.filter((item) => item.type === 'foreignKeyColumn')) {
|
||||
const { table, column } = resolveColumn(builder, object, propertyName);
|
||||
if (!table) {
|
||||
onMissingTable(builder, '@ForeignKeyColumn', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!column) {
|
||||
// should be impossible since they are pre-created in `column.processor.ts`
|
||||
onMissingColumn(builder, '@ForeignKeyColumn', object, propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const referenceTable = resolveTable(builder, target());
|
||||
if (!referenceTable) {
|
||||
onMissingTable(builder, '@ForeignKeyColumn', object, propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const columnNames = [column.name];
|
||||
const referenceColumns = referenceTable.columns.filter((column) => column.primary);
|
||||
|
||||
// infer FK column type from reference table
|
||||
if (referenceColumns.length === 1) {
|
||||
column.type = referenceColumns[0].type;
|
||||
}
|
||||
|
||||
table.constraints.push({
|
||||
name: options.constraintName || asForeignKeyConstraintName(table.name, columnNames),
|
||||
tableName: table.name,
|
||||
columnNames,
|
||||
type: DatabaseConstraintType.FOREIGN_KEY,
|
||||
referenceTableName: referenceTable.name,
|
||||
referenceColumnNames: referenceColumns.map((column) => column.name),
|
||||
onUpdate: options.onUpdate as DatabaseActionType,
|
||||
onDelete: options.onDelete as DatabaseActionType,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
|
||||
if (options.unique) {
|
||||
table.constraints.push({
|
||||
name: options.uniqueConstraintName || asRelationKeyConstraintName(table.name, columnNames),
|
||||
tableName: table.name,
|
||||
columnNames,
|
||||
type: DatabaseConstraintType.UNIQUE,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
@ -0,0 +1,8 @@
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
|
||||
export const processFunctions: Processor = (builder, items) => {
|
||||
for (const { item } of items.filter((item) => item.type === 'function')) {
|
||||
// TODO log warnings if function name is not unique
|
||||
builder.functions.push(item);
|
||||
}
|
||||
};
|
27
server/src/sql-tools/from-code/processors/index.processor.ts
Normal file
27
server/src/sql-tools/from-code/processors/index.processor.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asIndexName } from 'src/sql-tools/helpers';
|
||||
|
||||
export const processIndexes: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'index')) {
|
||||
const table = resolveTable(builder, object);
|
||||
if (!table) {
|
||||
onMissingTable(builder, '@Check', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
table.indexes.push({
|
||||
name: options.name || asIndexName(table.name, options.columns, options.where),
|
||||
tableName: table.name,
|
||||
unique: options.unique ?? false,
|
||||
expression: options.expression,
|
||||
using: options.using,
|
||||
with: options.with,
|
||||
where: options.where,
|
||||
columnNames: options.columns,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
@ -0,0 +1,24 @@
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asPrimaryKeyConstraintName } from 'src/sql-tools/helpers';
|
||||
import { DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
|
||||
export const processPrimaryKeyConstraints: Processor = (builder) => {
|
||||
for (const table of builder.tables) {
|
||||
const columnNames: string[] = [];
|
||||
|
||||
for (const column of table.columns) {
|
||||
if (column.primary) {
|
||||
columnNames.push(column.name);
|
||||
}
|
||||
}
|
||||
if (columnNames.length > 0) {
|
||||
table.constraints.push({
|
||||
type: DatabaseConstraintType.PRIMARY_KEY,
|
||||
name: table.metadata.options.primaryConstraintName || asPrimaryKeyConstraintName(table.name, columnNames),
|
||||
tableName: table.name,
|
||||
columnNames,
|
||||
synchronize: table.metadata.options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
51
server/src/sql-tools/from-code/processors/table.processor.ts
Normal file
51
server/src/sql-tools/from-code/processors/table.processor.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator';
|
||||
import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asMetadataKey, asSnakeCase } from 'src/sql-tools/helpers';
|
||||
|
||||
export const processTables: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { options, object },
|
||||
} of items.filter((item) => item.type === 'table')) {
|
||||
const tableName = options.name || asSnakeCase(object.name);
|
||||
|
||||
writeMetadata(object, { name: tableName, options });
|
||||
|
||||
builder.tables.push({
|
||||
name: tableName,
|
||||
columns: [],
|
||||
constraints: [],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
synchronize: options.synchronize ?? true,
|
||||
metadata: { options, object },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveTable = (builder: SchemaBuilder, object: object) => {
|
||||
const metadata = readMetadata(object);
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
return builder.tables.find((table) => table.name === metadata.name);
|
||||
};
|
||||
|
||||
export const onMissingTable = (
|
||||
builder: SchemaBuilder,
|
||||
context: string,
|
||||
object: object,
|
||||
propertyName?: symbol | string,
|
||||
) => {
|
||||
const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : '');
|
||||
builder.warnings.push(`[${context}] Unable to find table (${label})`);
|
||||
};
|
||||
|
||||
const METADATA_KEY = asMetadataKey('table-metadata');
|
||||
|
||||
type TableMetadata = { name: string; options: TableOptions };
|
||||
|
||||
const readMetadata = (object: object): TableMetadata | undefined => Reflect.getMetadata(METADATA_KEY, object);
|
||||
|
||||
const writeMetadata = (object: object, metadata: TableMetadata): void =>
|
||||
Reflect.defineMetadata(METADATA_KEY, metadata, object);
|
@ -0,0 +1,28 @@
|
||||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asTriggerName } from 'src/sql-tools/helpers';
|
||||
|
||||
export const processTriggers: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'trigger')) {
|
||||
const table = resolveTable(builder, object);
|
||||
if (!table) {
|
||||
onMissingTable(builder, '@Trigger', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
table.triggers.push({
|
||||
name: options.name || asTriggerName(table.name, options),
|
||||
tableName: table.name,
|
||||
timing: options.timing,
|
||||
actions: options.actions,
|
||||
when: options.when,
|
||||
scope: options.scope,
|
||||
referencingNewTableAs: options.referencingNewTableAs,
|
||||
referencingOldTableAs: options.referencingOldTableAs,
|
||||
functionName: options.functionName,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
9
server/src/sql-tools/from-code/processors/type.ts
Normal file
9
server/src/sql-tools/from-code/processors/type.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator';
|
||||
import { RegisterItem } from 'src/sql-tools/from-code/register-item';
|
||||
import { DatabaseSchema, DatabaseTable } from 'src/sql-tools/types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
export type TableWithMetadata = DatabaseTable & { metadata: { options: TableOptions; object: Function } };
|
||||
export type SchemaBuilder = Omit<DatabaseSchema, 'tables'> & { tables: TableWithMetadata[] };
|
||||
|
||||
export type Processor = (builder: SchemaBuilder, items: RegisterItem[]) => void;
|
@ -0,0 +1,27 @@
|
||||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asUniqueConstraintName } from 'src/sql-tools/helpers';
|
||||
import { DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
|
||||
export const processUniqueConstraints: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'uniqueConstraint')) {
|
||||
const table = resolveTable(builder, object);
|
||||
if (!table) {
|
||||
onMissingTable(builder, '@Unique', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tableName = table.name;
|
||||
const columnNames = options.columns;
|
||||
|
||||
table.constraints.push({
|
||||
type: DatabaseConstraintType.UNIQUE,
|
||||
name: options.name || asUniqueConstraintName(tableName, columnNames),
|
||||
tableName,
|
||||
columnNames,
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
20
server/src/sql-tools/from-code/register-enum.ts
Normal file
20
server/src/sql-tools/from-code/register-enum.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { DatabaseEnum } from 'src/sql-tools/types';
|
||||
|
||||
export type EnumOptions = {
|
||||
name: string;
|
||||
values: string[];
|
||||
synchronize?: boolean;
|
||||
};
|
||||
|
||||
export const registerEnum = (options: EnumOptions) => {
|
||||
const item: DatabaseEnum = {
|
||||
name: options.name,
|
||||
values: options.values,
|
||||
synchronize: options.synchronize ?? true,
|
||||
};
|
||||
|
||||
register({ type: 'enum', item });
|
||||
|
||||
return item;
|
||||
};
|
29
server/src/sql-tools/from-code/register-function.ts
Normal file
29
server/src/sql-tools/from-code/register-function.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asFunctionExpression } from 'src/sql-tools/helpers';
|
||||
import { ColumnType, DatabaseFunction } from 'src/sql-tools/types';
|
||||
|
||||
export type FunctionOptions = {
|
||||
name: string;
|
||||
arguments?: string[];
|
||||
returnType: ColumnType | string;
|
||||
language?: 'SQL' | 'PLPGSQL';
|
||||
behavior?: 'immutable' | 'stable' | 'volatile';
|
||||
parallel?: 'safe' | 'unsafe' | 'restricted';
|
||||
strict?: boolean;
|
||||
synchronize?: boolean;
|
||||
} & ({ body: string } | { return: string });
|
||||
|
||||
export const registerFunction = (options: FunctionOptions) => {
|
||||
const name = options.name;
|
||||
const expression = asFunctionExpression(options);
|
||||
|
||||
const item: DatabaseFunction = {
|
||||
name,
|
||||
expression,
|
||||
synchronize: options.synchronize ?? true,
|
||||
};
|
||||
|
||||
register({ type: 'function', item });
|
||||
|
||||
return item;
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user