diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d7b6310667..8317640db5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -525,7 +525,7 @@ jobs: - name: Generate new migrations continue-on-error: true - run: npm run typeorm:migrations:generate ./src/migrations/TestMigration + run: npm run migrations:generate TestMigration - name: Find file changes uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20 @@ -538,7 +538,7 @@ jobs: run: | echo "ERROR: Generated migration files not up to date!" echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}" - cat ./src/migrations/*-TestMigration.ts + cat ./src/*-TestMigration.ts exit 1 - name: Run SQL generation diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 5fe62b9651..b1e7d409b1 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -77,6 +77,14 @@ export default [ ], }, ], + + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], }, }, ]; diff --git a/server/package.json b/server/package.json index d600fbad9a..f1d5d4c6b8 100644 --- a/server/package.json +++ b/server/package.json @@ -23,8 +23,8 @@ "test:medium": "vitest --config test/vitest.config.medium.mjs", "typeorm": "typeorm", "lifecycle": "node ./dist/utils/lifecycle.js", - "typeorm:migrations:create": "typeorm migration:create", - "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/bin/database.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", "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;'", diff --git a/server/src/bin/migrations.ts b/server/src/bin/migrations.ts new file mode 100644 index 0000000000..13a149a1a1 --- /dev/null +++ b/server/src/bin/migrations.ts @@ -0,0 +1,112 @@ +#!/usr/bin/env node +process.env.DB_URL = 'postgres://postgres:postgres@localhost:5432/immich'; + +import { writeFileSync } from 'node:fs'; +import postgres from 'postgres'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { DatabaseTable, schemaDiff, schemaFromDatabase, schemaFromDecorators } from 'src/sql-tools'; +import 'src/tables'; + +const main = async () => { + const command = process.argv[2]; + const name = process.argv[3] || 'Migration'; + + switch (command) { + case 'debug': { + await debug(); + return; + } + + case 'create': { + create(name, [], []); + return; + } + + case 'generate': { + await generate(name); + return; + } + + default: { + console.log(`Usage: + node dist/bin/migrations.js create + node dist/bin/migrations.js generate +`); + } + } +}; + +const debug = async () => { + const { up, down } = 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); + console.log('Wrote migrations.sql'); +}; + +const generate = async (name: string) => { + const { up, down } = await compare(); + if (up.items.length === 0) { + console.log('No changes detected'); + return; + } + create(name, up.asSql(), down.asSql()); +}; + +const create = (name: string, up: string[], down: string[]) => { + const { filename, code } = asMigration(name, up, down); + const fullPath = `./src/${filename}`; + writeFileSync(fullPath, code); + console.log(`Wrote ${fullPath}`); +}; + +const compare = async () => { + const configRepository = new ConfigRepository(); + const { database } = configRepository.getEnv(); + const db = postgres(database.config.kysely); + + const source = schemaFromDecorators(); + const target = await schemaFromDatabase(db, {}); + + 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 }); + + return { up, down }; +}; + +const asMigration = (name: string, up: string[], down: string[]) => { + const timestamp = Date.now(); + + const upSql = up.map((sql) => ` await queryRunner.query(\`${sql}\`);`).join('\n'); + const downSql = down.map((sql) => ` await queryRunner.query(\`${sql}\`);`).join('\n'); + return { + filename: `${timestamp}-${name}.ts`, + code: `import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ${name}${timestamp} implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { +${upSql} + } + + public async down(queryRunner: QueryRunner): Promise { +${downSql} + } +} +`, + }; +}; + +main() + .then(() => { + process.exit(0); + }) + .catch((error) => { + console.error(error); + console.log('Something went wrong'); + process.exit(1); + }); diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 85aade2c9b..e315a266cf 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -4,8 +4,9 @@ */ import type { ColumnType } from 'kysely'; -import { OnThisDayData } from 'src/entities/memory.entity'; import { AssetType, MemoryType, Permission, SyncEntityType } from 'src/enum'; +import { UserTable } from 'src/tables/user.table'; +import { OnThisDayData } from 'src/types'; export type ArrayType = ArrayTypeImpl extends (infer U)[] ? U[] : ArrayTypeImpl; @@ -410,26 +411,6 @@ export interface UserMetadata { value: Json; } -export interface Users { - createdAt: Generated; - deletedAt: Timestamp | null; - email: string; - id: Generated; - isAdmin: Generated; - name: Generated; - oauthId: Generated; - password: Generated; - profileChangedAt: Generated; - profileImagePath: Generated; - quotaSizeInBytes: Int8 | null; - quotaUsageInBytes: Generated; - shouldChangePassword: Generated; - status: Generated; - storageLabel: string | null; - updatedAt: Generated; - updateId: Generated; -} - export interface UsersAudit { id: Generated; userId: string; @@ -495,7 +476,7 @@ export interface DB { tags_closure: TagsClosure; typeorm_metadata: TypeormMetadata; user_metadata: UserMetadata; - users: Users; + users: UserTable; users_audit: UsersAudit; 'vectors.pg_vector_index_stat': VectorsPgVectorIndexStat; version_history: VersionHistory; diff --git a/server/src/entities/activity.entity.ts b/server/src/entities/activity.entity.ts deleted file mode 100644 index dabb371977..0000000000 --- a/server/src/entities/activity.entity.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { AlbumEntity } from 'src/entities/album.entity'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { UserEntity } from 'src/entities/user.entity'; -import { - Check, - Column, - CreateDateColumn, - Entity, - Index, - ManyToOne, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; - -@Entity('activity') -@Index('IDX_activity_like', ['assetId', 'userId', 'albumId'], { unique: true, where: '("isLiked" = true)' }) -@Check(`("comment" IS NULL AND "isLiked" = true) OR ("comment" IS NOT NULL AND "isLiked" = false)`) -export class ActivityEntity { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @CreateDateColumn({ type: 'timestamptz' }) - createdAt!: Date; - - @UpdateDateColumn({ type: 'timestamptz' }) - updatedAt!: Date; - - @Index('IDX_activity_update_id') - @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) - updateId?: string; - - @Column() - albumId!: string; - - @Column() - userId!: string; - - @Column({ nullable: true, type: 'uuid' }) - assetId!: string | null; - - @Column({ type: 'text', default: null }) - comment!: string | null; - - @Column({ type: 'boolean', default: false }) - isLiked!: boolean; - - @ManyToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) - asset!: AssetEntity | null; - - @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - user!: UserEntity; - - @ManyToOne(() => AlbumEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - album!: AlbumEntity; -} diff --git a/server/src/entities/album-user.entity.ts b/server/src/entities/album-user.entity.ts index e75b3cd43e..7950ffab7d 100644 --- a/server/src/entities/album-user.entity.ts +++ b/server/src/entities/album-user.entity.ts @@ -1,27 +1,11 @@ import { AlbumEntity } from 'src/entities/album.entity'; import { UserEntity } from 'src/entities/user.entity'; import { AlbumUserRole } from 'src/enum'; -import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; -@Entity('albums_shared_users_users') -// Pre-existing indices from original album <--> user ManyToMany mapping -@Index('IDX_427c350ad49bd3935a50baab73', ['album']) -@Index('IDX_f48513bf9bccefd6ff3ad30bd0', ['user']) export class AlbumUserEntity { - @PrimaryColumn({ type: 'uuid', name: 'albumsId' }) albumId!: string; - - @PrimaryColumn({ type: 'uuid', name: 'usersId' }) userId!: string; - - @JoinColumn({ name: 'albumsId' }) - @ManyToOne(() => AlbumEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) album!: AlbumEntity; - - @JoinColumn({ name: 'usersId' }) - @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) user!: UserEntity; - - @Column({ type: 'varchar', default: AlbumUserRole.EDITOR }) role!: AlbumUserRole; } diff --git a/server/src/entities/album.entity.ts b/server/src/entities/album.entity.ts index 4cd7c82394..946c807a1a 100644 --- a/server/src/entities/album.entity.ts +++ b/server/src/entities/album.entity.ts @@ -3,69 +3,22 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { UserEntity } from 'src/entities/user.entity'; import { AssetOrder } from 'src/enum'; -import { - Column, - CreateDateColumn, - DeleteDateColumn, - Entity, - Index, - JoinTable, - ManyToMany, - ManyToOne, - OneToMany, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; -@Entity('albums') export class AlbumEntity { - @PrimaryGeneratedColumn('uuid') id!: string; - - @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) owner!: UserEntity; - - @Column() ownerId!: string; - - @Column({ default: 'Untitled Album' }) albumName!: string; - - @Column({ type: 'text', default: '' }) description!: string; - - @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; - - @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; - - @Index('IDX_albums_update_id') - @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) updateId?: string; - - @DeleteDateColumn({ type: 'timestamptz' }) deletedAt!: Date | null; - - @ManyToOne(() => AssetEntity, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) albumThumbnailAsset!: AssetEntity | null; - - @Column({ comment: 'Asset ID to be used as thumbnail', nullable: true }) albumThumbnailAssetId!: string | null; - - @OneToMany(() => AlbumUserEntity, ({ album }) => album, { cascade: true, onDelete: 'CASCADE' }) albumUsers!: AlbumUserEntity[]; - - @ManyToMany(() => AssetEntity, (asset) => asset.albums) - @JoinTable({ synchronize: false }) assets!: AssetEntity[]; - - @OneToMany(() => SharedLinkEntity, (link) => link.album) sharedLinks!: SharedLinkEntity[]; - - @Column({ default: true }) isActivityEnabled!: boolean; - - @Column({ type: 'varchar', default: AssetOrder.DESC }) order!: AssetOrder; } diff --git a/server/src/entities/api-key.entity.ts b/server/src/entities/api-key.entity.ts deleted file mode 100644 index f59bf0d918..0000000000 --- a/server/src/entities/api-key.entity.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { UserEntity } from 'src/entities/user.entity'; -import { Permission } from 'src/enum'; -import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; - -@Entity('api_keys') -export class APIKeyEntity { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column() - name!: string; - - @Column({ select: false }) - key?: string; - - @ManyToOne(() => UserEntity, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) - user?: UserEntity; - - @Column() - userId!: string; - - @Column({ array: true, type: 'varchar' }) - permissions!: Permission[]; - - @CreateDateColumn({ type: 'timestamptz' }) - createdAt!: Date; - - @UpdateDateColumn({ type: 'timestamptz' }) - updatedAt!: Date; - - @Index('IDX_api_keys_update_id') - @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) - updateId?: string; -} diff --git a/server/src/entities/asset-audit.entity.ts b/server/src/entities/asset-audit.entity.ts deleted file mode 100644 index 0172d15ce6..0000000000 --- a/server/src/entities/asset-audit.entity.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; - -@Entity('assets_audit') -export class AssetAuditEntity { - @PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) - id!: string; - - @Index('IDX_assets_audit_asset_id') - @Column({ type: 'uuid' }) - assetId!: string; - - @Index('IDX_assets_audit_owner_id') - @Column({ type: 'uuid' }) - ownerId!: string; - - @Index('IDX_assets_audit_deleted_at') - @CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' }) - deletedAt!: Date; -} diff --git a/server/src/entities/asset-face.entity.ts b/server/src/entities/asset-face.entity.ts index b556a8b7cf..dddb6b0f3f 100644 --- a/server/src/entities/asset-face.entity.ts +++ b/server/src/entities/asset-face.entity.ts @@ -2,55 +2,20 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { SourceType } from 'src/enum'; -import { Column, Entity, Index, ManyToOne, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; -@Entity('asset_faces', { synchronize: false }) -@Index('IDX_asset_faces_assetId_personId', ['assetId', 'personId']) -@Index(['personId', 'assetId']) export class AssetFaceEntity { - @PrimaryGeneratedColumn('uuid') id!: string; - - @Column() assetId!: string; - - @Column({ nullable: true, type: 'uuid' }) personId!: string | null; - - @OneToOne(() => FaceSearchEntity, (faceSearchEntity) => faceSearchEntity.face, { cascade: ['insert'] }) faceSearch?: FaceSearchEntity; - - @Column({ default: 0, type: 'int' }) imageWidth!: number; - - @Column({ default: 0, type: 'int' }) imageHeight!: number; - - @Column({ default: 0, type: 'int' }) boundingBoxX1!: number; - - @Column({ default: 0, type: 'int' }) boundingBoxY1!: number; - - @Column({ default: 0, type: 'int' }) boundingBoxX2!: number; - - @Column({ default: 0, type: 'int' }) boundingBoxY2!: number; - - @Column({ default: SourceType.MACHINE_LEARNING, type: 'enum', enum: SourceType }) sourceType!: SourceType; - - @ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) asset!: AssetEntity; - - @ManyToOne(() => PersonEntity, (person) => person.faces, { - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - nullable: true, - }) person!: PersonEntity | null; - - @Column({ type: 'timestamptz' }) deletedAt!: Date | null; } diff --git a/server/src/entities/asset-files.entity.ts b/server/src/entities/asset-files.entity.ts index 09f96e849d..3bd80784b6 100644 --- a/server/src/entities/asset-files.entity.ts +++ b/server/src/entities/asset-files.entity.ts @@ -1,42 +1,13 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { AssetFileType } from 'src/enum'; -import { - Column, - CreateDateColumn, - Entity, - Index, - ManyToOne, - PrimaryGeneratedColumn, - Unique, - UpdateDateColumn, -} from 'typeorm'; -@Unique('UQ_assetId_type', ['assetId', 'type']) -@Entity('asset_files') export class AssetFileEntity { - @PrimaryGeneratedColumn('uuid') id!: string; - - @Index('IDX_asset_files_assetId') - @Column() assetId!: string; - - @ManyToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) asset?: AssetEntity; - - @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; - - @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; - - @Index('IDX_asset_files_update_id') - @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) updateId?: string; - - @Column() type!: AssetFileType; - - @Column() path!: string; } diff --git a/server/src/entities/asset-job-status.entity.ts b/server/src/entities/asset-job-status.entity.ts index 353055df43..2cccfcab3a 100644 --- a/server/src/entities/asset-job-status.entity.ts +++ b/server/src/entities/asset-job-status.entity.ts @@ -1,27 +1,11 @@ import { AssetEntity } from 'src/entities/asset.entity'; -import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; -@Entity('asset_job_status') export class AssetJobStatusEntity { - @OneToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - @JoinColumn() asset!: AssetEntity; - - @PrimaryColumn() assetId!: string; - - @Column({ type: 'timestamptz', nullable: true }) facesRecognizedAt!: Date | null; - - @Column({ type: 'timestamptz', nullable: true }) metadataExtractedAt!: Date | null; - - @Column({ type: 'timestamptz', nullable: true }) duplicatesDetectedAt!: Date | null; - - @Column({ type: 'timestamptz', nullable: true }) previewAt!: Date | null; - - @Column({ type: 'timestamptz', nullable: true }) thumbnailAt!: Date | null; } diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index b2589e1231..836fc409af 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -6,7 +6,6 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { LibraryEntity } from 'src/entities/library.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SmartSearchEntity } from 'src/entities/smart-search.entity'; import { StackEntity } from 'src/entities/stack.entity'; @@ -16,171 +15,49 @@ import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { TimeBucketSize } from 'src/repositories/asset.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { anyUuid, asUuid } from 'src/utils/database'; -import { - Column, - CreateDateColumn, - DeleteDateColumn, - Entity, - Index, - JoinColumn, - JoinTable, - ManyToMany, - ManyToOne, - OneToMany, - OneToOne, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum'; -@Entity('assets') -// Checksums must be unique per user and library -@Index(ASSET_CHECKSUM_CONSTRAINT, ['owner', 'checksum'], { - unique: true, - where: '"libraryId" IS NULL', -}) -@Index('UQ_assets_owner_library_checksum' + '', ['owner', 'library', 'checksum'], { - unique: true, - where: '"libraryId" IS NOT NULL', -}) -@Index('idx_local_date_time', { synchronize: false }) -@Index('idx_local_date_time_month', { synchronize: false }) -@Index('IDX_originalPath_libraryId', ['originalPath', 'libraryId']) -@Index('IDX_asset_id_stackId', ['id', 'stackId']) -@Index('idx_originalFileName_trigram', { synchronize: false }) -// For all assets, each originalpath must be unique per user and library export class AssetEntity { - @PrimaryGeneratedColumn('uuid') id!: string; - - @Column() deviceAssetId!: string; - - @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) owner!: UserEntity; - - @Column() ownerId!: string; - - @ManyToOne(() => LibraryEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - library?: LibraryEntity | null; - - @Column({ nullable: true }) libraryId?: string | null; - - @Column() deviceId!: string; - - @Column() type!: AssetType; - - @Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE }) status!: AssetStatus; - - @Column() originalPath!: string; - - @OneToMany(() => AssetFileEntity, (assetFile) => assetFile.asset) files!: AssetFileEntity[]; - - @Column({ type: 'bytea', nullable: true }) thumbhash!: Buffer | null; - - @Column({ type: 'varchar', nullable: true, default: '' }) encodedVideoPath!: string | null; - - @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; - - @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; - - @Index('IDX_assets_update_id') - @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) updateId?: string; - - @DeleteDateColumn({ type: 'timestamptz', nullable: true }) deletedAt!: Date | null; - - @Index('idx_asset_file_created_at') - @Column({ type: 'timestamptz', nullable: true, default: null }) fileCreatedAt!: Date; - - @Column({ type: 'timestamptz', nullable: true, default: null }) localDateTime!: Date; - - @Column({ type: 'timestamptz', nullable: true, default: null }) fileModifiedAt!: Date; - - @Column({ type: 'boolean', default: false }) isFavorite!: boolean; - - @Column({ type: 'boolean', default: false }) isArchived!: boolean; - - @Column({ type: 'boolean', default: false }) isExternal!: boolean; - - @Column({ type: 'boolean', default: false }) isOffline!: boolean; - - @Column({ type: 'bytea' }) - @Index() checksum!: Buffer; // sha1 checksum - - @Column({ type: 'varchar', nullable: true }) duration!: string | null; - - @Column({ type: 'boolean', default: true }) isVisible!: boolean; - - @ManyToOne(() => AssetEntity, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' }) - @JoinColumn() livePhotoVideo!: AssetEntity | null; - - @Column({ nullable: true }) livePhotoVideoId!: string | null; - - @Column({ type: 'varchar' }) - @Index() originalFileName!: string; - - @Column({ type: 'varchar', nullable: true }) sidecarPath!: string | null; - - @OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset) exifInfo?: ExifEntity; - - @OneToOne(() => SmartSearchEntity, (smartSearchEntity) => smartSearchEntity.asset) smartSearch?: SmartSearchEntity; - - @ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true }) - @JoinTable({ name: 'tag_asset', synchronize: false }) tags!: TagEntity[]; - - @ManyToMany(() => SharedLinkEntity, (link) => link.assets, { cascade: true }) - @JoinTable({ name: 'shared_link__asset' }) sharedLinks!: SharedLinkEntity[]; - - @ManyToMany(() => AlbumEntity, (album) => album.assets, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) albums?: AlbumEntity[]; - - @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset) faces!: AssetFaceEntity[]; - - @Column({ nullable: true }) stackId?: string | null; - - @ManyToOne(() => StackEntity, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) - @JoinColumn() stack?: StackEntity | null; - - @OneToOne(() => AssetJobStatusEntity, (jobStatus) => jobStatus.asset, { nullable: true }) jobStatus?: AssetJobStatusEntity; - - @Index('IDX_assets_duplicateId') - @Column({ type: 'uuid', nullable: true }) duplicateId!: string | null; } diff --git a/server/src/entities/audit.entity.ts b/server/src/entities/audit.entity.ts deleted file mode 100644 index 7f51e17585..0000000000 --- a/server/src/entities/audit.entity.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { DatabaseAction, EntityType } from 'src/enum'; -import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; - -@Entity('audit') -@Index('IDX_ownerId_createdAt', ['ownerId', 'createdAt']) -export class AuditEntity { - @PrimaryGeneratedColumn('increment') - id!: number; - - @Column() - entityType!: EntityType; - - @Column({ type: 'uuid' }) - entityId!: string; - - @Column() - action!: DatabaseAction; - - @Column({ type: 'uuid' }) - ownerId!: string; - - @CreateDateColumn({ type: 'timestamptz' }) - createdAt!: Date; -} diff --git a/server/src/entities/exif.entity.ts b/server/src/entities/exif.entity.ts index 5b402109a5..75064b7917 100644 --- a/server/src/entities/exif.entity.ts +++ b/server/src/entities/exif.entity.ts @@ -1,111 +1,36 @@ import { AssetEntity } from 'src/entities/asset.entity'; -import { Index, JoinColumn, OneToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; -import { Column } from 'typeorm/decorator/columns/Column.js'; -import { Entity } from 'typeorm/decorator/entity/Entity.js'; -@Entity('exif') export class ExifEntity { - @OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true }) - @JoinColumn() asset?: AssetEntity; - - @PrimaryColumn() assetId!: string; - - @UpdateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' }) updatedAt?: Date; - - @Index('IDX_asset_exif_update_id') - @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) updateId?: string; - - /* General info */ - @Column({ type: 'text', default: '' }) description!: string; // or caption - - @Column({ type: 'integer', nullable: true }) exifImageWidth!: number | null; - - @Column({ type: 'integer', nullable: true }) exifImageHeight!: number | null; - - @Column({ type: 'bigint', nullable: true }) fileSizeInByte!: number | null; - - @Column({ type: 'varchar', nullable: true }) orientation!: string | null; - - @Column({ type: 'timestamptz', nullable: true }) dateTimeOriginal!: Date | null; - - @Column({ type: 'timestamptz', nullable: true }) modifyDate!: Date | null; - - @Column({ type: 'varchar', nullable: true }) timeZone!: string | null; - - @Column({ type: 'float', nullable: true }) latitude!: number | null; - - @Column({ type: 'float', nullable: true }) longitude!: number | null; - - @Column({ type: 'varchar', nullable: true }) projectionType!: string | null; - - @Index('exif_city') - @Column({ type: 'varchar', nullable: true }) city!: string | null; - - @Index('IDX_live_photo_cid') - @Column({ type: 'varchar', nullable: true }) livePhotoCID!: string | null; - - @Index('IDX_auto_stack_id') - @Column({ type: 'varchar', nullable: true }) autoStackId!: string | null; - - @Column({ type: 'varchar', nullable: true }) state!: string | null; - - @Column({ type: 'varchar', nullable: true }) country!: string | null; - - /* Image info */ - @Column({ type: 'varchar', nullable: true }) make!: string | null; - - @Column({ type: 'varchar', nullable: true }) model!: string | null; - - @Column({ type: 'varchar', nullable: true }) lensModel!: string | null; - - @Column({ type: 'float8', nullable: true }) fNumber!: number | null; - - @Column({ type: 'float8', nullable: true }) focalLength!: number | null; - - @Column({ type: 'integer', nullable: true }) iso!: number | null; - - @Column({ type: 'varchar', nullable: true }) exposureTime!: string | null; - - @Column({ type: 'varchar', nullable: true }) profileDescription!: string | null; - - @Column({ type: 'varchar', nullable: true }) colorspace!: string | null; - - @Column({ type: 'integer', nullable: true }) bitsPerSample!: number | null; - - @Column({ type: 'integer', nullable: true }) rating!: number | null; - - /* Video info */ - @Column({ type: 'float8', nullable: true }) fps?: number | null; } diff --git a/server/src/entities/face-search.entity.ts b/server/src/entities/face-search.entity.ts index e907ba6c9e..701fd9e580 100644 --- a/server/src/entities/face-search.entity.ts +++ b/server/src/entities/face-search.entity.ts @@ -1,16 +1,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { Column, Entity, Index, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; -@Entity('face_search', { synchronize: false }) export class FaceSearchEntity { - @OneToOne(() => AssetFaceEntity, { onDelete: 'CASCADE', nullable: true }) - @JoinColumn({ name: 'faceId', referencedColumnName: 'id' }) face?: AssetFaceEntity; - - @PrimaryColumn() faceId!: string; - - @Index('face_index', { synchronize: false }) - @Column({ type: 'float4', array: true }) embedding!: string; } diff --git a/server/src/entities/geodata-places.entity.ts b/server/src/entities/geodata-places.entity.ts index eb32d1b99b..aad6c38dda 100644 --- a/server/src/entities/geodata-places.entity.ts +++ b/server/src/entities/geodata-places.entity.ts @@ -1,73 +1,13 @@ -import { Column, Entity, PrimaryColumn } from 'typeorm'; - -@Entity('geodata_places', { synchronize: false }) export class GeodataPlacesEntity { - @PrimaryColumn({ type: 'integer' }) id!: number; - - @Column({ type: 'varchar', length: 200 }) name!: string; - - @Column({ type: 'float' }) longitude!: number; - - @Column({ type: 'float' }) latitude!: number; - - @Column({ type: 'char', length: 2 }) countryCode!: string; - - @Column({ type: 'varchar', length: 20, nullable: true }) admin1Code!: string; - - @Column({ type: 'varchar', length: 80, nullable: true }) admin2Code!: string; - - @Column({ type: 'varchar', nullable: true }) admin1Name!: string; - - @Column({ type: 'varchar', nullable: true }) admin2Name!: string; - - @Column({ type: 'varchar', nullable: true }) alternateNames!: string; - - @Column({ type: 'date' }) - modificationDate!: Date; -} - -@Entity('geodata_places_tmp', { synchronize: false }) -export class GeodataPlacesTempEntity { - @PrimaryColumn({ type: 'integer' }) - id!: number; - - @Column({ type: 'varchar', length: 200 }) - name!: string; - - @Column({ type: 'float' }) - longitude!: number; - - @Column({ type: 'float' }) - latitude!: number; - - @Column({ type: 'char', length: 2 }) - countryCode!: string; - - @Column({ type: 'varchar', length: 20, nullable: true }) - admin1Code!: string; - - @Column({ type: 'varchar', length: 80, nullable: true }) - admin2Code!: string; - - @Column({ type: 'varchar', nullable: true }) - admin1Name!: string; - - @Column({ type: 'varchar', nullable: true }) - admin2Name!: string; - - @Column({ type: 'varchar', nullable: true }) - alternateNames!: string; - - @Column({ type: 'date' }) modificationDate!: Date; } diff --git a/server/src/entities/library.entity.ts b/server/src/entities/library.entity.ts deleted file mode 100644 index 0471661fca..0000000000 --- a/server/src/entities/library.entity.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { AssetEntity } from 'src/entities/asset.entity'; -import { UserEntity } from 'src/entities/user.entity'; -import { - Column, - CreateDateColumn, - DeleteDateColumn, - Entity, - Index, - JoinTable, - ManyToOne, - OneToMany, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; - -@Entity('libraries') -export class LibraryEntity { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column() - name!: string; - - @OneToMany(() => AssetEntity, (asset) => asset.library) - @JoinTable() - assets!: AssetEntity[]; - - @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) - owner?: UserEntity; - - @Column() - ownerId!: string; - - @Column('text', { array: true }) - importPaths!: string[]; - - @Column('text', { array: true }) - exclusionPatterns!: string[]; - - @CreateDateColumn({ type: 'timestamptz' }) - createdAt!: Date; - - @UpdateDateColumn({ type: 'timestamptz' }) - updatedAt!: Date; - - @Index('IDX_libraries_update_id') - @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) - updateId?: string; - - @DeleteDateColumn({ type: 'timestamptz' }) - deletedAt?: Date; - - @Column({ type: 'timestamptz', nullable: true }) - refreshedAt!: Date | null; -} diff --git a/server/src/entities/memory.entity.ts b/server/src/entities/memory.entity.ts deleted file mode 100644 index dafd7eb21c..0000000000 --- a/server/src/entities/memory.entity.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { AssetEntity } from 'src/entities/asset.entity'; -import { UserEntity } from 'src/entities/user.entity'; -import { MemoryType } from 'src/enum'; -import { - Column, - CreateDateColumn, - DeleteDateColumn, - Entity, - Index, - JoinTable, - ManyToMany, - ManyToOne, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; - -export type OnThisDayData = { year: number }; - -export interface MemoryData { - [MemoryType.ON_THIS_DAY]: OnThisDayData; -} - -@Entity('memories') -export class MemoryEntity { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @CreateDateColumn({ type: 'timestamptz' }) - createdAt!: Date; - - @UpdateDateColumn({ type: 'timestamptz' }) - updatedAt!: Date; - - @Index('IDX_memories_update_id') - @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) - updateId?: string; - - @DeleteDateColumn({ type: 'timestamptz' }) - deletedAt?: Date; - - @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) - owner!: UserEntity; - - @Column() - ownerId!: string; - - @Column() - type!: T; - - @Column({ type: 'jsonb' }) - data!: MemoryData[T]; - - /** unless set to true, will be automatically deleted in the future */ - @Column({ default: false }) - isSaved!: boolean; - - /** memories are sorted in ascending order by this value */ - @Column({ type: 'timestamptz' }) - memoryAt!: Date; - - @Column({ type: 'timestamptz', nullable: true }) - showAt?: Date; - - @Column({ type: 'timestamptz', nullable: true }) - hideAt?: Date; - - /** when the user last viewed the memory */ - @Column({ type: 'timestamptz', nullable: true }) - seenAt?: Date; - - @ManyToMany(() => AssetEntity) - @JoinTable() - assets!: AssetEntity[]; -} diff --git a/server/src/entities/move.entity.ts b/server/src/entities/move.entity.ts index 7a998eaebe..0570d98edc 100644 --- a/server/src/entities/move.entity.ts +++ b/server/src/entities/move.entity.ts @@ -1,24 +1,9 @@ import { PathType } from 'src/enum'; -import { Column, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm'; -@Entity('move_history') -// path lock (per entity) -@Unique('UQ_entityId_pathType', ['entityId', 'pathType']) -// new path lock (global) -@Unique('UQ_newPath', ['newPath']) export class MoveEntity { - @PrimaryGeneratedColumn('uuid') id!: string; - - @Column({ type: 'uuid' }) entityId!: string; - - @Column({ type: 'varchar' }) pathType!: PathType; - - @Column({ type: 'varchar' }) oldPath!: string; - - @Column({ type: 'varchar' }) newPath!: string; } diff --git a/server/src/entities/natural-earth-countries.entity.ts b/server/src/entities/natural-earth-countries.entity.ts index 0f97132045..50bce3e034 100644 --- a/server/src/entities/natural-earth-countries.entity.ts +++ b/server/src/entities/natural-earth-countries.entity.ts @@ -1,37 +1,7 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; - -@Entity('naturalearth_countries', { synchronize: false }) -export class NaturalEarthCountriesEntity { - @PrimaryGeneratedColumn('identity', { generatedIdentity: 'ALWAYS' }) - id!: number; - - @Column({ type: 'varchar', length: 50 }) - admin!: string; - - @Column({ type: 'varchar', length: 3 }) - admin_a3!: string; - - @Column({ type: 'varchar', length: 50 }) - type!: string; - - @Column({ type: 'polygon' }) - coordinates!: string; -} - -@Entity('naturalearth_countries_tmp', { synchronize: false }) export class NaturalEarthCountriesTempEntity { - @PrimaryGeneratedColumn('identity', { generatedIdentity: 'ALWAYS' }) id!: number; - - @Column({ type: 'varchar', length: 50 }) admin!: string; - - @Column({ type: 'varchar', length: 3 }) admin_a3!: string; - - @Column({ type: 'varchar', length: 50 }) type!: string; - - @Column({ type: 'polygon' }) coordinates!: string; } diff --git a/server/src/entities/partner-audit.entity.ts b/server/src/entities/partner-audit.entity.ts deleted file mode 100644 index a731e017dc..0000000000 --- a/server/src/entities/partner-audit.entity.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; - -@Entity('partners_audit') -export class PartnerAuditEntity { - @PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) - id!: string; - - @Index('IDX_partners_audit_shared_by_id') - @Column({ type: 'uuid' }) - sharedById!: string; - - @Index('IDX_partners_audit_shared_with_id') - @Column({ type: 'uuid' }) - sharedWithId!: string; - - @Index('IDX_partners_audit_deleted_at') - @CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' }) - deletedAt!: Date; -} diff --git a/server/src/entities/partner.entity.ts b/server/src/entities/partner.entity.ts deleted file mode 100644 index 5326757736..0000000000 --- a/server/src/entities/partner.entity.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { UserEntity } from 'src/entities/user.entity'; -import { - Column, - CreateDateColumn, - Entity, - Index, - JoinColumn, - ManyToOne, - PrimaryColumn, - UpdateDateColumn, -} from 'typeorm'; - -/** @deprecated delete after coming up with a migration workflow for kysely */ -@Entity('partners') -export class PartnerEntity { - @PrimaryColumn('uuid') - sharedById!: string; - - @PrimaryColumn('uuid') - sharedWithId!: string; - - @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', eager: true }) - @JoinColumn({ name: 'sharedById' }) - sharedBy!: UserEntity; - - @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', eager: true }) - @JoinColumn({ name: 'sharedWithId' }) - sharedWith!: UserEntity; - - @CreateDateColumn({ type: 'timestamptz' }) - createdAt!: Date; - - @UpdateDateColumn({ type: 'timestamptz' }) - updatedAt!: Date; - - @Index('IDX_partners_update_id') - @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) - updateId?: string; - - @Column({ type: 'boolean', default: false }) - inTimeline!: boolean; -} diff --git a/server/src/entities/person.entity.ts b/server/src/entities/person.entity.ts index 5efa602cc8..6ea97b21bc 100644 --- a/server/src/entities/person.entity.ts +++ b/server/src/entities/person.entity.ts @@ -1,63 +1,20 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { - Check, - Column, - CreateDateColumn, - Entity, - Index, - ManyToOne, - OneToMany, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; -@Entity('person') -@Check(`"birthDate" <= CURRENT_DATE`) export class PersonEntity { - @PrimaryGeneratedColumn('uuid') id!: string; - - @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; - - @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; - - @Index('IDX_person_update_id') - @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) updateId?: string; - - @Column() ownerId!: string; - - @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) owner!: UserEntity; - - @Column({ default: '' }) name!: string; - - @Column({ type: 'date', nullable: true }) birthDate!: Date | string | null; - - @Column({ default: '' }) thumbnailPath!: string; - - @Column({ nullable: true }) faceAssetId!: string | null; - - @ManyToOne(() => AssetFaceEntity, { onDelete: 'SET NULL', nullable: true }) faceAsset!: AssetFaceEntity | null; - - @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.person) faces!: AssetFaceEntity[]; - - @Column({ default: false }) isHidden!: boolean; - - @Column({ default: false }) isFavorite!: boolean; - - @Column({ type: 'varchar', nullable: true, default: null }) color?: string | null; } diff --git a/server/src/entities/session.entity.ts b/server/src/entities/session.entity.ts index cb208c958e..45856ff2af 100644 --- a/server/src/entities/session.entity.ts +++ b/server/src/entities/session.entity.ts @@ -1,36 +1,16 @@ import { ExpressionBuilder } from 'kysely'; import { DB } from 'src/db'; import { UserEntity } from 'src/entities/user.entity'; -import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; -@Entity('sessions') export class SessionEntity { - @PrimaryGeneratedColumn('uuid') id!: string; - - @Column({ select: false }) token!: string; - - @Column() userId!: string; - - @ManyToOne(() => UserEntity, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) user!: UserEntity; - - @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; - - @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; - - @Index('IDX_sessions_update_id') - @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) updateId!: string; - - @Column({ default: '' }) deviceType!: string; - - @Column({ default: '' }) deviceOS!: string; } diff --git a/server/src/entities/shared-link.entity.ts b/server/src/entities/shared-link.entity.ts index 1fed44b301..5ce0247be7 100644 --- a/server/src/entities/shared-link.entity.ts +++ b/server/src/entities/shared-link.entity.ts @@ -2,64 +2,21 @@ import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { UserEntity } from 'src/entities/user.entity'; import { SharedLinkType } from 'src/enum'; -import { - Column, - CreateDateColumn, - Entity, - Index, - ManyToMany, - ManyToOne, - PrimaryGeneratedColumn, - Unique, -} from 'typeorm'; -@Entity('shared_links') -@Unique('UQ_sharedlink_key', ['key']) export class SharedLinkEntity { - @PrimaryGeneratedColumn('uuid') id!: string; - - @Column({ type: 'varchar', nullable: true }) description!: string | null; - - @Column({ type: 'varchar', nullable: true }) password!: string | null; - - @Column() userId!: string; - - @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) user!: UserEntity; - - @Index('IDX_sharedlink_key') - @Column({ type: 'bytea' }) key!: Buffer; // use to access the inidividual asset - - @Column() type!: SharedLinkType; - - @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; - - @Column({ type: 'timestamptz', nullable: true }) expiresAt!: Date | null; - - @Column({ type: 'boolean', default: false }) allowUpload!: boolean; - - @Column({ type: 'boolean', default: true }) allowDownload!: boolean; - - @Column({ type: 'boolean', default: true }) showExif!: boolean; - - @ManyToMany(() => AssetEntity, (asset) => asset.sharedLinks, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) assets!: AssetEntity[]; - - @Index('IDX_sharedlink_albumId') - @ManyToOne(() => AlbumEntity, (album) => album.sharedLinks, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) album?: AlbumEntity; - - @Column({ type: 'varchar', nullable: true }) albumId!: string | null; } diff --git a/server/src/entities/smart-search.entity.ts b/server/src/entities/smart-search.entity.ts index 42245a17fb..e8a8f27cb1 100644 --- a/server/src/entities/smart-search.entity.ts +++ b/server/src/entities/smart-search.entity.ts @@ -1,16 +1,7 @@ import { AssetEntity } from 'src/entities/asset.entity'; -import { Column, Entity, Index, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; -@Entity('smart_search', { synchronize: false }) export class SmartSearchEntity { - @OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true }) - @JoinColumn({ name: 'assetId', referencedColumnName: 'id' }) asset?: AssetEntity; - - @PrimaryColumn() assetId!: string; - - @Index('clip_index', { synchronize: false }) - @Column({ type: 'float4', array: true }) embedding!: string; } diff --git a/server/src/entities/stack.entity.ts b/server/src/entities/stack.entity.ts index 883f5cf246..8b8fd94f38 100644 --- a/server/src/entities/stack.entity.ts +++ b/server/src/entities/stack.entity.ts @@ -1,28 +1,12 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; -@Entity('asset_stack') export class StackEntity { - @PrimaryGeneratedColumn('uuid') id!: string; - - @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) owner!: UserEntity; - - @Column() ownerId!: string; - - @OneToMany(() => AssetEntity, (asset) => asset.stack) assets!: AssetEntity[]; - - @OneToOne(() => AssetEntity) - @JoinColumn() - //TODO: Add constraint to ensure primary asset exists in the assets array primaryAsset!: AssetEntity; - - @Column({ nullable: false }) primaryAssetId!: string; - assetCount?: number; } diff --git a/server/src/entities/sync-checkpoint.entity.ts b/server/src/entities/sync-checkpoint.entity.ts deleted file mode 100644 index 7c6818aba0..0000000000 --- a/server/src/entities/sync-checkpoint.entity.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { SessionEntity } from 'src/entities/session.entity'; -import { SyncEntityType } from 'src/enum'; -import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; - -@Entity('session_sync_checkpoints') -export class SessionSyncCheckpointEntity { - @ManyToOne(() => SessionEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - session?: SessionEntity; - - @PrimaryColumn() - sessionId!: string; - - @PrimaryColumn({ type: 'varchar' }) - type!: SyncEntityType; - - @CreateDateColumn({ type: 'timestamptz' }) - createdAt!: Date; - - @UpdateDateColumn({ type: 'timestamptz' }) - updatedAt!: Date; - - @Index('IDX_session_sync_checkpoints_update_id') - @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) - updateId?: string; - - @Column() - ack!: string; -} diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts deleted file mode 100644 index b024862ba5..0000000000 --- a/server/src/entities/system-metadata.entity.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { SystemConfig } from 'src/config'; -import { StorageFolder, SystemMetadataKey } from 'src/enum'; -import { DeepPartial } from 'src/types'; -import { Column, Entity, PrimaryColumn } from 'typeorm'; - -@Entity('system_metadata') -export class SystemMetadataEntity { - @PrimaryColumn({ type: 'varchar' }) - key!: T; - - @Column({ type: 'jsonb' }) - value!: SystemMetadata[T]; -} - -export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string }; -export type SystemFlags = { mountChecks: Record }; -export type MemoriesState = { - /** memories have already been created through this date */ - lastOnThisDayDate: string; -}; - -export interface SystemMetadata extends Record> { - [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; - [SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string }; - [SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date }; - [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; - [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial; - [SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial; - [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; - [SystemMetadataKey.MEMORIES_STATE]: MemoriesState; -} diff --git a/server/src/entities/tag.entity.ts b/server/src/entities/tag.entity.ts index fcbde6c779..01235085a4 100644 --- a/server/src/entities/tag.entity.ts +++ b/server/src/entities/tag.entity.ts @@ -1,58 +1,17 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { - Column, - CreateDateColumn, - Entity, - Index, - ManyToMany, - ManyToOne, - PrimaryGeneratedColumn, - Tree, - TreeChildren, - TreeParent, - Unique, - UpdateDateColumn, -} from 'typeorm'; -@Entity('tags') -@Unique(['userId', 'value']) -@Tree('closure-table') export class TagEntity { - @PrimaryGeneratedColumn('uuid') id!: string; - - @Column() value!: string; - - @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; - - @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; - - @Index('IDX_tags_update_id') - @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) updateId?: string; - - @Column({ type: 'varchar', nullable: true, default: null }) color!: string | null; - - @Column({ nullable: true }) parentId?: string; - - @TreeParent({ onDelete: 'CASCADE' }) parent?: TagEntity; - - @TreeChildren() children?: TagEntity[]; - - @ManyToOne(() => UserEntity, (user) => user.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) user?: UserEntity; - - @Column() userId!: string; - - @ManyToMany(() => AssetEntity, (asset) => asset.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) assets?: AssetEntity[]; } diff --git a/server/src/entities/user-audit.entity.ts b/server/src/entities/user-audit.entity.ts deleted file mode 100644 index c29bc94d97..0000000000 --- a/server/src/entities/user-audit.entity.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; - -@Entity('users_audit') -export class UserAuditEntity { - @PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) - id!: string; - - @Column({ type: 'uuid' }) - userId!: string; - - @Index('IDX_users_audit_deleted_at') - @CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' }) - deletedAt!: Date; -} diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index 8c7a13ed0d..065f4deac3 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -2,25 +2,16 @@ import { UserEntity } from 'src/entities/user.entity'; import { UserAvatarColor, UserMetadataKey } from 'src/enum'; import { DeepPartial } from 'src/types'; import { HumanReadableSize } from 'src/utils/bytes'; -import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; export type UserMetadataItem = { key: T; value: UserMetadata[T]; }; -@Entity('user_metadata') export class UserMetadataEntity implements UserMetadataItem { - @PrimaryColumn({ type: 'uuid' }) userId!: string; - - @ManyToOne(() => UserEntity, (user) => user.metadata, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) user?: UserEntity; - - @PrimaryColumn({ type: 'varchar' }) key!: T; - - @Column({ type: 'jsonb' }) value!: UserMetadata[T]; } diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index 5758e29098..5035f96274 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -2,82 +2,28 @@ import { ExpressionBuilder } from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { DB } from 'src/db'; import { AssetEntity } from 'src/entities/asset.entity'; -import { TagEntity } from 'src/entities/tag.entity'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserStatus } from 'src/enum'; -import { - Column, - CreateDateColumn, - DeleteDateColumn, - Entity, - Index, - OneToMany, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; -@Entity('users') -@Index('IDX_users_updated_at_asc_id_asc', ['updatedAt', 'id']) export class UserEntity { - @PrimaryGeneratedColumn('uuid') id!: string; - - @Column({ default: '' }) name!: string; - - @Column({ default: false }) isAdmin!: boolean; - - @Column({ unique: true }) email!: string; - - @Column({ type: 'varchar', unique: true, default: null }) storageLabel!: string | null; - - @Column({ default: '', select: false }) password?: string; - - @Column({ default: '' }) oauthId!: string; - - @Column({ default: '' }) profileImagePath!: string; - - @Column({ default: true }) shouldChangePassword!: boolean; - - @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; - - @DeleteDateColumn({ type: 'timestamptz' }) deletedAt!: Date | null; - - @Column({ type: 'varchar', default: UserStatus.ACTIVE }) status!: UserStatus; - - @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; - - @Index('IDX_users_update_id') - @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) updateId?: string; - - @OneToMany(() => TagEntity, (tag) => tag.user) - tags!: TagEntity[]; - - @OneToMany(() => AssetEntity, (asset) => asset.owner) assets!: AssetEntity[]; - - @Column({ type: 'bigint', nullable: true }) quotaSizeInBytes!: number | null; - - @Column({ type: 'bigint', default: 0 }) quotaUsageInBytes!: number; - - @OneToMany(() => UserMetadataEntity, (metadata) => metadata.user) metadata!: UserMetadataEntity[]; - - @Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) profileChangedAt!: Date; } diff --git a/server/src/entities/version-history.entity.ts b/server/src/entities/version-history.entity.ts deleted file mode 100644 index edccd9aed6..0000000000 --- a/server/src/entities/version-history.entity.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'; - -@Entity('version_history') -export class VersionHistoryEntity { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @CreateDateColumn({ type: 'timestamptz' }) - createdAt!: Date; - - @Column() - version!: string; -} diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index 2d5f2bc2e2..8cbb87b0c5 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -316,9 +316,9 @@ const getEnv = (): EnvData => { config: { typeorm: { type: 'postgres', - entities: [`${folders.dist}/entities` + '/*.entity.{js,ts}'], + entities: [], migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'], - subscribers: [`${folders.dist}/subscribers` + '/*.{js,ts}'], + subscribers: [], migrationsRun: false, synchronize: false, connectTimeoutMS: 10_000, // 10 seconds diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index d5855d3b91..01b45bd94b 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -9,7 +9,6 @@ import { PersonEntity } from 'src/entities/person.entity'; import { SourceType } from 'src/enum'; import { removeUndefinedKeys } from 'src/utils/database'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; -import { FindOptionsRelations } from 'typeorm'; export interface PersonSearchOptions { minimumFaceCount: number; @@ -247,7 +246,7 @@ export class PersonRepository { @GenerateSql({ params: [DummyValue.UUID] }) getFaceByIdWithAssets( id: string, - relations?: FindOptionsRelations, + relations?: { faceSearch?: boolean }, select?: SelectFaceOptions, ): Promise { return this.db diff --git a/server/src/repositories/system-metadata.repository.ts b/server/src/repositories/system-metadata.repository.ts index a110b9bc44..2038f204f7 100644 --- a/server/src/repositories/system-metadata.repository.ts +++ b/server/src/repositories/system-metadata.repository.ts @@ -4,7 +4,7 @@ import { InjectKysely } from 'nestjs-kysely'; import { readFile } from 'node:fs/promises'; import { DB, SystemMetadata as DbSystemMetadata } from 'src/db'; import { GenerateSql } from 'src/decorators'; -import { SystemMetadata } from 'src/entities/system-metadata.entity'; +import { SystemMetadata } from 'src/types'; type Upsert = Insertable; diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 055df9dfdc..758f99eec1 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -3,11 +3,12 @@ import { Insertable, Kysely, sql, Updateable } from 'kysely'; import { DateTime } from 'luxon'; import { InjectKysely } from 'nestjs-kysely'; import { columns, UserAdmin } from 'src/database'; -import { DB, UserMetadata as DbUserMetadata, Users } from 'src/db'; +import { DB, UserMetadata as DbUserMetadata } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { UserMetadata, UserMetadataItem } from 'src/entities/user-metadata.entity'; import { UserEntity, withMetadata } from 'src/entities/user.entity'; import { AssetType, UserStatus } from 'src/enum'; +import { UserTable } from 'src/tables/user.table'; import { asUuid } from 'src/utils/database'; type Upsert = Insertable; @@ -128,7 +129,7 @@ export class UserRepository { .execute() as Promise; } - async create(dto: Insertable): Promise { + async create(dto: Insertable): Promise { return this.db .insertInto('users') .values(dto) @@ -136,7 +137,7 @@ export class UserRepository { .executeTakeFirst() as unknown as Promise; } - update(id: string, dto: Updateable): Promise { + update(id: string, dto: Updateable): Promise { return this.db .updateTable('users') .set(dto) diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index f8c995c007..efdff0e480 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -4,7 +4,6 @@ import sanitize from 'sanitize-filename'; import { SystemConfig } from 'src/config'; import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { Users } from 'src/db'; import { UserEntity } from 'src/entities/user.entity'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; @@ -47,6 +46,7 @@ import { TrashRepository } from 'src/repositories/trash.repository'; import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; +import { UserTable } from 'src/tables/user.table'; import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; import { getConfig, updateConfig } from 'src/utils/config'; @@ -138,7 +138,7 @@ export class BaseService { return checkAccess(this.accessRepository, request); } - async createUser(dto: Insertable & { email: string }): Promise { + async createUser(dto: Insertable & { email: string }): Promise { const user = await this.userRepository.getByEmail(dto.email); if (user) { throw new BadRequestException('User exists'); @@ -151,7 +151,7 @@ export class BaseService { } } - const payload: Insertable = { ...dto }; + const payload: Insertable = { ...dto }; if (payload.password) { payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS); } diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 8ad3c27b4d..3d3d10540b 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -4,9 +4,9 @@ import { OnJob } from 'src/decorators'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; -import { OnThisDayData } from 'src/entities/memory.entity'; import { JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; +import { OnThisDayData } from 'src/types'; import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util'; const DAYS = 3; diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index e297910a95..c6c3ce4e4f 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -451,11 +451,11 @@ export class PersonService extends BaseService { return JobStatus.SKIPPED; } - const face = await this.personRepository.getFaceByIdWithAssets( - id, - { person: true, asset: true, faceSearch: true }, - ['id', 'personId', 'sourceType'], - ); + const face = await this.personRepository.getFaceByIdWithAssets(id, { faceSearch: true }, [ + 'id', + 'personId', + 'sourceType', + ]); if (!face || !face.asset) { this.logger.warn(`Face ${id} not found`); return JobStatus.FAILED; diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index ca1d9e7921..99d89df099 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -2,10 +2,9 @@ import { Injectable } from '@nestjs/common'; import { join } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; -import { SystemFlags } from 'src/entities/system-metadata.entity'; import { DatabaseLock, JobName, JobStatus, QueueName, StorageFolder, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { JobOf } from 'src/types'; +import { JobOf, SystemFlags } from 'src/types'; import { ImmichStartupError } from 'src/utils/misc'; const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`; diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index ee28a20d4d..869acc269c 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -4,10 +4,10 @@ import semver, { SemVer } from 'semver'; import { serverVersion } from 'src/constants'; import { OnEvent, OnJob } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; -import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { DatabaseLock, ImmichEnvironment, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; +import { VersionCheckMetadata } from 'src/types'; const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => { return { diff --git a/server/src/sql-tools/decorators.ts b/server/src/sql-tools/decorators.ts new file mode 100644 index 0000000000..88b3e4c7d1 --- /dev/null +++ b/server/src/sql-tools/decorators.ts @@ -0,0 +1,107 @@ +/* eslint-disable @typescript-eslint/no-unsafe-function-type */ +import { register } from 'src/sql-tools/schema-from-decorators'; +import { + CheckOptions, + ColumnDefaultValue, + ColumnIndexOptions, + ColumnOptions, + ForeignKeyColumnOptions, + GenerateColumnOptions, + IndexOptions, + TableOptions, + UniqueOptions, +} from 'src/sql-tools/types'; + +export const Table = (options: string | TableOptions = {}): ClassDecorator => { + return (object: Function) => void register({ type: 'table', item: { object, options: asOptions(options) } }); +}; + +export const Column = (options: string | ColumnOptions = {}): PropertyDecorator => { + return (object: object, propertyName: string | symbol) => + void register({ type: 'column', item: { object, propertyName, options: asOptions(options) } }); +}; + +export const Index = (options: string | IndexOptions = {}): ClassDecorator => { + return (object: Function) => void register({ type: 'index', item: { object, options: asOptions(options) } }); +}; + +export const Unique = (options: UniqueOptions): ClassDecorator => { + return (object: Function) => void register({ type: 'uniqueConstraint', item: { object, options } }); +}; + +export const Check = (options: CheckOptions): ClassDecorator => { + return (object: Function) => void register({ type: 'checkConstraint', item: { object, options } }); +}; + +export const ColumnIndex = (options: string | ColumnIndexOptions = {}): PropertyDecorator => { + return (object: object, propertyName: string | symbol) => + void register({ type: 'columnIndex', item: { object, propertyName, options: asOptions(options) } }); +}; + +export const ForeignKeyColumn = (target: () => object, options: ForeignKeyColumnOptions): PropertyDecorator => { + return (object: object, propertyName: string | symbol) => { + register({ type: 'foreignKeyColumn', item: { object, propertyName, options, target } }); + }; +}; + +export const CreateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { + return Column({ + type: 'timestamp with time zone', + default: () => 'now()', + ...options, + }); +}; + +export const UpdateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { + return Column({ + type: 'timestamp with time zone', + default: () => 'now()', + ...options, + }); +}; + +export const DeleteDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { + return Column({ + type: 'timestamp with time zone', + nullable: true, + ...options, + }); +}; + +export const PrimaryGeneratedColumn = (options: Omit = {}) => + GeneratedColumn({ type: 'v4', ...options, primary: true }); + +export const PrimaryColumn = (options: Omit = {}) => Column({ ...options, primary: true }); + +export const GeneratedColumn = ({ type = 'v4', ...options }: GenerateColumnOptions): PropertyDecorator => { + const columnType = type === 'v4' || type === 'v7' ? 'uuid' : type; + + let columnDefault: ColumnDefaultValue | undefined; + switch (type) { + case 'v4': { + columnDefault = () => 'uuid_generate_v4()'; + break; + } + + case 'v7': { + columnDefault = () => 'immich_uuid_v7()'; + break; + } + } + + return Column({ + type: columnType, + default: columnDefault, + ...options, + }); +}; + +export const UpdateIdColumn = () => GeneratedColumn({ type: 'v7', nullable: false }); + +const asOptions = (options: string | T): T => { + if (typeof options === 'string') { + return { name: options } as T; + } + + return options; +}; diff --git a/server/src/sql-tools/index.ts b/server/src/sql-tools/index.ts new file mode 100644 index 0000000000..0d3e53df51 --- /dev/null +++ b/server/src/sql-tools/index.ts @@ -0,0 +1 @@ +export * from 'src/sql-tools/public_api'; diff --git a/server/src/sql-tools/public_api.ts b/server/src/sql-tools/public_api.ts new file mode 100644 index 0000000000..8b5a36e6a5 --- /dev/null +++ b/server/src/sql-tools/public_api.ts @@ -0,0 +1,6 @@ +export * from 'src/sql-tools/decorators'; +export { schemaDiff } from 'src/sql-tools/schema-diff'; +export { schemaDiffToSql } from 'src/sql-tools/schema-diff-to-sql'; +export { schemaFromDatabase } from 'src/sql-tools/schema-from-database'; +export { schemaFromDecorators } from 'src/sql-tools/schema-from-decorators'; +export * from 'src/sql-tools/types'; diff --git a/server/src/sql-tools/schema-diff-to-sql.spec.ts b/server/src/sql-tools/schema-diff-to-sql.spec.ts new file mode 100644 index 0000000000..c44d87e6bd --- /dev/null +++ b/server/src/sql-tools/schema-diff-to-sql.spec.ts @@ -0,0 +1,473 @@ +import { DatabaseConstraintType, schemaDiffToSql } from 'src/sql-tools'; +import { describe, expect, it } from 'vitest'; + +describe('diffToSql', () => { + describe('table.drop', () => { + it('should work', () => { + expect( + schemaDiffToSql([ + { + type: 'table.drop', + tableName: 'table1', + reason: 'unknown', + }, + ]), + ).toEqual([`DROP TABLE "table1";`]); + }); + }); + + describe('table.create', () => { + it('should work', () => { + expect( + schemaDiffToSql([ + { + type: 'table.create', + tableName: 'table1', + columns: [ + { + tableName: 'table1', + name: 'column1', + type: 'character varying', + nullable: true, + isArray: false, + synchronize: true, + }, + ], + reason: 'unknown', + }, + ]), + ).toEqual([`CREATE TABLE "table1" ("column1" character varying);`]); + }); + + it('should handle a non-nullable column', () => { + expect( + schemaDiffToSql([ + { + type: 'table.create', + tableName: 'table1', + columns: [ + { + tableName: 'table1', + name: 'column1', + type: 'character varying', + isArray: false, + nullable: false, + synchronize: true, + }, + ], + reason: 'unknown', + }, + ]), + ).toEqual([`CREATE TABLE "table1" ("column1" character varying NOT NULL);`]); + }); + + it('should handle a default value', () => { + expect( + schemaDiffToSql([ + { + type: 'table.create', + tableName: 'table1', + columns: [ + { + tableName: 'table1', + name: 'column1', + type: 'character varying', + isArray: false, + nullable: true, + default: 'uuid_generate_v4()', + synchronize: true, + }, + ], + reason: 'unknown', + }, + ]), + ).toEqual([`CREATE TABLE "table1" ("column1" character varying DEFAULT uuid_generate_v4());`]); + }); + + it('should handle an array type', () => { + expect( + schemaDiffToSql([ + { + type: 'table.create', + tableName: 'table1', + columns: [ + { + tableName: 'table1', + name: 'column1', + type: 'character varying', + isArray: true, + nullable: true, + synchronize: true, + }, + ], + reason: 'unknown', + }, + ]), + ).toEqual([`CREATE TABLE "table1" ("column1" character varying[]);`]); + }); + }); + + describe('column.add', () => { + it('should work', () => { + expect( + schemaDiffToSql([ + { + type: 'column.add', + column: { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: false, + isArray: false, + synchronize: true, + }, + reason: 'unknown', + }, + ]), + ).toEqual(['ALTER TABLE "table1" ADD "column1" character varying NOT NULL;']); + }); + + it('should add a nullable column', () => { + expect( + schemaDiffToSql([ + { + type: 'column.add', + column: { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: true, + isArray: false, + synchronize: true, + }, + reason: 'unknown', + }, + ]), + ).toEqual(['ALTER TABLE "table1" ADD "column1" character varying;']); + }); + + it('should add a column with an enum type', () => { + expect( + schemaDiffToSql([ + { + type: 'column.add', + column: { + name: 'column1', + tableName: 'table1', + type: 'character varying', + enumName: 'table1_column1_enum', + nullable: true, + isArray: false, + synchronize: true, + }, + reason: 'unknown', + }, + ]), + ).toEqual(['ALTER TABLE "table1" ADD "column1" table1_column1_enum;']); + }); + + it('should add a column that is an array type', () => { + expect( + schemaDiffToSql([ + { + type: 'column.add', + column: { + name: 'column1', + tableName: 'table1', + type: 'boolean', + nullable: true, + isArray: true, + synchronize: true, + }, + reason: 'unknown', + }, + ]), + ).toEqual(['ALTER TABLE "table1" ADD "column1" boolean[];']); + }); + }); + + describe('column.alter', () => { + it('should make a column nullable', () => { + expect( + schemaDiffToSql([ + { + type: 'column.alter', + tableName: 'table1', + columnName: 'column1', + changes: { nullable: true }, + reason: 'unknown', + }, + ]), + ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" DROP NOT NULL;`]); + }); + + it('should make a column non-nullable', () => { + expect( + schemaDiffToSql([ + { + type: 'column.alter', + tableName: 'table1', + columnName: 'column1', + changes: { nullable: false }, + reason: 'unknown', + }, + ]), + ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET NOT NULL;`]); + }); + + it('should update the default value', () => { + expect( + schemaDiffToSql([ + { + type: 'column.alter', + tableName: 'table1', + columnName: 'column1', + changes: { default: 'uuid_generate_v4()' }, + reason: 'unknown', + }, + ]), + ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET DEFAULT uuid_generate_v4();`]); + }); + }); + + describe('column.drop', () => { + it('should work', () => { + expect( + schemaDiffToSql([ + { + type: 'column.drop', + tableName: 'table1', + columnName: 'column1', + reason: 'unknown', + }, + ]), + ).toEqual([`ALTER TABLE "table1" DROP COLUMN "column1";`]); + }); + }); + + describe('constraint.add', () => { + describe('primary keys', () => { + it('should work', () => { + expect( + schemaDiffToSql([ + { + type: 'constraint.add', + constraint: { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_test', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + reason: 'unknown', + }, + ]), + ).toEqual(['ALTER TABLE "table1" ADD CONSTRAINT "PK_test" PRIMARY KEY ("id");']); + }); + }); + + describe('foreign keys', () => { + it('should work', () => { + expect( + schemaDiffToSql([ + { + type: 'constraint.add', + constraint: { + type: DatabaseConstraintType.FOREIGN_KEY, + name: 'FK_test', + tableName: 'table1', + columnNames: ['parentId'], + referenceColumnNames: ['id'], + referenceTableName: 'table2', + synchronize: true, + }, + reason: 'unknown', + }, + ]), + ).toEqual([ + 'ALTER TABLE "table1" ADD CONSTRAINT "FK_test" FOREIGN KEY ("parentId") REFERENCES "table2" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION;', + ]); + }); + }); + + describe('unique', () => { + it('should work', () => { + expect( + schemaDiffToSql([ + { + type: 'constraint.add', + constraint: { + type: DatabaseConstraintType.UNIQUE, + name: 'UQ_test', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + reason: 'unknown', + }, + ]), + ).toEqual(['ALTER TABLE "table1" ADD CONSTRAINT "UQ_test" UNIQUE ("id");']); + }); + }); + + describe('check', () => { + it('should work', () => { + expect( + schemaDiffToSql([ + { + type: 'constraint.add', + constraint: { + type: DatabaseConstraintType.CHECK, + name: 'CHK_test', + tableName: 'table1', + expression: '"id" IS NOT NULL', + synchronize: true, + }, + reason: 'unknown', + }, + ]), + ).toEqual(['ALTER TABLE "table1" ADD CONSTRAINT "CHK_test" CHECK ("id" IS NOT NULL);']); + }); + }); + }); + + describe('constraint.drop', () => { + it('should work', () => { + expect( + schemaDiffToSql([ + { + type: 'constraint.drop', + tableName: 'table1', + constraintName: 'PK_test', + reason: 'unknown', + }, + ]), + ).toEqual([`ALTER TABLE "table1" DROP CONSTRAINT "PK_test";`]); + }); + }); + + describe('index.create', () => { + it('should work', () => { + expect( + schemaDiffToSql([ + { + type: 'index.create', + index: { + name: 'IDX_test', + tableName: 'table1', + columnNames: ['column1'], + unique: false, + synchronize: true, + }, + reason: 'unknown', + }, + ]), + ).toEqual(['CREATE INDEX "IDX_test" ON "table1" ("column1")']); + }); + + it('should create an unique index', () => { + expect( + schemaDiffToSql([ + { + type: 'index.create', + index: { + name: 'IDX_test', + tableName: 'table1', + columnNames: ['column1'], + unique: true, + synchronize: true, + }, + reason: 'unknown', + }, + ]), + ).toEqual(['CREATE UNIQUE INDEX "IDX_test" ON "table1" ("column1")']); + }); + + it('should create an index with a custom expression', () => { + expect( + schemaDiffToSql([ + { + type: 'index.create', + index: { + name: 'IDX_test', + tableName: 'table1', + unique: false, + expression: '"id" IS NOT NULL', + synchronize: true, + }, + reason: 'unknown', + }, + ]), + ).toEqual(['CREATE INDEX "IDX_test" ON "table1" ("id" IS NOT NULL)']); + }); + + it('should create an index with a where clause', () => { + expect( + schemaDiffToSql([ + { + type: 'index.create', + index: { + name: 'IDX_test', + tableName: 'table1', + columnNames: ['id'], + unique: false, + where: '("id" IS NOT NULL)', + synchronize: true, + }, + reason: 'unknown', + }, + ]), + ).toEqual(['CREATE INDEX "IDX_test" ON "table1" ("id") WHERE ("id" IS NOT NULL)']); + }); + + it('should create an index with a custom expression', () => { + expect( + schemaDiffToSql([ + { + type: 'index.create', + index: { + name: 'IDX_test', + tableName: 'table1', + unique: false, + using: 'gin', + expression: '"id" IS NOT NULL', + synchronize: true, + }, + reason: 'unknown', + }, + ]), + ).toEqual(['CREATE INDEX "IDX_test" ON "table1" USING gin ("id" IS NOT NULL)']); + }); + }); + + describe('index.drop', () => { + it('should work', () => { + expect( + schemaDiffToSql([ + { + type: 'index.drop', + indexName: 'IDX_test', + reason: 'unknown', + }, + ]), + ).toEqual([`DROP INDEX "IDX_test";`]); + }); + }); + + describe('comments', () => { + it('should include the reason in a SQL comment', () => { + expect( + schemaDiffToSql( + [ + { + type: 'index.drop', + indexName: 'IDX_test', + reason: 'unknown', + }, + ], + { comments: true }, + ), + ).toEqual([`DROP INDEX "IDX_test"; -- unknown`]); + }); + }); +}); diff --git a/server/src/sql-tools/schema-diff-to-sql.ts b/server/src/sql-tools/schema-diff-to-sql.ts new file mode 100644 index 0000000000..0a537c600b --- /dev/null +++ b/server/src/sql-tools/schema-diff-to-sql.ts @@ -0,0 +1,204 @@ +import { + DatabaseActionType, + DatabaseColumn, + DatabaseColumnChanges, + DatabaseConstraint, + DatabaseConstraintType, + DatabaseIndex, + SchemaDiff, + SchemaDiffToSqlOptions, +} from 'src/sql-tools/types'; + +const asColumnList = (columns: string[]) => + columns + .toSorted() + .map((column) => `"${column}"`) + .join(', '); +const withNull = (column: DatabaseColumn) => (column.nullable ? '' : ' NOT NULL'); +const withDefault = (column: DatabaseColumn) => (column.default ? ` DEFAULT ${column.default}` : ''); +const withAction = (constraint: { onDelete?: DatabaseActionType; onUpdate?: DatabaseActionType }) => + ` ON UPDATE ${constraint.onUpdate ?? DatabaseActionType.NO_ACTION} ON DELETE ${constraint.onDelete ?? DatabaseActionType.NO_ACTION}`; + +const withComments = (comments: boolean | undefined, item: SchemaDiff): string => { + if (!comments) { + return ''; + } + + return ` -- ${item.reason}`; +}; + +const asArray = (items: T | T[]): T[] => { + if (Array.isArray(items)) { + return items; + } + + return [items]; +}; + +export const getColumnType = (column: DatabaseColumn) => { + let type = column.enumName || column.type; + if (column.isArray) { + type += '[]'; + } + + return type; +}; + +/** + * Convert schema diffs into SQL statements + */ +export const schemaDiffToSql = (items: SchemaDiff[], options: SchemaDiffToSqlOptions = {}): string[] => { + return items.flatMap((item) => asArray(asSql(item)).map((result) => result + withComments(options.comments, item))); +}; + +const asSql = (item: SchemaDiff): string | string[] => { + switch (item.type) { + case 'table.create': { + return asTableCreate(item.tableName, item.columns); + } + + case 'table.drop': { + return asTableDrop(item.tableName); + } + + case 'column.add': { + return asColumnAdd(item.column); + } + + case 'column.alter': { + return asColumnAlter(item.tableName, item.columnName, item.changes); + } + + case 'column.drop': { + return asColumnDrop(item.tableName, item.columnName); + } + + case 'constraint.add': { + return asConstraintAdd(item.constraint); + } + + case 'constraint.drop': { + return asConstraintDrop(item.tableName, item.constraintName); + } + + case 'index.create': { + return asIndexCreate(item.index); + } + + case 'index.drop': { + return asIndexDrop(item.indexName); + } + + default: { + return []; + } + } +}; + +const asTableCreate = (tableName: string, tableColumns: DatabaseColumn[]): string => { + const columns = tableColumns + .map((column) => `"${column.name}" ${getColumnType(column)}` + withNull(column) + withDefault(column)) + .join(', '); + return `CREATE TABLE "${tableName}" (${columns});`; +}; + +const asTableDrop = (tableName: string): string => { + return `DROP TABLE "${tableName}";`; +}; + +const asColumnAdd = (column: DatabaseColumn): string => { + return ( + `ALTER TABLE "${column.tableName}" ADD "${column.name}" ${getColumnType(column)}` + + withNull(column) + + withDefault(column) + + ';' + ); +}; + +const asColumnAlter = (tableName: string, columnName: string, changes: DatabaseColumnChanges): string[] => { + const base = `ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}"`; + const items: string[] = []; + if (changes.nullable !== undefined) { + items.push(changes.nullable ? `${base} DROP NOT NULL;` : `${base} SET NOT NULL;`); + } + + if (changes.default !== undefined) { + items.push(`${base} SET DEFAULT ${changes.default};`); + } + + return items; +}; + +const asColumnDrop = (tableName: string, columnName: string): string => { + return `ALTER TABLE "${tableName}" DROP COLUMN "${columnName}";`; +}; + +const asConstraintAdd = (constraint: DatabaseConstraint): string | string[] => { + const base = `ALTER TABLE "${constraint.tableName}" ADD CONSTRAINT "${constraint.name}"`; + switch (constraint.type) { + case DatabaseConstraintType.PRIMARY_KEY: { + const columnNames = asColumnList(constraint.columnNames); + return `${base} PRIMARY KEY (${columnNames});`; + } + + case DatabaseConstraintType.FOREIGN_KEY: { + const columnNames = asColumnList(constraint.columnNames); + const referenceColumnNames = asColumnList(constraint.referenceColumnNames); + return ( + `${base} FOREIGN KEY (${columnNames}) REFERENCES "${constraint.referenceTableName}" (${referenceColumnNames})` + + withAction(constraint) + + ';' + ); + } + + case DatabaseConstraintType.UNIQUE: { + const columnNames = asColumnList(constraint.columnNames); + return `${base} UNIQUE (${columnNames});`; + } + + case DatabaseConstraintType.CHECK: { + return `${base} CHECK (${constraint.expression});`; + } + + default: { + return []; + } + } +}; + +const asConstraintDrop = (tableName: string, constraintName: string): string => { + return `ALTER TABLE "${tableName}" DROP CONSTRAINT "${constraintName}";`; +}; + +const asIndexCreate = (index: DatabaseIndex): string => { + let sql = `CREATE`; + + if (index.unique) { + sql += ' UNIQUE'; + } + + sql += ` INDEX "${index.name}" ON "${index.tableName}"`; + + if (index.columnNames) { + const columnNames = asColumnList(index.columnNames); + sql += ` (${columnNames})`; + } + + if (index.using && index.using !== 'btree') { + sql += ` USING ${index.using}`; + } + + if (index.expression) { + sql += ` (${index.expression})`; + } + + if (index.where) { + sql += ` WHERE ${index.where}`; + } + + return sql; +}; + +const asIndexDrop = (indexName: string): string => { + return `DROP INDEX "${indexName}";`; +}; diff --git a/server/src/sql-tools/schema-diff.spec.ts b/server/src/sql-tools/schema-diff.spec.ts new file mode 100644 index 0000000000..2f536cfabd --- /dev/null +++ b/server/src/sql-tools/schema-diff.spec.ts @@ -0,0 +1,635 @@ +import { schemaDiff } from 'src/sql-tools/schema-diff'; +import { + DatabaseActionType, + DatabaseColumn, + DatabaseColumnType, + DatabaseConstraint, + DatabaseConstraintType, + DatabaseIndex, + DatabaseSchema, + DatabaseTable, +} from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const fromColumn = (column: Partial>): DatabaseSchema => { + const tableName = 'table1'; + + return { + name: 'public', + tables: [ + { + name: tableName, + columns: [ + { + name: 'column1', + synchronize: true, + isArray: false, + type: 'character varying', + nullable: false, + ...column, + tableName, + }, + ], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], + }; +}; + +const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => { + const tableName = constraint?.tableName || 'table1'; + + return { + name: 'public', + tables: [ + { + name: tableName, + columns: [ + { + name: 'column1', + synchronize: true, + isArray: false, + type: 'character varying', + nullable: false, + tableName, + }, + ], + indexes: [], + constraints: constraint ? [constraint] : [], + synchronize: true, + }, + ], + warnings: [], + }; +}; + +const fromIndex = (index?: DatabaseIndex): DatabaseSchema => { + const tableName = index?.tableName || 'table1'; + + return { + name: 'public', + tables: [ + { + name: tableName, + columns: [ + { + name: 'column1', + synchronize: true, + isArray: false, + type: 'character varying', + nullable: false, + tableName, + }, + ], + indexes: index ? [index] : [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], + }; +}; + +const newSchema = (schema: { + name?: string; + tables: Array<{ + name: string; + columns?: Array<{ + name: string; + type?: DatabaseColumnType; + nullable?: boolean; + isArray?: boolean; + }>; + indexes?: DatabaseIndex[]; + constraints?: DatabaseConstraint[]; + }>; +}): DatabaseSchema => { + const tables: DatabaseTable[] = []; + + for (const table of schema.tables || []) { + const tableName = table.name; + const columns: DatabaseColumn[] = []; + + for (const column of table.columns || []) { + const columnName = column.name; + + columns.push({ + tableName, + name: columnName, + type: column.type || 'character varying', + isArray: column.isArray ?? false, + nullable: column.nullable ?? false, + synchronize: true, + }); + } + + tables.push({ + name: tableName, + columns, + indexes: table.indexes ?? [], + constraints: table.constraints ?? [], + synchronize: true, + }); + } + + return { + name: schema?.name || 'public', + tables, + warnings: [], + }; +}; + +describe('schemaDiff', () => { + it('should work', () => { + const diff = schemaDiff(newSchema({ tables: [] }), newSchema({ tables: [] })); + expect(diff.items).toEqual([]); + }); + + describe('table', () => { + describe('table.create', () => { + it('should find a missing table', () => { + const column: DatabaseColumn = { + type: 'character varying', + tableName: 'table1', + name: 'column1', + isArray: false, + nullable: false, + synchronize: true, + }; + const diff = schemaDiff( + newSchema({ tables: [{ name: 'table1', columns: [column] }] }), + newSchema({ tables: [] }), + ); + + expect(diff.items).toHaveLength(1); + expect(diff.items[0]).toEqual({ + type: 'table.create', + tableName: 'table1', + columns: [column], + reason: 'missing in target', + }); + }); + }); + + describe('table.drop', () => { + it('should find an extra table', () => { + const diff = schemaDiff( + newSchema({ tables: [] }), + newSchema({ + tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], + }), + { ignoreExtraTables: false }, + ); + + expect(diff.items).toHaveLength(1); + expect(diff.items[0]).toEqual({ + type: 'table.drop', + tableName: 'table1', + reason: 'missing in source', + }); + }); + }); + + it('should skip identical tables', () => { + const diff = schemaDiff( + newSchema({ + tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], + }), + newSchema({ + tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], + }), + ); + + expect(diff.items).toEqual([]); + }); + }); + + describe('column', () => { + describe('column.add', () => { + it('should find a new column', () => { + const diff = schemaDiff( + newSchema({ + tables: [ + { + name: 'table1', + columns: [{ name: 'column1' }, { name: 'column2' }], + }, + ], + }), + newSchema({ + tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], + }), + ); + + expect(diff.items).toEqual([ + { + type: 'column.add', + column: { + tableName: 'table1', + isArray: false, + name: 'column2', + nullable: false, + type: 'character varying', + synchronize: true, + }, + reason: 'missing in target', + }, + ]); + }); + }); + + describe('column.drop', () => { + it('should find an extra column', () => { + const diff = schemaDiff( + newSchema({ + tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], + }), + newSchema({ + tables: [ + { + name: 'table1', + columns: [{ name: 'column1' }, { name: 'column2' }], + }, + ], + }), + ); + + expect(diff.items).toEqual([ + { + type: 'column.drop', + tableName: 'table1', + columnName: 'column2', + reason: 'missing in source', + }, + ]); + }); + }); + + describe('nullable', () => { + it('should make a column nullable', () => { + const diff = schemaDiff( + fromColumn({ name: 'column1', nullable: true }), + fromColumn({ name: 'column1', nullable: false }), + ); + + expect(diff.items).toEqual([ + { + type: 'column.alter', + tableName: 'table1', + columnName: 'column1', + changes: { + nullable: true, + }, + reason: 'nullable is different (true vs false)', + }, + ]); + }); + + it('should make a column non-nullable', () => { + const diff = schemaDiff( + fromColumn({ name: 'column1', nullable: false }), + fromColumn({ name: 'column1', nullable: true }), + ); + + expect(diff.items).toEqual([ + { + type: 'column.alter', + tableName: 'table1', + columnName: 'column1', + changes: { + nullable: false, + }, + reason: 'nullable is different (false vs true)', + }, + ]); + }); + }); + + describe('default', () => { + it('should set a default value to a function', () => { + const diff = schemaDiff( + fromColumn({ name: 'column1', default: 'uuid_generate_v4()' }), + fromColumn({ name: 'column1' }), + ); + + expect(diff.items).toEqual([ + { + type: 'column.alter', + tableName: 'table1', + columnName: 'column1', + changes: { + default: 'uuid_generate_v4()', + }, + reason: 'default is different (uuid_generate_v4() vs undefined)', + }, + ]); + }); + + it('should ignore explicit casts for strings', () => { + const diff = schemaDiff( + fromColumn({ name: 'column1', type: 'character varying', default: `''` }), + fromColumn({ name: 'column1', type: 'character varying', default: `''::character varying` }), + ); + + expect(diff.items).toEqual([]); + }); + + it('should ignore explicit casts for numbers', () => { + const diff = schemaDiff( + fromColumn({ name: 'column1', type: 'bigint', default: `0` }), + fromColumn({ name: 'column1', type: 'bigint', default: `'0'::bigint` }), + ); + + expect(diff.items).toEqual([]); + }); + + it('should ignore explicit casts for enums', () => { + const diff = schemaDiff( + fromColumn({ name: 'column1', type: 'enum', enumName: 'enum1', default: `test` }), + fromColumn({ name: 'column1', type: 'enum', enumName: 'enum1', default: `'test'::enum1` }), + ); + + expect(diff.items).toEqual([]); + }); + }); + }); + + describe('constraint', () => { + describe('constraint.add', () => { + it('should detect a new constraint', () => { + const diff = schemaDiff( + fromConstraint({ + name: 'PK_test', + type: DatabaseConstraintType.PRIMARY_KEY, + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }), + fromConstraint(), + ); + + expect(diff.items).toEqual([ + { + type: 'constraint.add', + constraint: { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_test', + columnNames: ['id'], + tableName: 'table1', + synchronize: true, + }, + reason: 'missing in target', + }, + ]); + }); + }); + + describe('constraint.drop', () => { + it('should detect an extra constraint', () => { + const diff = schemaDiff( + fromConstraint(), + fromConstraint({ + name: 'PK_test', + type: DatabaseConstraintType.PRIMARY_KEY, + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }), + ); + + expect(diff.items).toEqual([ + { + type: 'constraint.drop', + tableName: 'table1', + constraintName: 'PK_test', + reason: 'missing in source', + }, + ]); + }); + }); + + describe('primary key', () => { + it('should skip identical primary key constraints', () => { + const constraint: DatabaseConstraint = { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_test', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }; + + const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint })); + + expect(diff.items).toEqual([]); + }); + }); + + describe('foreign key', () => { + it('should skip identical foreign key constraints', () => { + const constraint: DatabaseConstraint = { + type: DatabaseConstraintType.FOREIGN_KEY, + name: 'FK_test', + tableName: 'table1', + columnNames: ['parentId'], + referenceTableName: 'table2', + referenceColumnNames: ['id'], + synchronize: true, + }; + + const diff = schemaDiff(fromConstraint(constraint), fromConstraint(constraint)); + + expect(diff.items).toEqual([]); + }); + + it('should drop and recreate when the column changes', () => { + const constraint: DatabaseConstraint = { + type: DatabaseConstraintType.FOREIGN_KEY, + name: 'FK_test', + tableName: 'table1', + columnNames: ['parentId'], + referenceTableName: 'table2', + referenceColumnNames: ['id'], + synchronize: true, + }; + + const diff = schemaDiff( + fromConstraint(constraint), + fromConstraint({ ...constraint, columnNames: ['parentId2'] }), + ); + + expect(diff.items).toEqual([ + { + constraintName: 'FK_test', + reason: 'columns are different (parentId vs parentId2)', + tableName: 'table1', + type: 'constraint.drop', + }, + { + constraint: { + columnNames: ['parentId'], + name: 'FK_test', + referenceColumnNames: ['id'], + referenceTableName: 'table2', + synchronize: true, + tableName: 'table1', + type: 'foreign-key', + }, + reason: 'columns are different (parentId vs parentId2)', + type: 'constraint.add', + }, + ]); + }); + + it('should drop and recreate when the ON DELETE action changes', () => { + const constraint: DatabaseConstraint = { + type: DatabaseConstraintType.FOREIGN_KEY, + name: 'FK_test', + tableName: 'table1', + columnNames: ['parentId'], + referenceTableName: 'table2', + referenceColumnNames: ['id'], + onDelete: DatabaseActionType.CASCADE, + synchronize: true, + }; + + const diff = schemaDiff(fromConstraint(constraint), fromConstraint({ ...constraint, onDelete: undefined })); + + expect(diff.items).toEqual([ + { + constraintName: 'FK_test', + reason: 'ON DELETE action is different (CASCADE vs NO ACTION)', + tableName: 'table1', + type: 'constraint.drop', + }, + { + constraint: { + columnNames: ['parentId'], + name: 'FK_test', + referenceColumnNames: ['id'], + referenceTableName: 'table2', + onDelete: DatabaseActionType.CASCADE, + synchronize: true, + tableName: 'table1', + type: 'foreign-key', + }, + reason: 'ON DELETE action is different (CASCADE vs NO ACTION)', + type: 'constraint.add', + }, + ]); + }); + }); + + describe('unique', () => { + it('should skip identical unique constraints', () => { + const constraint: DatabaseConstraint = { + type: DatabaseConstraintType.UNIQUE, + name: 'UQ_test', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }; + + const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint })); + + expect(diff.items).toEqual([]); + }); + }); + + describe('check', () => { + it('should skip identical check constraints', () => { + const constraint: DatabaseConstraint = { + type: DatabaseConstraintType.CHECK, + name: 'CHK_test', + tableName: 'table1', + expression: 'column1 > 0', + synchronize: true, + }; + + const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint })); + + expect(diff.items).toEqual([]); + }); + }); + }); + + describe('index', () => { + describe('index.create', () => { + it('should detect a new index', () => { + const diff = schemaDiff( + fromIndex({ + name: 'IDX_test', + tableName: 'table1', + columnNames: ['id'], + unique: false, + synchronize: true, + }), + fromIndex(), + ); + + expect(diff.items).toEqual([ + { + type: 'index.create', + index: { + name: 'IDX_test', + columnNames: ['id'], + tableName: 'table1', + unique: false, + synchronize: true, + }, + reason: 'missing in target', + }, + ]); + }); + }); + + describe('index.drop', () => { + it('should detect an extra index', () => { + const diff = schemaDiff( + fromIndex(), + fromIndex({ + name: 'IDX_test', + unique: true, + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }), + ); + + expect(diff.items).toEqual([ + { + type: 'index.drop', + indexName: 'IDX_test', + reason: 'missing in source', + }, + ]); + }); + }); + + it('should recreate the index if unique changes', () => { + const index: DatabaseIndex = { + name: 'IDX_test', + tableName: 'table1', + columnNames: ['id'], + unique: true, + synchronize: true, + }; + const diff = schemaDiff(fromIndex(index), fromIndex({ ...index, unique: false })); + + expect(diff.items).toEqual([ + { + type: 'index.drop', + indexName: 'IDX_test', + reason: 'uniqueness is different (true vs false)', + }, + { + type: 'index.create', + index, + reason: 'uniqueness is different (true vs false)', + }, + ]); + }); + }); +}); diff --git a/server/src/sql-tools/schema-diff.ts b/server/src/sql-tools/schema-diff.ts new file mode 100644 index 0000000000..ca7f35a45f --- /dev/null +++ b/server/src/sql-tools/schema-diff.ts @@ -0,0 +1,449 @@ +import { getColumnType, schemaDiffToSql } from 'src/sql-tools/schema-diff-to-sql'; +import { + DatabaseCheckConstraint, + DatabaseColumn, + DatabaseConstraint, + DatabaseConstraintType, + DatabaseForeignKeyConstraint, + DatabaseIndex, + DatabasePrimaryKeyConstraint, + DatabaseSchema, + DatabaseTable, + DatabaseUniqueConstraint, + SchemaDiff, + SchemaDiffToSqlOptions, +} from 'src/sql-tools/types'; + +enum Reason { + MissingInSource = 'missing in source', + MissingInTarget = 'missing in target', +} + +const setIsEqual = (source: Set, target: Set) => + source.size === target.size && [...source].every((x) => target.has(x)); + +const haveEqualColumns = (sourceColumns?: string[], targetColumns?: string[]) => { + return setIsEqual(new Set(sourceColumns ?? []), new Set(targetColumns ?? [])); +}; + +const isSynchronizeDisabled = (source?: { synchronize?: boolean }, target?: { synchronize?: boolean }) => { + return source?.synchronize === false || target?.synchronize === false; +}; + +const withTypeCast = (value: string, type: string) => { + if (!value.startsWith(`'`)) { + value = `'${value}'`; + } + return `${value}::${type}`; +}; + +const isDefaultEqual = (source: DatabaseColumn, target: DatabaseColumn) => { + if (source.default === target.default) { + return true; + } + + if (source.default === undefined || target.default === undefined) { + return false; + } + + if ( + withTypeCast(source.default, getColumnType(source)) === target.default || + source.default === withTypeCast(target.default, getColumnType(target)) + ) { + return true; + } + + return false; +}; + +/** + * Compute the difference between two database schemas + */ +export const schemaDiff = ( + source: DatabaseSchema, + target: DatabaseSchema, + options: { ignoreExtraTables?: boolean } = {}, +) => { + const items = diffTables(source.tables, target.tables, { + ignoreExtraTables: options.ignoreExtraTables ?? true, + }); + + return { + items, + asSql: (options?: SchemaDiffToSqlOptions) => schemaDiffToSql(items, options), + }; +}; + +export const diffTables = ( + sources: DatabaseTable[], + targets: DatabaseTable[], + options: { ignoreExtraTables: boolean }, +) => { + const items: SchemaDiff[] = []; + const sourceMap = Object.fromEntries(sources.map((table) => [table.name, table])); + const targetMap = Object.fromEntries(targets.map((table) => [table.name, table])); + const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); + + for (const key of keys) { + if (options.ignoreExtraTables && !sourceMap[key]) { + continue; + } + items.push(...diffTable(sourceMap[key], targetMap[key])); + } + + return items; +}; + +const diffTable = (source?: DatabaseTable, target?: DatabaseTable): SchemaDiff[] => { + if (isSynchronizeDisabled(source, target)) { + return []; + } + + if (source && !target) { + return [ + { + type: 'table.create', + tableName: source.name, + columns: Object.values(source.columns), + reason: Reason.MissingInTarget, + }, + ...diffIndexes(source.indexes, []), + // TODO merge constraints into table create record when possible + ...diffConstraints(source.constraints, []), + ]; + } + + if (!source && target) { + return [ + { + type: 'table.drop', + tableName: target.name, + reason: Reason.MissingInSource, + }, + ]; + } + + if (!source || !target) { + return []; + } + + return [ + ...diffColumns(source.columns, target.columns), + ...diffConstraints(source.constraints, target.constraints), + ...diffIndexes(source.indexes, target.indexes), + ]; +}; + +const diffColumns = (sources: DatabaseColumn[], targets: DatabaseColumn[]): SchemaDiff[] => { + const items: SchemaDiff[] = []; + const sourceMap = Object.fromEntries(sources.map((column) => [column.name, column])); + const targetMap = Object.fromEntries(targets.map((column) => [column.name, column])); + const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); + + for (const key of keys) { + items.push(...diffColumn(sourceMap[key], targetMap[key])); + } + + return items; +}; + +const diffColumn = (source?: DatabaseColumn, target?: DatabaseColumn): SchemaDiff[] => { + if (isSynchronizeDisabled(source, target)) { + return []; + } + + if (source && !target) { + return [ + { + type: 'column.add', + column: source, + reason: Reason.MissingInTarget, + }, + ]; + } + + if (!source && target) { + return [ + { + type: 'column.drop', + tableName: target.tableName, + columnName: target.name, + reason: Reason.MissingInSource, + }, + ]; + } + + if (!source || !target) { + return []; + } + + const sourceType = getColumnType(source); + const targetType = getColumnType(target); + + const isTypeChanged = sourceType !== targetType; + + if (isTypeChanged) { + // TODO: convert between types via UPDATE when possible + return dropAndRecreateColumn(source, target, `column type is different (${sourceType} vs ${targetType})`); + } + + const items: SchemaDiff[] = []; + if (source.nullable !== target.nullable) { + items.push({ + type: 'column.alter', + tableName: source.tableName, + columnName: source.name, + changes: { + nullable: source.nullable, + }, + reason: `nullable is different (${source.nullable} vs ${target.nullable})`, + }); + } + + if (!isDefaultEqual(source, target)) { + items.push({ + type: 'column.alter', + tableName: source.tableName, + columnName: source.name, + changes: { + default: String(source.default), + }, + reason: `default is different (${source.default} vs ${target.default})`, + }); + } + + return items; +}; + +const diffConstraints = (sources: DatabaseConstraint[], targets: DatabaseConstraint[]): SchemaDiff[] => { + const items: SchemaDiff[] = []; + + for (const type of Object.values(DatabaseConstraintType)) { + const sourceMap = Object.fromEntries(sources.filter((item) => item.type === type).map((item) => [item.name, item])); + const targetMap = Object.fromEntries(targets.filter((item) => item.type === type).map((item) => [item.name, item])); + const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); + + for (const key of keys) { + items.push(...diffConstraint(sourceMap[key], targetMap[key])); + } + } + + return items; +}; + +const diffConstraint = (source?: T, target?: T): SchemaDiff[] => { + if (isSynchronizeDisabled(source, target)) { + return []; + } + + if (source && !target) { + return [ + { + type: 'constraint.add', + constraint: source, + reason: Reason.MissingInTarget, + }, + ]; + } + + if (!source && target) { + return [ + { + type: 'constraint.drop', + tableName: target.tableName, + constraintName: target.name, + reason: Reason.MissingInSource, + }, + ]; + } + + if (!source || !target) { + return []; + } + + switch (source.type) { + case DatabaseConstraintType.PRIMARY_KEY: { + return diffPrimaryKeyConstraint(source, target as DatabasePrimaryKeyConstraint); + } + + case DatabaseConstraintType.FOREIGN_KEY: { + return diffForeignKeyConstraint(source, target as DatabaseForeignKeyConstraint); + } + + case DatabaseConstraintType.UNIQUE: { + return diffUniqueConstraint(source, target as DatabaseUniqueConstraint); + } + + case DatabaseConstraintType.CHECK: { + return diffCheckConstraint(source, target as DatabaseCheckConstraint); + } + + default: { + return dropAndRecreateConstraint(source, target, `Unknown constraint type: ${(source as any).type}`); + } + } +}; + +const diffPrimaryKeyConstraint = ( + source: DatabasePrimaryKeyConstraint, + target: DatabasePrimaryKeyConstraint, +): SchemaDiff[] => { + if (!haveEqualColumns(source.columnNames, target.columnNames)) { + return dropAndRecreateConstraint( + source, + target, + `Primary key columns are different: (${source.columnNames} vs ${target.columnNames})`, + ); + } + + return []; +}; + +const diffForeignKeyConstraint = ( + source: DatabaseForeignKeyConstraint, + target: DatabaseForeignKeyConstraint, +): SchemaDiff[] => { + let reason = ''; + + const sourceDeleteAction = source.onDelete ?? 'NO ACTION'; + const targetDeleteAction = target.onDelete ?? 'NO ACTION'; + + const sourceUpdateAction = source.onUpdate ?? 'NO ACTION'; + const targetUpdateAction = target.onUpdate ?? 'NO ACTION'; + + if (!haveEqualColumns(source.columnNames, target.columnNames)) { + reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; + } else if (!haveEqualColumns(source.referenceColumnNames, target.referenceColumnNames)) { + reason = `reference columns are different (${source.referenceColumnNames} vs ${target.referenceColumnNames})`; + } else if (source.referenceTableName !== target.referenceTableName) { + reason = `reference table is different (${source.referenceTableName} vs ${target.referenceTableName})`; + } else if (sourceDeleteAction !== targetDeleteAction) { + reason = `ON DELETE action is different (${sourceDeleteAction} vs ${targetDeleteAction})`; + } else if (sourceUpdateAction !== targetUpdateAction) { + reason = `ON UPDATE action is different (${sourceUpdateAction} vs ${targetUpdateAction})`; + } + + if (reason) { + return dropAndRecreateConstraint(source, target, reason); + } + + return []; +}; + +const diffUniqueConstraint = (source: DatabaseUniqueConstraint, target: DatabaseUniqueConstraint): SchemaDiff[] => { + let reason = ''; + + if (!haveEqualColumns(source.columnNames, target.columnNames)) { + reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; + } + + if (reason) { + return dropAndRecreateConstraint(source, target, reason); + } + + return []; +}; + +const diffCheckConstraint = (source: DatabaseCheckConstraint, target: DatabaseCheckConstraint): SchemaDiff[] => { + if (source.expression !== target.expression) { + // comparing expressions is hard because postgres reconstructs it with different formatting + // for now if the constraint exists with the same name, we will just skip it + } + + return []; +}; + +const diffIndexes = (sources: DatabaseIndex[], targets: DatabaseIndex[]) => { + const items: SchemaDiff[] = []; + const sourceMap = Object.fromEntries(sources.map((index) => [index.name, index])); + const targetMap = Object.fromEntries(targets.map((index) => [index.name, index])); + const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); + + for (const key of keys) { + items.push(...diffIndex(sourceMap[key], targetMap[key])); + } + + return items; +}; + +const diffIndex = (source?: DatabaseIndex, target?: DatabaseIndex): SchemaDiff[] => { + if (isSynchronizeDisabled(source, target)) { + return []; + } + + if (source && !target) { + return [{ type: 'index.create', index: source, reason: Reason.MissingInTarget }]; + } + + if (!source && target) { + return [ + { + type: 'index.drop', + indexName: target.name, + reason: Reason.MissingInSource, + }, + ]; + } + + if (!target || !source) { + return []; + } + + const sourceUsing = source.using ?? 'btree'; + const targetUsing = target.using ?? 'btree'; + + let reason = ''; + + if (!haveEqualColumns(source.columnNames, target.columnNames)) { + reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; + } else if (source.unique !== target.unique) { + reason = `uniqueness is different (${source.unique} vs ${target.unique})`; + } else if (sourceUsing !== targetUsing) { + reason = `using method is different (${source.using} vs ${target.using})`; + } else if (source.where !== target.where) { + reason = `where clause is different (${source.where} vs ${target.where})`; + } else if (source.expression !== target.expression) { + reason = `expression is different (${source.expression} vs ${target.expression})`; + } + + if (reason) { + return dropAndRecreateIndex(source, target, reason); + } + + return []; +}; + +const dropAndRecreateColumn = (source: DatabaseColumn, target: DatabaseColumn, reason: string): SchemaDiff[] => { + return [ + { + type: 'column.drop', + tableName: target.tableName, + columnName: target.name, + reason, + }, + { type: 'column.add', column: source, reason }, + ]; +}; + +const dropAndRecreateConstraint = ( + source: DatabaseConstraint, + target: DatabaseConstraint, + reason: string, +): SchemaDiff[] => { + return [ + { + type: 'constraint.drop', + tableName: target.tableName, + constraintName: target.name, + reason, + }, + { type: 'constraint.add', constraint: source, reason }, + ]; +}; + +const dropAndRecreateIndex = (source: DatabaseIndex, target: DatabaseIndex, reason: string): SchemaDiff[] => { + return [ + { type: 'index.drop', indexName: target.name, reason }, + { type: 'index.create', index: source, reason }, + ]; +}; diff --git a/server/src/sql-tools/schema-from-database.ts b/server/src/sql-tools/schema-from-database.ts new file mode 100644 index 0000000000..fe7af6b623 --- /dev/null +++ b/server/src/sql-tools/schema-from-database.ts @@ -0,0 +1,394 @@ +import { Kysely, sql } from 'kysely'; +import { PostgresJSDialect } from 'kysely-postgres-js'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; +import { Sql } from 'postgres'; +import { + DatabaseActionType, + DatabaseClient, + DatabaseColumn, + DatabaseColumnType, + DatabaseConstraintType, + DatabaseSchema, + DatabaseTable, + LoadSchemaOptions, + PostgresDB, +} from 'src/sql-tools/types'; + +/** + * Load the database schema from the database + */ +export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptions = {}): Promise => { + const db = createDatabaseClient(postgres); + + const warnings: string[] = []; + const warn = (message: string) => { + warnings.push(message); + }; + + const schemaName = options.schemaName || 'public'; + const tablesMap: Record = {}; + + const [tables, columns, indexes, constraints, enums] = await Promise.all([ + getTables(db, schemaName), + getTableColumns(db, schemaName), + getTableIndexes(db, schemaName), + getTableConstraints(db, schemaName), + getUserDefinedEnums(db, schemaName), + ]); + + const enumMap = Object.fromEntries(enums.map((e) => [e.name, e.values])); + + // add tables + for (const table of tables) { + const tableName = table.table_name; + if (tablesMap[tableName]) { + continue; + } + + tablesMap[table.table_name] = { + name: table.table_name, + columns: [], + indexes: [], + constraints: [], + synchronize: true, + }; + } + + // add columns to tables + for (const column of columns) { + const table = tablesMap[column.table_name]; + if (!table) { + continue; + } + + const columnName = column.column_name; + + const item: DatabaseColumn = { + type: column.data_type as DatabaseColumnType, + name: columnName, + tableName: column.table_name, + nullable: column.is_nullable === 'YES', + isArray: column.array_type !== null, + numericPrecision: column.numeric_precision ?? undefined, + numericScale: column.numeric_scale ?? undefined, + default: column.column_default ?? undefined, + synchronize: true, + }; + + const columnLabel = `${table.name}.${columnName}`; + + switch (column.data_type) { + // array types + case 'ARRAY': { + if (!column.array_type) { + warn(`Unable to find type for ${columnLabel} (ARRAY)`); + continue; + } + item.type = column.array_type as DatabaseColumnType; + break; + } + + // enum types + case 'USER-DEFINED': { + if (!enumMap[column.udt_name]) { + warn(`Unable to find type for ${columnLabel} (ENUM)`); + continue; + } + + item.type = 'enum'; + item.enumName = column.udt_name; + item.enumValues = enumMap[column.udt_name]; + break; + } + } + + table.columns.push(item); + } + + // add table indexes + for (const index of indexes) { + const table = tablesMap[index.table_name]; + if (!table) { + continue; + } + + const indexName = index.index_name; + + table.indexes.push({ + name: indexName, + tableName: index.table_name, + columnNames: index.column_names ?? undefined, + expression: index.expression ?? undefined, + using: index.using, + where: index.where ?? undefined, + unique: index.unique, + synchronize: true, + }); + } + + // add table constraints + for (const constraint of constraints) { + const table = tablesMap[constraint.table_name]; + if (!table) { + continue; + } + + const constraintName = constraint.constraint_name; + + switch (constraint.constraint_type) { + // primary key constraint + case 'p': { + if (!constraint.column_names) { + warn(`Skipping CONSTRAINT "${constraintName}", no columns found`); + continue; + } + table.constraints.push({ + type: DatabaseConstraintType.PRIMARY_KEY, + name: constraintName, + tableName: constraint.table_name, + columnNames: constraint.column_names, + synchronize: true, + }); + break; + } + + // foreign key constraint + case 'f': { + if (!constraint.column_names || !constraint.reference_table_name || !constraint.reference_column_names) { + warn( + `Skipping CONSTRAINT "${constraintName}", missing either columns, referenced table, or referenced columns,`, + ); + continue; + } + + table.constraints.push({ + type: DatabaseConstraintType.FOREIGN_KEY, + name: constraintName, + tableName: constraint.table_name, + columnNames: constraint.column_names, + referenceTableName: constraint.reference_table_name, + referenceColumnNames: constraint.reference_column_names, + onUpdate: asDatabaseAction(constraint.update_action), + onDelete: asDatabaseAction(constraint.delete_action), + synchronize: true, + }); + break; + } + + // unique constraint + case 'u': { + table.constraints.push({ + type: DatabaseConstraintType.UNIQUE, + name: constraintName, + tableName: constraint.table_name, + columnNames: constraint.column_names as string[], + synchronize: true, + }); + break; + } + + // check constraint + case 'c': { + table.constraints.push({ + type: DatabaseConstraintType.CHECK, + name: constraint.constraint_name, + tableName: constraint.table_name, + expression: constraint.expression.replace('CHECK ', ''), + synchronize: true, + }); + break; + } + } + } + + await db.destroy(); + + return { + name: schemaName, + tables: Object.values(tablesMap), + warnings, + }; +}; + +const createDatabaseClient = (postgres: Sql): DatabaseClient => + new Kysely({ dialect: new PostgresJSDialect({ postgres }) }); + +const asDatabaseAction = (action: string) => { + switch (action) { + case 'a': { + return DatabaseActionType.NO_ACTION; + } + case 'c': { + return DatabaseActionType.CASCADE; + } + case 'r': { + return DatabaseActionType.RESTRICT; + } + case 'n': { + return DatabaseActionType.SET_NULL; + } + case 'd': { + return DatabaseActionType.SET_DEFAULT; + } + + default: { + return DatabaseActionType.NO_ACTION; + } + } +}; + +const getTables = (db: DatabaseClient, schemaName: string) => { + return db + .selectFrom('information_schema.tables') + .where('table_schema', '=', schemaName) + .where('table_type', '=', sql.lit('BASE TABLE')) + .selectAll() + .execute(); +}; + +const getUserDefinedEnums = async (db: DatabaseClient, schemaName: string) => { + const items = await db + .selectFrom('pg_type') + .innerJoin('pg_namespace', (join) => + join.onRef('pg_namespace.oid', '=', 'pg_type.typnamespace').on('pg_namespace.nspname', '=', schemaName), + ) + .where('typtype', '=', sql.lit('e')) + .select((eb) => [ + 'pg_type.typname as name', + jsonArrayFrom( + eb.selectFrom('pg_enum as e').select(['e.enumlabel as value']).whereRef('e.enumtypid', '=', 'pg_type.oid'), + ).as('values'), + ]) + .execute(); + + return items.map((item) => ({ + name: item.name, + values: item.values.map(({ value }) => value), + })); +}; + +const getTableColumns = (db: DatabaseClient, schemaName: string) => { + return db + .selectFrom('information_schema.columns as c') + .leftJoin('information_schema.element_types as o', (join) => + join + .onRef('c.table_catalog', '=', 'o.object_catalog') + .onRef('c.table_schema', '=', 'o.object_schema') + .onRef('c.table_name', '=', 'o.object_name') + .on('o.object_type', '=', sql.lit('TABLE')) + .onRef('c.dtd_identifier', '=', 'o.collection_type_identifier'), + ) + .leftJoin('pg_type as t', (join) => + join.onRef('t.typname', '=', 'c.udt_name').on('c.data_type', '=', sql.lit('USER-DEFINED')), + ) + .leftJoin('pg_enum as e', (join) => join.onRef('e.enumtypid', '=', 't.oid')) + .select([ + 'c.table_name', + 'c.column_name', + + // is ARRAY, USER-DEFINED, or data type + 'c.data_type', + 'c.column_default', + 'c.is_nullable', + + // number types + 'c.numeric_precision', + 'c.numeric_scale', + + // date types + 'c.datetime_precision', + + // user defined type + 'c.udt_catalog', + 'c.udt_schema', + 'c.udt_name', + + // data type for ARRAYs + 'o.data_type as array_type', + ]) + .where('table_schema', '=', schemaName) + .execute(); +}; + +const getTableIndexes = (db: DatabaseClient, schemaName: string) => { + return ( + db + .selectFrom('pg_index as ix') + // matching index, which has column information + .innerJoin('pg_class as i', 'ix.indexrelid', 'i.oid') + .innerJoin('pg_am as a', 'i.relam', 'a.oid') + // matching table + .innerJoin('pg_class as t', 'ix.indrelid', 't.oid') + // namespace + .innerJoin('pg_namespace', 'pg_namespace.oid', 'i.relnamespace') + // PK and UQ constraints automatically have indexes, so we can ignore those + .leftJoin('pg_constraint', (join) => + join + .onRef('pg_constraint.conindid', '=', 'i.oid') + .on('pg_constraint.contype', 'in', [sql.lit('p'), sql.lit('u')]), + ) + .where('pg_constraint.oid', 'is', null) + .select((eb) => [ + 'i.relname as index_name', + 't.relname as table_name', + 'ix.indisunique as unique', + 'a.amname as using', + eb.fn('pg_get_expr', ['ix.indexprs', 'ix.indrelid']).as('expression'), + eb.fn('pg_get_expr', ['ix.indpred', 'ix.indrelid']).as('where'), + eb + .selectFrom('pg_attribute as a') + .where('t.relkind', '=', sql.lit('r')) + .whereRef('a.attrelid', '=', 't.oid') + // list of columns numbers in the index + .whereRef('a.attnum', '=', sql`any("ix"."indkey")`) + .select((eb) => eb.fn('json_agg', ['a.attname']).as('column_name')) + .as('column_names'), + ]) + .where('pg_namespace.nspname', '=', schemaName) + .where('ix.indisprimary', '=', sql.lit(false)) + .execute() + ); +}; + +const getTableConstraints = (db: DatabaseClient, schemaName: string) => { + return db + .selectFrom('pg_constraint') + .innerJoin('pg_namespace', 'pg_namespace.oid', 'pg_constraint.connamespace') // namespace + .innerJoin('pg_class as source_table', (join) => + join.onRef('source_table.oid', '=', 'pg_constraint.conrelid').on('source_table.relkind', 'in', [ + // ordinary table + sql.lit('r'), + // partitioned table + sql.lit('p'), + // foreign table + sql.lit('f'), + ]), + ) // table + .leftJoin('pg_class as reference_table', 'reference_table.oid', 'pg_constraint.confrelid') // reference table + .select((eb) => [ + 'pg_constraint.contype as constraint_type', + 'pg_constraint.conname as constraint_name', + 'source_table.relname as table_name', + 'reference_table.relname as reference_table_name', + 'pg_constraint.confupdtype as update_action', + 'pg_constraint.confdeltype as delete_action', + // 'pg_constraint.oid as constraint_id', + eb + .selectFrom('pg_attribute') + // matching table for PK, FK, and UQ + .whereRef('pg_attribute.attrelid', '=', 'pg_constraint.conrelid') + .whereRef('pg_attribute.attnum', '=', sql`any("pg_constraint"."conkey")`) + .select((eb) => eb.fn('json_agg', ['pg_attribute.attname']).as('column_name')) + .as('column_names'), + eb + .selectFrom('pg_attribute') + // matching foreign table for FK + .whereRef('pg_attribute.attrelid', '=', 'pg_constraint.confrelid') + .whereRef('pg_attribute.attnum', '=', sql`any("pg_constraint"."confkey")`) + .select((eb) => eb.fn('json_agg', ['pg_attribute.attname']).as('column_name')) + .as('reference_column_names'), + eb.fn('pg_get_constraintdef', ['pg_constraint.oid']).as('expression'), + ]) + .where('pg_namespace.nspname', '=', schemaName) + .execute(); +}; diff --git a/server/src/sql-tools/schema-from-decorators.spec.ts b/server/src/sql-tools/schema-from-decorators.spec.ts new file mode 100644 index 0000000000..6703277844 --- /dev/null +++ b/server/src/sql-tools/schema-from-decorators.spec.ts @@ -0,0 +1,31 @@ +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { reset, schemaFromDecorators } from 'src/sql-tools/schema-from-decorators'; +import { describe, expect, it } from 'vitest'; + +describe('schemaDiff', () => { + beforeEach(() => { + reset(); + }); + + it('should work', () => { + expect(schemaFromDecorators()).toEqual({ + name: 'public', + tables: [], + warnings: [], + }); + }); + + describe('test files', () => { + const files = readdirSync('test/sql-tools', { withFileTypes: true }); + for (const file of files) { + const filePath = join(file.parentPath, file.name); + it(filePath, async () => { + const module = await import(filePath); + expect(module.description).toBeDefined(); + expect(module.schema).toBeDefined(); + expect(schemaFromDecorators(), module.description).toEqual(module.schema); + }); + } + }); +}); diff --git a/server/src/sql-tools/schema-from-decorators.ts b/server/src/sql-tools/schema-from-decorators.ts new file mode 100644 index 0000000000..b11817678e --- /dev/null +++ b/server/src/sql-tools/schema-from-decorators.ts @@ -0,0 +1,443 @@ +/* eslint-disable @typescript-eslint/no-unsafe-function-type */ +import { createHash } from 'node:crypto'; +import 'reflect-metadata'; +import { + CheckOptions, + ColumnDefaultValue, + ColumnIndexOptions, + ColumnOptions, + DatabaseActionType, + DatabaseColumn, + DatabaseConstraintType, + DatabaseSchema, + DatabaseTable, + ForeignKeyColumnOptions, + IndexOptions, + TableOptions, + UniqueOptions, +} from 'src/sql-tools/types'; + +enum SchemaKey { + TableName = 'immich-schema:table-name', + ColumnName = 'immich-schema:column-name', + IndexName = 'immich-schema:index-name', +} + +type SchemaTable = DatabaseTable & { options: TableOptions }; +type SchemaTables = SchemaTable[]; +type ClassBased = { object: Function } & T; +type PropertyBased = { object: object; propertyName: string | symbol } & T; +type RegisterItem = + | { type: 'table'; item: ClassBased<{ options: TableOptions }> } + | { type: 'index'; item: ClassBased<{ options: IndexOptions }> } + | { type: 'uniqueConstraint'; item: ClassBased<{ options: UniqueOptions }> } + | { type: 'checkConstraint'; item: ClassBased<{ options: CheckOptions }> } + | { type: 'column'; item: PropertyBased<{ options: ColumnOptions }> } + | { type: 'columnIndex'; item: PropertyBased<{ options: ColumnIndexOptions }> } + | { type: 'foreignKeyColumn'; item: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }> }; + +const items: RegisterItem[] = []; +export const register = (item: RegisterItem) => void items.push(item); + +const asSnakeCase = (name: string): string => name.replaceAll(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); +const asKey = (prefix: string, tableName: string, values: string[]) => + (prefix + sha1(`${tableName}_${values.toSorted().join('_')}`)).slice(0, 30); +const asPrimaryKeyConstraintName = (table: string, columns: string[]) => asKey('PK_', table, columns); +const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, columns); +const asRelationKeyConstraintName = (table: string, columns: string[]) => asKey('REL_', table, columns); +const asUniqueConstraintName = (table: string, columns: string[]) => asKey('UQ_', table, columns); +const asCheckConstraintName = (table: string, expression: string) => asKey('CHK_', table, [expression]); +const asIndexName = (table: string, columns: string[] | undefined, where: string | undefined) => { + const items: string[] = []; + for (const columnName of columns ?? []) { + items.push(columnName); + } + + if (where) { + items.push(where); + } + + return asKey('IDX_', table, items); +}; + +const makeColumn = ({ + name, + tableName, + options, +}: { + name: string; + tableName: string; + options: ColumnOptions; +}): DatabaseColumn => { + const columnName = options.name ?? name; + const enumName = options.enumName ?? `${tableName}_${columnName}_enum`.toLowerCase(); + let defaultValue = asDefaultValue(options); + let nullable = options.nullable ?? false; + + if (defaultValue === null) { + nullable = true; + defaultValue = undefined; + } + + const isEnum = !!options.enum; + + return { + name: columnName, + tableName, + primary: options.primary ?? false, + default: defaultValue, + nullable, + enumName: isEnum ? enumName : undefined, + enumValues: isEnum ? Object.values(options.enum as object) : undefined, + isArray: options.array ?? false, + type: isEnum ? 'enum' : options.type || 'character varying', + synchronize: options.synchronize ?? true, + }; +}; + +const asDefaultValue = (options: { nullable?: boolean; default?: ColumnDefaultValue }) => { + if (typeof options.default === 'function') { + return options.default() as string; + } + + if (options.default === undefined) { + return; + } + + const value = options.default; + + if (value === null) { + return value; + } + + if (typeof value === 'number') { + return String(value); + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + + if (value instanceof Date) { + return `'${value.toISOString()}'`; + } + + return `'${String(value)}'`; +}; + +const missingTableError = (context: string, object: object, propertyName?: string | symbol) => { + const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : ''); + return `[${context}] Unable to find table (${label})`; +}; + +// match TypeORM +const sha1 = (value: string) => createHash('sha1').update(value).digest('hex'); + +const findByName = (items: T[], name?: string) => + name ? items.find((item) => item.name === name) : undefined; +const resolveTable = (tables: SchemaTables, object: object) => + findByName(tables, Reflect.getMetadata(SchemaKey.TableName, object)); + +let initialized = false; +let schema: DatabaseSchema; + +export const reset = () => { + initialized = false; + items.length = 0; +}; + +export const schemaFromDecorators = () => { + if (!initialized) { + const schemaTables: SchemaTables = []; + + const warnings: string[] = []; + const warn = (message: string) => void warnings.push(message); + + for (const { item } of items.filter((item) => item.type === 'table')) { + processTable(schemaTables, item); + } + + for (const { item } of items.filter((item) => item.type === 'column')) { + processColumn(schemaTables, item, { warn }); + } + + for (const { item } of items.filter((item) => item.type === 'foreignKeyColumn')) { + processForeignKeyColumn(schemaTables, item, { warn }); + } + + for (const { item } of items.filter((item) => item.type === 'uniqueConstraint')) { + processUniqueConstraint(schemaTables, item, { warn }); + } + + for (const { item } of items.filter((item) => item.type === 'checkConstraint')) { + processCheckConstraint(schemaTables, item, { warn }); + } + + for (const table of schemaTables) { + processPrimaryKeyConstraint(table); + } + + for (const { item } of items.filter((item) => item.type === 'index')) { + processIndex(schemaTables, item, { warn }); + } + + for (const { item } of items.filter((item) => item.type === 'columnIndex')) { + processColumnIndex(schemaTables, item, { warn }); + } + + for (const { item } of items.filter((item) => item.type === 'foreignKeyColumn')) { + processForeignKeyConstraint(schemaTables, item, { warn }); + } + + schema = { + name: 'public', + tables: schemaTables.map(({ options: _, ...table }) => table), + warnings, + }; + + initialized = true; + } + + return schema; +}; + +const processTable = (tables: SchemaTables, { object, options }: ClassBased<{ options: TableOptions }>) => { + const tableName = options.name || asSnakeCase(object.name); + Reflect.defineMetadata(SchemaKey.TableName, tableName, object); + tables.push({ + name: tableName, + columns: [], + constraints: [], + indexes: [], + options, + synchronize: options.synchronize ?? true, + }); +}; + +type OnWarn = (message: string) => void; + +const processColumn = ( + tables: SchemaTables, + { object, propertyName, options }: PropertyBased<{ options: ColumnOptions }>, + { warn }: { warn: OnWarn }, +) => { + const table = resolveTable(tables, object.constructor); + if (!table) { + warn(missingTableError('@Column', object, propertyName)); + return; + } + + // TODO make sure column name is unique + + const column = makeColumn({ name: String(propertyName), tableName: table.name, options }); + + Reflect.defineMetadata(SchemaKey.ColumnName, column.name, object, propertyName); + + table.columns.push(column); + + if (!options.primary && options.unique) { + table.constraints.push({ + type: DatabaseConstraintType.UNIQUE, + name: options.uniqueConstraintName || asUniqueConstraintName(table.name, [column.name]), + tableName: table.name, + columnNames: [column.name], + synchronize: options.synchronize ?? true, + }); + } +}; + +const processUniqueConstraint = ( + tables: SchemaTables, + { object, options }: ClassBased<{ options: UniqueOptions }>, + { warn }: { warn: OnWarn }, +) => { + const table = resolveTable(tables, object); + if (!table) { + warn(missingTableError('@Unique', object)); + return; + } + + const tableName = table.name; + const columnNames = options.columns; + + table.constraints.push({ + type: DatabaseConstraintType.UNIQUE, + name: options.name || asUniqueConstraintName(tableName, columnNames), + tableName, + columnNames, + synchronize: options.synchronize ?? true, + }); +}; + +const processCheckConstraint = ( + tables: SchemaTables, + { object, options }: ClassBased<{ options: CheckOptions }>, + { warn }: { warn: OnWarn }, +) => { + const table = resolveTable(tables, object); + if (!table) { + warn(missingTableError('@Check', object)); + return; + } + + const tableName = table.name; + + table.constraints.push({ + type: DatabaseConstraintType.CHECK, + name: options.name || asCheckConstraintName(tableName, options.expression), + tableName, + expression: options.expression, + synchronize: options.synchronize ?? true, + }); +}; + +const processPrimaryKeyConstraint = (table: SchemaTable) => { + const columnNames: string[] = []; + + for (const column of table.columns) { + if (column.primary) { + columnNames.push(column.name); + } + } + + if (columnNames.length > 0) { + table.constraints.push({ + type: DatabaseConstraintType.PRIMARY_KEY, + name: table.options.primaryConstraintName || asPrimaryKeyConstraintName(table.name, columnNames), + tableName: table.name, + columnNames, + synchronize: table.options.synchronize ?? true, + }); + } +}; + +const processIndex = ( + tables: SchemaTables, + { object, options }: ClassBased<{ options: IndexOptions }>, + { warn }: { warn: OnWarn }, +) => { + const table = resolveTable(tables, object); + if (!table) { + warn(missingTableError('@Index', object)); + return; + } + + table.indexes.push({ + name: options.name || asIndexName(table.name, options.columns, options.where), + tableName: table.name, + unique: options.unique ?? false, + expression: options.expression, + using: options.using, + where: options.where, + columnNames: options.columns, + synchronize: options.synchronize ?? true, + }); +}; + +const processColumnIndex = ( + tables: SchemaTables, + { object, propertyName, options }: PropertyBased<{ options: ColumnIndexOptions }>, + { warn }: { warn: OnWarn }, +) => { + const table = resolveTable(tables, object.constructor); + if (!table) { + warn(missingTableError('@ColumnIndex', object, propertyName)); + return; + } + + const column = findByName(table.columns, Reflect.getMetadata(SchemaKey.ColumnName, object, propertyName)); + if (!column) { + return; + } + + table.indexes.push({ + name: options.name || asIndexName(table.name, [column.name], options.where), + tableName: table.name, + unique: options.unique ?? false, + expression: options.expression, + using: options.using, + where: options.where, + columnNames: [column.name], + synchronize: options.synchronize ?? true, + }); +}; + +const processForeignKeyColumn = ( + tables: SchemaTables, + { object, propertyName, options }: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }>, + { warn }: { warn: OnWarn }, +) => { + const table = resolveTable(tables, object.constructor); + if (!table) { + warn(missingTableError('@ForeignKeyColumn', object)); + return; + } + + const columnName = String(propertyName); + const existingColumn = table.columns.find((column) => column.name === columnName); + if (existingColumn) { + // TODO log warnings if column options and `@Column` is also used + return; + } + + const column = makeColumn({ name: columnName, tableName: table.name, options }); + + Reflect.defineMetadata(SchemaKey.ColumnName, columnName, object, propertyName); + + table.columns.push(column); +}; + +const processForeignKeyConstraint = ( + tables: SchemaTables, + { object, propertyName, options, target }: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }>, + { warn }: { warn: OnWarn }, +) => { + const childTable = resolveTable(tables, object.constructor); + if (!childTable) { + warn(missingTableError('@ForeignKeyColumn', object)); + return; + } + + const parentTable = resolveTable(tables, target()); + if (!parentTable) { + warn(missingTableError('@ForeignKeyColumn', object, propertyName)); + return; + } + + const columnName = String(propertyName); + const column = childTable.columns.find((column) => column.name === columnName); + if (!column) { + warn('@ForeignKeyColumn: Column not found, creating a new one'); + return; + } + + const columnNames = [column.name]; + const referenceColumns = parentTable.columns.filter((column) => column.primary); + + // infer FK column type from reference table + if (referenceColumns.length === 1) { + column.type = referenceColumns[0].type; + } + + childTable.constraints.push({ + name: options.constraintName || asForeignKeyConstraintName(childTable.name, columnNames), + tableName: childTable.name, + columnNames, + type: DatabaseConstraintType.FOREIGN_KEY, + referenceTableName: parentTable.name, + referenceColumnNames: referenceColumns.map((column) => column.name), + onUpdate: options.onUpdate as DatabaseActionType, + onDelete: options.onDelete as DatabaseActionType, + synchronize: options.synchronize ?? true, + }); + + if (options.unique) { + childTable.constraints.push({ + name: options.uniqueConstraintName || asRelationKeyConstraintName(childTable.name, columnNames), + tableName: childTable.name, + columnNames, + type: DatabaseConstraintType.UNIQUE, + synchronize: options.synchronize ?? true, + }); + } +}; diff --git a/server/src/sql-tools/types.ts b/server/src/sql-tools/types.ts new file mode 100644 index 0000000000..64813ca348 --- /dev/null +++ b/server/src/sql-tools/types.ts @@ -0,0 +1,363 @@ +import { Kysely } from 'kysely'; + +export type PostgresDB = { + pg_am: { + oid: number; + amname: string; + amhandler: string; + amtype: string; + }; + + pg_attribute: { + attrelid: number; + attname: string; + attnum: number; + atttypeid: number; + attstattarget: number; + attstatarget: number; + aanum: number; + }; + + pg_class: { + oid: number; + relname: string; + relkind: string; + relnamespace: string; + reltype: string; + relowner: string; + relam: string; + relfilenode: string; + reltablespace: string; + relpages: number; + reltuples: number; + relallvisible: number; + reltoastrelid: string; + relhasindex: PostgresYesOrNo; + relisshared: PostgresYesOrNo; + relpersistence: string; + }; + + pg_constraint: { + oid: number; + conname: string; + conrelid: string; + contype: string; + connamespace: string; + conkey: number[]; + confkey: number[]; + confrelid: string; + confupdtype: string; + confdeltype: string; + confmatchtype: number; + condeferrable: PostgresYesOrNo; + condeferred: PostgresYesOrNo; + convalidated: PostgresYesOrNo; + conindid: number; + }; + + pg_enum: { + oid: string; + enumtypid: string; + enumsortorder: number; + enumlabel: string; + }; + + pg_index: { + indexrelid: string; + indrelid: string; + indisready: boolean; + indexprs: string | null; + indpred: string | null; + indkey: number[]; + indisprimary: boolean; + indisunique: boolean; + }; + + pg_indexes: { + schemaname: string; + tablename: string; + indexname: string; + tablespace: string | null; + indexrelid: string; + indexdef: string; + }; + + pg_namespace: { + oid: number; + nspname: string; + nspowner: number; + nspacl: string[]; + }; + + pg_type: { + oid: string; + typname: string; + typnamespace: string; + typowner: string; + typtype: string; + typcategory: string; + typarray: string; + }; + + 'information_schema.tables': { + table_catalog: string; + table_schema: string; + table_name: string; + table_type: 'VIEW' | 'BASE TABLE' | string; + is_insertable_info: PostgresYesOrNo; + is_typed: PostgresYesOrNo; + commit_action: string | null; + }; + + 'information_schema.columns': { + table_catalog: string; + table_schema: string; + table_name: string; + column_name: string; + ordinal_position: number; + column_default: string | null; + is_nullable: PostgresYesOrNo; + data_type: string; + dtd_identifier: string; + character_maximum_length: number | null; + character_octet_length: number | null; + numeric_precision: number | null; + numeric_precision_radix: number | null; + numeric_scale: number | null; + datetime_precision: number | null; + interval_type: string | null; + interval_precision: number | null; + udt_catalog: string; + udt_schema: string; + udt_name: string; + maximum_cardinality: number | null; + is_updatable: PostgresYesOrNo; + }; + + 'information_schema.element_types': { + object_catalog: string; + object_schema: string; + object_name: string; + object_type: string; + collection_type_identifier: string; + data_type: string; + }; +}; + +type PostgresYesOrNo = 'YES' | 'NO'; + +export type ColumnDefaultValue = null | boolean | string | number | object | Date | (() => string); + +export type DatabaseClient = Kysely; + +export enum DatabaseConstraintType { + PRIMARY_KEY = 'primary-key', + FOREIGN_KEY = 'foreign-key', + UNIQUE = 'unique', + CHECK = 'check', +} + +export enum DatabaseActionType { + NO_ACTION = 'NO ACTION', + RESTRICT = 'RESTRICT', + CASCADE = 'CASCADE', + SET_NULL = 'SET NULL', + SET_DEFAULT = 'SET DEFAULT', +} + +export type DatabaseColumnType = + | 'bigint' + | 'boolean' + | 'bytea' + | 'character' + | 'character varying' + | 'date' + | 'double precision' + | 'integer' + | 'jsonb' + | 'polygon' + | 'text' + | 'time' + | 'time with time zone' + | 'time without time zone' + | 'timestamp' + | 'timestamp with time zone' + | 'timestamp without time zone' + | 'uuid' + | 'vector' + | 'enum' + | 'serial'; + +export type TableOptions = { + name?: string; + primaryConstraintName?: string; + synchronize?: boolean; +}; + +type ColumnBaseOptions = { + name?: string; + primary?: boolean; + type?: DatabaseColumnType; + nullable?: boolean; + length?: number; + default?: ColumnDefaultValue; + synchronize?: boolean; +}; + +export type ColumnOptions = ColumnBaseOptions & { + enum?: object; + enumName?: string; + array?: boolean; + unique?: boolean; + uniqueConstraintName?: string; +}; + +export type GenerateColumnOptions = Omit & { + type?: 'v4' | 'v7'; +}; + +export type ColumnIndexOptions = { + name?: string; + unique?: boolean; + expression?: string; + using?: string; + where?: string; + synchronize?: boolean; +}; + +export type IndexOptions = ColumnIndexOptions & { + columns?: string[]; + synchronize?: boolean; +}; + +export type UniqueOptions = { + name?: string; + columns: string[]; + synchronize?: boolean; +}; + +export type CheckOptions = { + name?: string; + expression: string; + synchronize?: boolean; +}; + +export type DatabaseSchema = { + name: string; + tables: DatabaseTable[]; + warnings: string[]; +}; + +export type DatabaseTable = { + name: string; + columns: DatabaseColumn[]; + indexes: DatabaseIndex[]; + constraints: DatabaseConstraint[]; + synchronize: boolean; +}; + +export type DatabaseConstraint = + | DatabasePrimaryKeyConstraint + | DatabaseForeignKeyConstraint + | DatabaseUniqueConstraint + | DatabaseCheckConstraint; + +export type DatabaseColumn = { + primary?: boolean; + name: string; + tableName: string; + + type: DatabaseColumnType; + nullable: boolean; + isArray: boolean; + synchronize: boolean; + + default?: string; + length?: number; + + // enum values + enumValues?: string[]; + enumName?: string; + + // numeric types + numericPrecision?: number; + numericScale?: number; +}; + +export type DatabaseColumnChanges = { + nullable?: boolean; + default?: string; +}; + +type ColumBasedConstraint = { + name: string; + tableName: string; + columnNames: string[]; +}; + +export type DatabasePrimaryKeyConstraint = ColumBasedConstraint & { + type: DatabaseConstraintType.PRIMARY_KEY; + synchronize: boolean; +}; + +export type DatabaseUniqueConstraint = ColumBasedConstraint & { + type: DatabaseConstraintType.UNIQUE; + synchronize: boolean; +}; + +export type DatabaseForeignKeyConstraint = ColumBasedConstraint & { + type: DatabaseConstraintType.FOREIGN_KEY; + referenceTableName: string; + referenceColumnNames: string[]; + onUpdate?: DatabaseActionType; + onDelete?: DatabaseActionType; + synchronize: boolean; +}; + +export type DatabaseCheckConstraint = { + type: DatabaseConstraintType.CHECK; + name: string; + tableName: string; + expression: string; + synchronize: boolean; +}; + +export type DatabaseIndex = { + name: string; + tableName: string; + columnNames?: string[]; + expression?: string; + unique: boolean; + using?: string; + where?: string; + synchronize: boolean; +}; + +export type LoadSchemaOptions = { + schemaName?: string; +}; + +export type SchemaDiffToSqlOptions = { + comments?: boolean; +}; + +export type SchemaDiff = { reason: string } & ( + | { type: 'table.create'; tableName: string; columns: DatabaseColumn[] } + | { type: 'table.drop'; tableName: string } + | { type: 'column.add'; column: DatabaseColumn } + | { type: 'column.alter'; tableName: string; columnName: string; changes: DatabaseColumnChanges } + | { type: 'column.drop'; tableName: string; columnName: string } + | { type: 'constraint.add'; constraint: DatabaseConstraint } + | { type: 'constraint.drop'; tableName: string; constraintName: string } + | { type: 'index.create'; index: DatabaseIndex } + | { type: 'index.drop'; indexName: string } +); + +type Action = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION'; +export type ForeignKeyColumnOptions = ColumnBaseOptions & { + onUpdate?: Action; + onDelete?: Action; + constraintName?: string; + unique?: boolean; + uniqueConstraintName?: string; +}; diff --git a/server/src/subscribers/audit.subscriber.ts b/server/src/subscribers/audit.subscriber.ts deleted file mode 100644 index 8c2ad3e18d..0000000000 --- a/server/src/subscribers/audit.subscriber.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { AlbumEntity } from 'src/entities/album.entity'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { AuditEntity } from 'src/entities/audit.entity'; -import { DatabaseAction, EntityType } from 'src/enum'; -import { EntitySubscriberInterface, EventSubscriber, RemoveEvent } from 'typeorm'; - -@EventSubscriber() -export class AuditSubscriber implements EntitySubscriberInterface { - async afterRemove(event: RemoveEvent): Promise { - await this.onEvent(DatabaseAction.DELETE, event); - } - - private async onEvent(action: DatabaseAction, event: RemoveEvent): Promise { - const audit = this.getAudit(event.metadata.name, { ...event.entity, id: event.entityId }); - if (audit && audit.entityId && audit.ownerId) { - await event.manager.getRepository(AuditEntity).save({ ...audit, action }); - } - } - - private getAudit(entityName: string, entity: any): Partial | null { - switch (entityName) { - case AssetEntity.name: { - const asset = entity as AssetEntity; - return { - entityType: EntityType.ASSET, - entityId: asset.id, - ownerId: asset.ownerId, - }; - } - - case AlbumEntity.name: { - const album = entity as AlbumEntity; - return { - entityType: EntityType.ALBUM, - entityId: album.id, - ownerId: album.ownerId, - }; - } - } - - return null; - } -} diff --git a/server/src/tables/activity.table.ts b/server/src/tables/activity.table.ts new file mode 100644 index 0000000000..d7bc7a7bc0 --- /dev/null +++ b/server/src/tables/activity.table.ts @@ -0,0 +1,56 @@ +import { + Check, + Column, + ColumnIndex, + CreateDateColumn, + ForeignKeyColumn, + Index, + PrimaryGeneratedColumn, + Table, + UpdateDateColumn, + UpdateIdColumn, +} from 'src/sql-tools'; +import { AlbumTable } from 'src/tables/album.table'; +import { AssetTable } from 'src/tables/asset.table'; +import { UserTable } from 'src/tables/user.table'; + +@Table('activity') +@Index({ + name: 'IDX_activity_like', + columns: ['assetId', 'userId', 'albumId'], + unique: true, + where: '("isLiked" = true)', +}) +@Check({ + name: 'CHK_2ab1e70f113f450eb40c1e3ec8', + expression: `("comment" IS NULL AND "isLiked" = true) OR ("comment" IS NOT NULL AND "isLiked" = false)`, +}) +export class ActivityTable { + @PrimaryGeneratedColumn() + id!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @ColumnIndex('IDX_activity_update_id') + @UpdateIdColumn() + updateId!: string; + + @Column({ type: 'text', default: null }) + comment!: string | null; + + @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; +} diff --git a/server/src/tables/album-asset.table.ts b/server/src/tables/album-asset.table.ts new file mode 100644 index 0000000000..7c51ee9ac2 --- /dev/null +++ b/server/src/tables/album-asset.table.ts @@ -0,0 +1,27 @@ +import { ColumnIndex, CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-tools'; +import { AlbumTable } from 'src/tables/album.table'; +import { AssetTable } from 'src/tables/asset.table'; + +@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', + nullable: false, + primary: true, + }) + @ColumnIndex() + albumsId!: string; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/server/src/tables/album-user.table.ts b/server/src/tables/album-user.table.ts new file mode 100644 index 0000000000..3f9df51723 --- /dev/null +++ b/server/src/tables/album-user.table.ts @@ -0,0 +1,29 @@ +import { AlbumUserRole } from 'src/enum'; +import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools'; +import { AlbumTable } from 'src/tables/album.table'; +import { UserTable } from 'src/tables/user.table'; + +@Table({ name: 'albums_shared_users_users', primaryConstraintName: 'PK_7df55657e0b2e8b626330a0ebc8' }) +// Pre-existing indices from original album <--> user ManyToMany mapping +@Index({ name: 'IDX_427c350ad49bd3935a50baab73', columns: ['albumsId'] }) +@Index({ name: 'IDX_f48513bf9bccefd6ff3ad30bd0', columns: ['usersId'] }) +export class AlbumUserTable { + @ForeignKeyColumn(() => AlbumTable, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + nullable: false, + primary: true, + }) + albumsId!: string; + + @ForeignKeyColumn(() => UserTable, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + nullable: false, + primary: true, + }) + usersId!: string; + + @Column({ type: 'character varying', default: AlbumUserRole.EDITOR }) + role!: AlbumUserRole; +} diff --git a/server/src/tables/album.table.ts b/server/src/tables/album.table.ts new file mode 100644 index 0000000000..4f2f7d88f9 --- /dev/null +++ b/server/src/tables/album.table.ts @@ -0,0 +1,51 @@ +import { AssetOrder } from 'src/enum'; +import { + Column, + ColumnIndex, + CreateDateColumn, + DeleteDateColumn, + ForeignKeyColumn, + PrimaryGeneratedColumn, + Table, + UpdateDateColumn, + UpdateIdColumn, +} from 'src/sql-tools'; +import { AssetTable } from 'src/tables/asset.table'; +import { UserTable } from 'src/tables/user.table'; + +@Table({ name: 'albums', primaryConstraintName: 'PK_7f71c7b5bc7c87b8f94c9a93a00' }) +export class AlbumTable { + @PrimaryGeneratedColumn() + id!: string; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + ownerId!: string; + + @Column({ default: 'Untitled Album' }) + albumName!: string; + + @Column({ type: 'text', default: '' }) + description!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @ColumnIndex('IDX_albums_update_id') + @UpdateIdColumn() + updateId?: 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; +} diff --git a/server/src/tables/api-key.table.ts b/server/src/tables/api-key.table.ts new file mode 100644 index 0000000000..dd4100e86f --- /dev/null +++ b/server/src/tables/api-key.table.ts @@ -0,0 +1,40 @@ +import { Permission } from 'src/enum'; +import { + Column, + ColumnIndex, + CreateDateColumn, + ForeignKeyColumn, + PrimaryGeneratedColumn, + Table, + UpdateDateColumn, + UpdateIdColumn, +} from 'src/sql-tools'; +import { UserTable } from 'src/tables/user.table'; + +@Table('api_keys') +export class APIKeyTable { + @PrimaryGeneratedColumn() + id!: string; + + @Column() + name!: string; + + @Column() + key!: string; + + @Column({ array: true, type: 'character varying' }) + permissions!: Permission[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @ColumnIndex({ name: 'IDX_api_keys_update_id' }) + @UpdateIdColumn() + updateId?: string; + + @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + userId!: string; +} diff --git a/server/src/tables/asset-audit.table.ts b/server/src/tables/asset-audit.table.ts new file mode 100644 index 0000000000..10f7b535bc --- /dev/null +++ b/server/src/tables/asset-audit.table.ts @@ -0,0 +1,19 @@ +import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; + +@Table('assets_audit') +export class AssetAuditTable { + @PrimaryGeneratedColumn({ type: 'v7' }) + id!: string; + + @ColumnIndex('IDX_assets_audit_asset_id') + @Column({ type: 'uuid' }) + assetId!: string; + + @ColumnIndex('IDX_assets_audit_owner_id') + @Column({ type: 'uuid' }) + ownerId!: string; + + @ColumnIndex('IDX_assets_audit_deleted_at') + @CreateDateColumn({ default: () => 'clock_timestamp()' }) + deletedAt!: Date; +} diff --git a/server/src/tables/asset-face.table.ts b/server/src/tables/asset-face.table.ts new file mode 100644 index 0000000000..623df937af --- /dev/null +++ b/server/src/tables/asset-face.table.ts @@ -0,0 +1,42 @@ +import { SourceType } from 'src/enum'; +import { Column, DeleteDateColumn, ForeignKeyColumn, Index, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; +import { AssetTable } from 'src/tables/asset.table'; +import { PersonTable } from 'src/tables/person.table'; + +@Table({ name: 'asset_faces' }) +@Index({ name: 'IDX_asset_faces_assetId_personId', columns: ['assetId', 'personId'] }) +@Index({ columns: ['personId', 'assetId'] }) +export class AssetFaceTable { + @PrimaryGeneratedColumn() + id!: string; + + @Column({ default: 0, type: 'integer' }) + imageWidth!: number; + + @Column({ default: 0, type: 'integer' }) + imageHeight!: number; + + @Column({ default: 0, type: 'integer' }) + boundingBoxX1!: number; + + @Column({ default: 0, type: 'integer' }) + boundingBoxY1!: number; + + @Column({ default: 0, type: 'integer' }) + boundingBoxX2!: number; + + @Column({ default: 0, type: 'integer' }) + boundingBoxY2!: number; + + @Column({ default: SourceType.MACHINE_LEARNING, enumName: 'sourcetype', enum: SourceType }) + sourceType!: SourceType; + + @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + assetId!: string; + + @ForeignKeyColumn(() => PersonTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true }) + personId!: string | null; + + @DeleteDateColumn() + deletedAt!: Date | null; +} diff --git a/server/src/tables/asset-files.table.ts b/server/src/tables/asset-files.table.ts new file mode 100644 index 0000000000..fb32070751 --- /dev/null +++ b/server/src/tables/asset-files.table.ts @@ -0,0 +1,40 @@ +import { AssetEntity } from 'src/entities/asset.entity'; +import { AssetFileType } from 'src/enum'; +import { + Column, + ColumnIndex, + CreateDateColumn, + ForeignKeyColumn, + PrimaryGeneratedColumn, + Table, + Unique, + UpdateDateColumn, + UpdateIdColumn, +} from 'src/sql-tools'; + +@Unique({ name: 'UQ_assetId_type', columns: ['assetId', 'type'] }) +@Table('asset_files') +export class AssetFileTable { + @PrimaryGeneratedColumn() + id!: string; + + @ColumnIndex('IDX_asset_files_assetId') + @ForeignKeyColumn(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + assetId?: AssetEntity; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @ColumnIndex('IDX_asset_files_update_id') + @UpdateIdColumn() + updateId?: string; + + @Column() + type!: AssetFileType; + + @Column() + path!: string; +} diff --git a/server/src/tables/asset-job-status.table.ts b/server/src/tables/asset-job-status.table.ts new file mode 100644 index 0000000000..d996577ae4 --- /dev/null +++ b/server/src/tables/asset-job-status.table.ts @@ -0,0 +1,23 @@ +import { Column, ForeignKeyColumn, Table } from 'src/sql-tools'; +import { AssetTable } from 'src/tables/asset.table'; + +@Table('asset_job_status') +export class AssetJobStatusTable { + @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', primary: true }) + assetId!: string; + + @Column({ type: 'timestamp with time zone', nullable: true }) + facesRecognizedAt!: Date | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + metadataExtractedAt!: Date | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + duplicatesDetectedAt!: Date | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + previewAt!: Date | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + thumbnailAt!: Date | null; +} diff --git a/server/src/tables/asset.table.ts b/server/src/tables/asset.table.ts new file mode 100644 index 0000000000..7e857b8423 --- /dev/null +++ b/server/src/tables/asset.table.ts @@ -0,0 +1,138 @@ +import { ASSET_CHECKSUM_CONSTRAINT } from 'src/entities/asset.entity'; +import { AssetStatus, AssetType } from 'src/enum'; +import { + Column, + ColumnIndex, + CreateDateColumn, + DeleteDateColumn, + ForeignKeyColumn, + Index, + PrimaryGeneratedColumn, + Table, + UpdateDateColumn, + UpdateIdColumn, +} from 'src/sql-tools'; +import { LibraryTable } from 'src/tables/library.table'; +import { StackTable } from 'src/tables/stack.table'; +import { UserTable } from 'src/tables/user.table'; + +@Table('assets') +// Checksums must be unique per user and library +@Index({ + name: ASSET_CHECKSUM_CONSTRAINT, + columns: ['ownerId', 'checksum'], + unique: true, + where: '("libraryId" IS NULL)', +}) +@Index({ + name: 'UQ_assets_owner_library_checksum' + '', + columns: ['ownerId', 'libraryId', 'checksum'], + 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_month', + expression: `(date_trunc('MONTH'::text, ("localDateTime" AT TIME ZONE 'UTC'::text)) AT TIME ZONE 'UTC'::text)`, +}) +@Index({ name: 'IDX_originalPath_libraryId', columns: ['originalPath', 'libraryId'] }) +@Index({ name: 'IDX_asset_id_stackId', columns: ['id', 'stackId'] }) +@Index({ + name: 'idx_originalFileName_trigram', + using: 'gin', + expression: 'f_unaccent(("originalFileName")::text)', +}) +// For all assets, each originalpath must be unique per user and library +export class AssetTable { + @PrimaryGeneratedColumn() + id!: string; + + @Column() + deviceAssetId!: string; + + @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: 'boolean', default: false }) + isExternal!: boolean; + + @Column({ type: 'boolean', default: false }) + isOffline!: boolean; + + @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; + + @Column() + @ColumnIndex() + originalFileName!: string; + + @Column({ nullable: true }) + sidecarPath!: string | null; + + @ForeignKeyColumn(() => StackTable, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) + stackId?: string | null; + + @ColumnIndex('IDX_assets_duplicateId') + @Column({ type: 'uuid', nullable: true }) + duplicateId!: string | null; +} diff --git a/server/src/tables/audit.table.ts b/server/src/tables/audit.table.ts new file mode 100644 index 0000000000..a05b070ba7 --- /dev/null +++ b/server/src/tables/audit.table.ts @@ -0,0 +1,24 @@ +import { DatabaseAction, EntityType } from 'src/enum'; +import { Column, CreateDateColumn, Index, PrimaryColumn, Table } from 'src/sql-tools'; + +@Table('audit') +@Index({ name: 'IDX_ownerId_createdAt', columns: ['ownerId', 'createdAt'] }) +export class AuditTable { + @PrimaryColumn({ type: 'integer', default: 'increment', synchronize: false }) + id!: number; + + @Column() + entityType!: EntityType; + + @Column({ type: 'uuid' }) + entityId!: string; + + @Column() + action!: DatabaseAction; + + @Column({ type: 'uuid' }) + ownerId!: string; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/server/src/tables/exif.table.ts b/server/src/tables/exif.table.ts new file mode 100644 index 0000000000..e06659d811 --- /dev/null +++ b/server/src/tables/exif.table.ts @@ -0,0 +1,105 @@ +import { Column, ColumnIndex, ForeignKeyColumn, Table, UpdateDateColumn, UpdateIdColumn } from 'src/sql-tools'; +import { AssetTable } from 'src/tables/asset.table'; + +@Table('exif') +export class ExifTable { + @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true }) + assetId!: string; + + @UpdateDateColumn({ default: () => 'clock_timestamp()' }) + updatedAt?: Date; + + @ColumnIndex('IDX_asset_exif_update_id') + @UpdateIdColumn() + updateId?: string; + + /* General info */ + @Column({ type: 'text', default: '' }) + description!: string; // or caption + + @Column({ type: 'integer', nullable: true }) + exifImageWidth!: number | null; + + @Column({ type: 'integer', nullable: true }) + exifImageHeight!: number | null; + + @Column({ type: 'bigint', nullable: true }) + fileSizeInByte!: number | null; + + @Column({ type: 'character varying', nullable: true }) + orientation!: string | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + dateTimeOriginal!: Date | null; + + @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; + + @Column({ type: 'double precision', nullable: true }) + fNumber!: number | null; + + @Column({ type: 'double precision', nullable: true }) + focalLength!: number | null; + + @Column({ type: 'integer', nullable: true }) + iso!: number | null; + + @Column({ type: 'character varying', nullable: true }) + exposureTime!: string | null; + + @Column({ type: 'character varying', nullable: true }) + profileDescription!: string | null; + + @Column({ type: 'character varying', nullable: true }) + colorspace!: string | null; + + @Column({ type: 'integer', nullable: true }) + bitsPerSample!: number | null; + + @Column({ type: 'integer', nullable: true }) + rating!: number | null; + + /* Video info */ + @Column({ type: 'double precision', nullable: true }) + fps?: number | null; +} diff --git a/server/src/tables/face-search.table.ts b/server/src/tables/face-search.table.ts new file mode 100644 index 0000000000..286d09c677 --- /dev/null +++ b/server/src/tables/face-search.table.ts @@ -0,0 +1,16 @@ +import { Column, ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools'; +import { AssetFaceTable } from 'src/tables/asset-face.table'; + +@Table({ name: 'face_search', primaryConstraintName: 'face_search_pkey' }) +export class FaceSearchTable { + @ForeignKeyColumn(() => AssetFaceTable, { + onDelete: 'CASCADE', + primary: true, + constraintName: 'face_search_faceId_fkey', + }) + faceId!: string; + + @ColumnIndex({ name: 'face_index', synchronize: false }) + @Column({ type: 'vector', array: true, length: 512, synchronize: false }) + embedding!: string; +} diff --git a/server/src/tables/geodata-places.table.ts b/server/src/tables/geodata-places.table.ts new file mode 100644 index 0000000000..5216a295cb --- /dev/null +++ b/server/src/tables/geodata-places.table.ts @@ -0,0 +1,73 @@ +import { Column, PrimaryColumn, Table } from 'src/sql-tools'; + +@Table({ name: 'geodata_places', synchronize: false }) +export class GeodataPlacesTable { + @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; + + @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; + + @Column({ type: 'character varying', nullable: true }) + admin2Name!: string; + + @Column({ type: 'character varying', nullable: true }) + alternateNames!: string; + + @Column({ type: 'date' }) + modificationDate!: Date; +} diff --git a/server/src/tables/index.ts b/server/src/tables/index.ts new file mode 100644 index 0000000000..8b92b55187 --- /dev/null +++ b/server/src/tables/index.ts @@ -0,0 +1,70 @@ +import { ActivityTable } from 'src/tables/activity.table'; +import { AlbumAssetTable } from 'src/tables/album-asset.table'; +import { AlbumUserTable } from 'src/tables/album-user.table'; +import { AlbumTable } from 'src/tables/album.table'; +import { APIKeyTable } from 'src/tables/api-key.table'; +import { AssetAuditTable } from 'src/tables/asset-audit.table'; +import { AssetFaceTable } from 'src/tables/asset-face.table'; +import { AssetJobStatusTable } from 'src/tables/asset-job-status.table'; +import { AssetTable } from 'src/tables/asset.table'; +import { AuditTable } from 'src/tables/audit.table'; +import { ExifTable } from 'src/tables/exif.table'; +import { FaceSearchTable } from 'src/tables/face-search.table'; +import { GeodataPlacesTable } from 'src/tables/geodata-places.table'; +import { LibraryTable } from 'src/tables/library.table'; +import { MemoryTable } from 'src/tables/memory.table'; +import { MemoryAssetTable } from 'src/tables/memory_asset.table'; +import { MoveTable } from 'src/tables/move.table'; +import { NaturalEarthCountriesTable, NaturalEarthCountriesTempTable } from 'src/tables/natural-earth-countries.table'; +import { PartnerAuditTable } from 'src/tables/partner-audit.table'; +import { PartnerTable } from 'src/tables/partner.table'; +import { PersonTable } from 'src/tables/person.table'; +import { SessionTable } from 'src/tables/session.table'; +import { SharedLinkAssetTable } from 'src/tables/shared-link-asset.table'; +import { SharedLinkTable } from 'src/tables/shared-link.table'; +import { SmartSearchTable } from 'src/tables/smart-search.table'; +import { StackTable } from 'src/tables/stack.table'; +import { SessionSyncCheckpointTable } from 'src/tables/sync-checkpoint.table'; +import { SystemMetadataTable } from 'src/tables/system-metadata.table'; +import { TagAssetTable } from 'src/tables/tag-asset.table'; +import { UserAuditTable } from 'src/tables/user-audit.table'; +import { UserMetadataTable } from 'src/tables/user-metadata.table'; +import { UserTable } from 'src/tables/user.table'; +import { VersionHistoryTable } from 'src/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, +]; diff --git a/server/src/tables/library.table.ts b/server/src/tables/library.table.ts new file mode 100644 index 0000000000..9119c517ea --- /dev/null +++ b/server/src/tables/library.table.ts @@ -0,0 +1,46 @@ +import { + Column, + ColumnIndex, + CreateDateColumn, + DeleteDateColumn, + ForeignKeyColumn, + PrimaryGeneratedColumn, + Table, + UpdateDateColumn, + UpdateIdColumn, +} from 'src/sql-tools'; +import { UserTable } from 'src/tables/user.table'; + +@Table('libraries') +export class LibraryTable { + @PrimaryGeneratedColumn() + id!: string; + + @Column() + name!: string; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + ownerId!: string; + + @Column({ type: 'text', array: true }) + importPaths!: string[]; + + @Column({ type: 'text', array: true }) + exclusionPatterns!: string[]; + + @CreateDateColumn() + createdAt!: Date; + + @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; +} diff --git a/server/src/tables/memory.table.ts b/server/src/tables/memory.table.ts new file mode 100644 index 0000000000..9523e72610 --- /dev/null +++ b/server/src/tables/memory.table.ts @@ -0,0 +1,60 @@ +import { MemoryType } from 'src/enum'; +import { + Column, + ColumnIndex, + CreateDateColumn, + DeleteDateColumn, + ForeignKeyColumn, + PrimaryGeneratedColumn, + Table, + UpdateDateColumn, + UpdateIdColumn, +} from 'src/sql-tools'; +import { UserTable } from 'src/tables/user.table'; +import { MemoryData } from 'src/types'; + +@Table('memories') +export class MemoryTable { + @PrimaryGeneratedColumn() + id!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @ColumnIndex('IDX_memories_update_id') + @UpdateIdColumn() + updateId?: string; + + @DeleteDateColumn() + deletedAt?: Date; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + ownerId!: string; + + @Column() + type!: T; + + @Column({ type: 'jsonb' }) + data!: MemoryData[T]; + + /** unless set to true, will be automatically deleted in the future */ + @Column({ type: 'boolean', default: false }) + isSaved!: boolean; + + /** memories are sorted in ascending order by this value */ + @Column({ type: 'timestamp with time zone' }) + memoryAt!: 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; +} diff --git a/server/src/tables/memory_asset.table.ts b/server/src/tables/memory_asset.table.ts new file mode 100644 index 0000000000..543c81c597 --- /dev/null +++ b/server/src/tables/memory_asset.table.ts @@ -0,0 +1,14 @@ +import { ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools'; +import { AssetTable } from 'src/tables/asset.table'; +import { MemoryTable } from 'src/tables/memory.table'; + +@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; +} diff --git a/server/src/tables/move.table.ts b/server/src/tables/move.table.ts new file mode 100644 index 0000000000..cdc00efcaf --- /dev/null +++ b/server/src/tables/move.table.ts @@ -0,0 +1,24 @@ +import { PathType } from 'src/enum'; +import { Column, PrimaryGeneratedColumn, Table, Unique } from 'src/sql-tools'; + +@Table('move_history') +// path lock (per entity) +@Unique({ name: 'UQ_entityId_pathType', columns: ['entityId', 'pathType'] }) +// new path lock (global) +@Unique({ name: 'UQ_newPath', columns: ['newPath'] }) +export class MoveTable { + @PrimaryGeneratedColumn() + id!: string; + + @Column({ type: 'uuid' }) + entityId!: string; + + @Column({ type: 'character varying' }) + pathType!: PathType; + + @Column({ type: 'character varying' }) + oldPath!: string; + + @Column({ type: 'character varying' }) + newPath!: string; +} diff --git a/server/src/tables/natural-earth-countries.table.ts b/server/src/tables/natural-earth-countries.table.ts new file mode 100644 index 0000000000..5ac5384afc --- /dev/null +++ b/server/src/tables/natural-earth-countries.table.ts @@ -0,0 +1,37 @@ +import { Column, PrimaryColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; + +@Table({ name: 'naturalearth_countries', synchronize: false }) +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() + 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; +} diff --git a/server/src/tables/partner-audit.table.ts b/server/src/tables/partner-audit.table.ts new file mode 100644 index 0000000000..77d9f976b1 --- /dev/null +++ b/server/src/tables/partner-audit.table.ts @@ -0,0 +1,19 @@ +import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; + +@Table('partners_audit') +export class PartnerAuditTable { + @PrimaryGeneratedColumn({ type: 'v7' }) + id!: string; + + @ColumnIndex('IDX_partners_audit_shared_by_id') + @Column({ type: 'uuid' }) + sharedById!: string; + + @ColumnIndex('IDX_partners_audit_shared_with_id') + @Column({ type: 'uuid' }) + sharedWithId!: string; + + @ColumnIndex('IDX_partners_audit_deleted_at') + @CreateDateColumn({ default: () => 'clock_timestamp()' }) + deletedAt!: Date; +} diff --git a/server/src/tables/partner.table.ts b/server/src/tables/partner.table.ts new file mode 100644 index 0000000000..900f5fa834 --- /dev/null +++ b/server/src/tables/partner.table.ts @@ -0,0 +1,32 @@ +import { + Column, + ColumnIndex, + CreateDateColumn, + ForeignKeyColumn, + Table, + UpdateDateColumn, + UpdateIdColumn, +} from 'src/sql-tools'; +import { UserTable } from 'src/tables/user.table'; + +@Table('partners') +export class PartnerTable { + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', primary: true }) + sharedById!: string; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', primary: true }) + sharedWithId!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @ColumnIndex('IDX_partners_update_id') + @UpdateIdColumn() + updateId!: string; + + @Column({ type: 'boolean', default: false }) + inTimeline!: boolean; +} diff --git a/server/src/tables/person.table.ts b/server/src/tables/person.table.ts new file mode 100644 index 0000000000..206e91e68c --- /dev/null +++ b/server/src/tables/person.table.ts @@ -0,0 +1,54 @@ +import { + Check, + Column, + ColumnIndex, + CreateDateColumn, + ForeignKeyColumn, + PrimaryGeneratedColumn, + Table, + UpdateDateColumn, + UpdateIdColumn, +} from 'src/sql-tools'; +import { AssetFaceTable } from 'src/tables/asset-face.table'; +import { UserTable } from 'src/tables/user.table'; + +@Table('person') +@Check({ name: 'CHK_b0f82b0ed662bfc24fbb58bb45', expression: `"birthDate" <= CURRENT_DATE` }) +export class PersonTable { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @CreateDateColumn() + createdAt!: Date; + + @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: 'boolean', default: false }) + isFavorite!: boolean; + + @Column({ type: 'character varying', nullable: true, default: null }) + color?: string | null; +} diff --git a/server/src/tables/session.table.ts b/server/src/tables/session.table.ts new file mode 100644 index 0000000000..4b6afef099 --- /dev/null +++ b/server/src/tables/session.table.ts @@ -0,0 +1,40 @@ +import { + Column, + ColumnIndex, + CreateDateColumn, + ForeignKeyColumn, + PrimaryGeneratedColumn, + Table, + UpdateDateColumn, + UpdateIdColumn, +} from 'src/sql-tools'; +import { UserTable } from 'src/tables/user.table'; + +@Table({ name: 'sessions', primaryConstraintName: 'PK_48cb6b5c20faa63157b3c1baf7f' }) +export class SessionTable { + @PrimaryGeneratedColumn() + id!: string; + + // TODO convert to byte[] + @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; + + @Column({ default: '' }) + deviceType!: string; + + @Column({ default: '' }) + deviceOS!: string; +} diff --git a/server/src/tables/shared-link-asset.table.ts b/server/src/tables/shared-link-asset.table.ts new file mode 100644 index 0000000000..da6526dfc8 --- /dev/null +++ b/server/src/tables/shared-link-asset.table.ts @@ -0,0 +1,14 @@ +import { ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools'; +import { AssetTable } from 'src/tables/asset.table'; +import { SharedLinkTable } from 'src/tables/shared-link.table'; + +@Table('shared_link__asset') +export class SharedLinkAssetTable { + @ColumnIndex() + @ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) + assetsId!: string; + + @ColumnIndex() + @ForeignKeyColumn(() => SharedLinkTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) + sharedLinksId!: string; +} diff --git a/server/src/tables/shared-link.table.ts b/server/src/tables/shared-link.table.ts new file mode 100644 index 0000000000..3a41f5a8f5 --- /dev/null +++ b/server/src/tables/shared-link.table.ts @@ -0,0 +1,54 @@ +import { SharedLinkType } from 'src/enum'; +import { + Column, + ColumnIndex, + CreateDateColumn, + ForeignKeyColumn, + PrimaryGeneratedColumn, + Table, + Unique, +} from 'src/sql-tools'; +import { AlbumTable } from 'src/tables/album.table'; +import { UserTable } from 'src/tables/user.table'; + +@Table('shared_links') +@Unique({ name: 'UQ_sharedlink_key', columns: ['key'] }) +export class SharedLinkTable { + @PrimaryGeneratedColumn() + id!: string; + + @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 + + @Column() + type!: SharedLinkType; + + @CreateDateColumn() + createdAt!: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + expiresAt!: Date | null; + + @Column({ type: 'boolean', default: false }) + allowUpload!: boolean; + + @Column({ type: 'boolean', default: true }) + allowDownload!: boolean; + + @Column({ type: 'boolean', default: true }) + showExif!: boolean; +} diff --git a/server/src/tables/smart-search.table.ts b/server/src/tables/smart-search.table.ts new file mode 100644 index 0000000000..8647756550 --- /dev/null +++ b/server/src/tables/smart-search.table.ts @@ -0,0 +1,16 @@ +import { Column, ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools'; +import { AssetTable } from 'src/tables/asset.table'; + +@Table({ name: 'smart_search', primaryConstraintName: 'smart_search_pkey' }) +export class SmartSearchTable { + @ForeignKeyColumn(() => AssetTable, { + onDelete: 'CASCADE', + primary: true, + constraintName: 'smart_search_assetId_fkey', + }) + assetId!: string; + + @ColumnIndex({ name: 'clip_index', synchronize: false }) + @Column({ type: 'vector', array: true, length: 512, synchronize: false }) + embedding!: string; +} diff --git a/server/src/tables/stack.table.ts b/server/src/tables/stack.table.ts new file mode 100644 index 0000000000..fc711233a4 --- /dev/null +++ b/server/src/tables/stack.table.ts @@ -0,0 +1,16 @@ +import { ForeignKeyColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; +import { AssetTable } from 'src/tables/asset.table'; +import { UserTable } from 'src/tables/user.table'; + +@Table('asset_stack') +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; +} diff --git a/server/src/tables/sync-checkpoint.table.ts b/server/src/tables/sync-checkpoint.table.ts new file mode 100644 index 0000000000..3fbffccb6c --- /dev/null +++ b/server/src/tables/sync-checkpoint.table.ts @@ -0,0 +1,34 @@ +import { SyncEntityType } from 'src/enum'; +import { + Column, + ColumnIndex, + CreateDateColumn, + ForeignKeyColumn, + PrimaryColumn, + Table, + UpdateDateColumn, + UpdateIdColumn, +} from 'src/sql-tools'; +import { SessionTable } from 'src/tables/session.table'; + +@Table('session_sync_checkpoints') +export class SessionSyncCheckpointTable { + @ForeignKeyColumn(() => SessionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', primary: true }) + sessionId!: string; + + @PrimaryColumn({ type: 'character varying' }) + type!: SyncEntityType; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @ColumnIndex('IDX_session_sync_checkpoints_update_id') + @UpdateIdColumn() + updateId!: string; + + @Column() + ack!: string; +} diff --git a/server/src/tables/system-metadata.table.ts b/server/src/tables/system-metadata.table.ts new file mode 100644 index 0000000000..8657768db6 --- /dev/null +++ b/server/src/tables/system-metadata.table.ts @@ -0,0 +1,12 @@ +import { SystemMetadataKey } from 'src/enum'; +import { Column, PrimaryColumn, Table } from 'src/sql-tools'; +import { SystemMetadata } from 'src/types'; + +@Table('system_metadata') +export class SystemMetadataTable { + @PrimaryColumn({ type: 'character varying' }) + key!: T; + + @Column({ type: 'jsonb' }) + value!: SystemMetadata[T]; +} diff --git a/server/src/tables/tag-asset.table.ts b/server/src/tables/tag-asset.table.ts new file mode 100644 index 0000000000..6080c432b5 --- /dev/null +++ b/server/src/tables/tag-asset.table.ts @@ -0,0 +1,15 @@ +import { ColumnIndex, ForeignKeyColumn, Index, Table } from 'src/sql-tools'; +import { AssetTable } from 'src/tables/asset.table'; +import { TagTable } from 'src/tables/tag.table'; + +@Index({ name: 'IDX_tag_asset_assetsId_tagsId', columns: ['assetsId', 'tagsId'] }) +@Table('tag_asset') +export class TagAssetTable { + @ColumnIndex() + @ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) + assetsId!: string; + + @ColumnIndex() + @ForeignKeyColumn(() => TagTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) + tagsId!: string; +} diff --git a/server/src/tables/tag-closure.table.ts b/server/src/tables/tag-closure.table.ts new file mode 100644 index 0000000000..a661904741 --- /dev/null +++ b/server/src/tables/tag-closure.table.ts @@ -0,0 +1,15 @@ +import { ColumnIndex, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools'; +import { TagTable } from 'src/tables/tag.table'; + +@Table('tags_closure') +export class TagClosureTable { + @PrimaryColumn() + @ColumnIndex() + @ForeignKeyColumn(() => TagTable, { onDelete: 'CASCADE', onUpdate: 'NO ACTION' }) + id_ancestor!: string; + + @PrimaryColumn() + @ColumnIndex() + @ForeignKeyColumn(() => TagTable, { onDelete: 'CASCADE', onUpdate: 'NO ACTION' }) + id_descendant!: string; +} diff --git a/server/src/tables/tag.table.ts b/server/src/tables/tag.table.ts new file mode 100644 index 0000000000..5b74075647 --- /dev/null +++ b/server/src/tables/tag.table.ts @@ -0,0 +1,41 @@ +import { + Column, + ColumnIndex, + CreateDateColumn, + ForeignKeyColumn, + PrimaryGeneratedColumn, + Table, + Unique, + UpdateDateColumn, + UpdateIdColumn, +} from 'src/sql-tools'; +import { UserTable } from 'src/tables/user.table'; + +@Table('tags') +@Unique({ columns: ['userId', 'value'] }) +export class TagTable { + @PrimaryGeneratedColumn() + id!: string; + + @Column() + value!: string; + + @CreateDateColumn() + createdAt!: Date; + + @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; +} diff --git a/server/src/tables/user-audit.table.ts b/server/src/tables/user-audit.table.ts new file mode 100644 index 0000000000..e3f117381c --- /dev/null +++ b/server/src/tables/user-audit.table.ts @@ -0,0 +1,14 @@ +import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, 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; +} diff --git a/server/src/tables/user-metadata.table.ts b/server/src/tables/user-metadata.table.ts new file mode 100644 index 0000000000..2f83287b6c --- /dev/null +++ b/server/src/tables/user-metadata.table.ts @@ -0,0 +1,16 @@ +import { UserMetadata, UserMetadataItem } from 'src/entities/user-metadata.entity'; +import { UserMetadataKey } from 'src/enum'; +import { Column, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools'; +import { UserTable } from 'src/tables/user.table'; + +@Table('user_metadata') +export class UserMetadataTable implements UserMetadataItem { + @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) + userId!: string; + + @PrimaryColumn({ type: 'character varying' }) + key!: T; + + @Column({ type: 'jsonb' }) + value!: UserMetadata[T]; +} diff --git a/server/src/tables/user.table.ts b/server/src/tables/user.table.ts new file mode 100644 index 0000000000..5bd9cd94c6 --- /dev/null +++ b/server/src/tables/user.table.ts @@ -0,0 +1,73 @@ +import { ColumnType } from 'kysely'; +import { UserStatus } from 'src/enum'; +import { + Column, + ColumnIndex, + CreateDateColumn, + DeleteDateColumn, + Index, + PrimaryGeneratedColumn, + Table, + UpdateDateColumn, + UpdateIdColumn, +} from 'src/sql-tools'; + +type Timestamp = ColumnType; +type Generated = + T extends ColumnType ? ColumnType : ColumnType; + +@Table('users') +@Index({ name: 'IDX_users_updated_at_asc_id_asc', columns: ['updatedAt', 'id'] }) +export class UserTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @Column({ default: '' }) + name!: Generated; + + @Column({ type: 'boolean', default: false }) + isAdmin!: Generated; + + @Column({ unique: true }) + email!: string; + + @Column({ unique: true, nullable: true, default: null }) + storageLabel!: string | null; + + @Column({ default: '' }) + password!: Generated; + + @Column({ default: '' }) + oauthId!: Generated; + + @Column({ default: '' }) + profileImagePath!: Generated; + + @Column({ type: 'boolean', default: true }) + shouldChangePassword!: Generated; + + @CreateDateColumn() + createdAt!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; + + @DeleteDateColumn() + deletedAt!: Timestamp | null; + + @Column({ type: 'character varying', default: UserStatus.ACTIVE }) + status!: Generated; + + @ColumnIndex({ name: 'IDX_users_update_id' }) + @UpdateIdColumn() + updateId!: Generated; + + @Column({ type: 'bigint', nullable: true }) + quotaSizeInBytes!: ColumnType | null; + + @Column({ type: 'bigint', default: 0 }) + quotaUsageInBytes!: Generated>; + + @Column({ type: 'timestamp with time zone', default: () => 'now()' }) + profileChangedAt!: Generated; +} diff --git a/server/src/tables/version-history.table.ts b/server/src/tables/version-history.table.ts new file mode 100644 index 0000000000..18805a2de3 --- /dev/null +++ b/server/src/tables/version-history.table.ts @@ -0,0 +1,13 @@ +import { Column, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; + +@Table('version_history') +export class VersionHistoryTable { + @PrimaryGeneratedColumn() + id!: string; + + @CreateDateColumn() + createdAt!: Date; + + @Column() + version!: string; +} diff --git a/server/src/types.ts b/server/src/types.ts index 1c0a61b259..6a3860830c 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,11 +1,15 @@ +import { SystemConfig } from 'src/config'; import { AssetType, DatabaseExtension, ExifOrientation, ImageFormat, JobName, + MemoryType, QueueName, + StorageFolder, SyncEntityType, + SystemMetadataKey, TranscodeTarget, VideoCodec, } from 'src/enum'; @@ -454,3 +458,27 @@ export type StorageAsset = { sidecarPath: string | null; fileSizeInByte: number | null; }; + +export type OnThisDayData = { year: number }; + +export interface MemoryData { + [MemoryType.ON_THIS_DAY]: OnThisDayData; +} + +export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string }; +export type SystemFlags = { mountChecks: Record }; +export type MemoriesState = { + /** memories have already been created through this date */ + lastOnThisDayDate: string; +}; + +export interface SystemMetadata extends Record> { + [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; + [SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string }; + [SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date }; + [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; + [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial; + [SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial; + [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; + [SystemMetadataKey.MEMORIES_STATE]: MemoriesState; +} diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 456165063c..8e07f388a0 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -1,19 +1,4 @@ import { Expression, sql } from 'kysely'; -import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; - -/** - * Allows optional values unlike the regular Between and uses MoreThanOrEqual - * or LessThanOrEqual when only one parameter is specified. - */ -export function OptionalBetween(from?: T, to?: T) { - if (from && to) { - return Between(from, to); - } else if (from) { - return MoreThanOrEqual(from); - } else if (to) { - return LessThanOrEqual(to); - } -} export const asUuid = (id: string | Expression) => sql`${id}::uuid`; @@ -32,16 +17,3 @@ export const removeUndefinedKeys = (update: T, template: unkno return update; }; - -/** - * Mainly for type debugging to make VS Code display a more useful tooltip. - * Source: https://stackoverflow.com/a/69288824 - */ -export type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; - -/** Recursive version of {@link Expand} from the same source. */ -export type ExpandRecursively = T extends object - ? T extends infer O - ? { [K in keyof O]: ExpandRecursively } - : never - : T; diff --git a/server/src/utils/logger.ts b/server/src/utils/logger.ts index f2f47e0471..ecc8847043 100644 --- a/server/src/utils/logger.ts +++ b/server/src/utils/logger.ts @@ -1,6 +1,5 @@ import { HttpException } from '@nestjs/common'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { TypeORMError } from 'typeorm'; export const logGlobalError = (logger: LoggingRepository, error: Error) => { if (error instanceof HttpException) { @@ -10,11 +9,6 @@ export const logGlobalError = (logger: LoggingRepository, error: Error) => { return; } - if (error instanceof TypeORMError) { - logger.error(`Database error: ${error}`); - return; - } - if (error instanceof Error) { logger.error(`Unknown error: ${error}`, error?.stack); return; diff --git a/server/test/factory.ts b/server/test/factory.ts index 69160aa8a4..0becc705bc 100644 --- a/server/test/factory.ts +++ b/server/test/factory.ts @@ -1,7 +1,7 @@ import { Insertable, Kysely } from 'kysely'; import { randomBytes } from 'node:crypto'; import { Writable } from 'node:stream'; -import { Assets, DB, Partners, Sessions, Users } from 'src/db'; +import { Assets, DB, Partners, Sessions } from 'src/db'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; @@ -35,6 +35,7 @@ import { TrashRepository } from 'src/repositories/trash.repository'; import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; +import { UserTable } from 'src/tables/user.table'; import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; import { newUuid } from 'test/small.factory'; import { automock } from 'test/utils'; @@ -57,7 +58,7 @@ class CustomWritable extends Writable { } type Asset = Partial>; -type User = Partial>; +type User = Partial>; type Session = Omit, 'token'> & { token?: string }; type Partner = Insertable; @@ -103,7 +104,7 @@ export class TestFactory { static user(user: User = {}) { const userId = user.id || newUuid(); - const defaults: Insertable = { + const defaults: Insertable = { email: `${userId}@immich.cloud`, name: `User ${userId}`, deletedAt: null, diff --git a/server/test/fixtures/audit.stub.ts b/server/test/fixtures/audit.stub.ts deleted file mode 100644 index 24f78a17ce..0000000000 --- a/server/test/fixtures/audit.stub.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { AuditEntity } from 'src/entities/audit.entity'; -import { DatabaseAction, EntityType } from 'src/enum'; -import { authStub } from 'test/fixtures/auth.stub'; - -export const auditStub = { - delete: Object.freeze({ - id: 3, - entityId: 'asset-deleted', - action: DatabaseAction.DELETE, - entityType: EntityType.ASSET, - ownerId: authStub.admin.user.id, - createdAt: new Date(), - }), -}; diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 9153cfa8f2..0ed1502fb9 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -17,7 +17,6 @@ export const userStub = { createdAt: new Date('2021-01-01'), deletedAt: null, updatedAt: new Date('2021-01-01'), - tags: [], assets: [], metadata: [], quotaSizeInBytes: null, @@ -36,7 +35,6 @@ export const userStub = { createdAt: new Date('2021-01-01'), deletedAt: null, updatedAt: new Date('2021-01-01'), - tags: [], assets: [], metadata: [ { @@ -62,7 +60,6 @@ export const userStub = { createdAt: new Date('2021-01-01'), deletedAt: null, updatedAt: new Date('2021-01-01'), - tags: [], assets: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, @@ -81,7 +78,6 @@ export const userStub = { createdAt: new Date('2021-01-01'), deletedAt: null, updatedAt: new Date('2021-01-01'), - tags: [], assets: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, @@ -100,7 +96,6 @@ export const userStub = { createdAt: new Date('2021-01-01'), deletedAt: null, updatedAt: new Date('2021-01-01'), - tags: [], assets: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 5b228d3afb..0f6d059b6a 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -1,9 +1,8 @@ import { randomUUID } from 'node:crypto'; import { ApiKey, Asset, AuthApiKey, AuthUser, Library, Partner, User, UserAdmin } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; -import { OnThisDayData } from 'src/entities/memory.entity'; import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum'; -import { ActivityItem, MemoryItem } from 'src/types'; +import { ActivityItem, MemoryItem, OnThisDayData } from 'src/types'; export const newUuid = () => randomUUID() as string; export const newUuids = () => diff --git a/server/test/sql-tools/check-constraint-default-name.stub.ts b/server/test/sql-tools/check-constraint-default-name.stub.ts new file mode 100644 index 0000000000..42ee336b94 --- /dev/null +++ b/server/test/sql-tools/check-constraint-default-name.stub.ts @@ -0,0 +1,41 @@ +import { Check, Column, DatabaseConstraintType, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +@Check({ expression: '1=1' }) +export class Table1 { + @Column({ type: 'uuid' }) + id!: string; +} + +export const description = 'should create a check constraint with a default name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [ + { + type: DatabaseConstraintType.CHECK, + name: 'CHK_8d2ecfd49b984941f6b2589799', + tableName: 'table1', + expression: '1=1', + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/check-constraint-override-name.stub.ts b/server/test/sql-tools/check-constraint-override-name.stub.ts new file mode 100644 index 0000000000..89db6044a2 --- /dev/null +++ b/server/test/sql-tools/check-constraint-override-name.stub.ts @@ -0,0 +1,41 @@ +import { Check, Column, DatabaseConstraintType, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +@Check({ name: 'CHK_test', expression: '1=1' }) +export class Table1 { + @Column({ type: 'uuid' }) + id!: string; +} + +export const description = 'should create a check constraint with a specific name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [ + { + type: DatabaseConstraintType.CHECK, + name: 'CHK_test', + tableName: 'table1', + expression: '1=1', + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-default-boolean.stub.ts b/server/test/sql-tools/column-default-boolean.stub.ts new file mode 100644 index 0000000000..464a34b26e --- /dev/null +++ b/server/test/sql-tools/column-default-boolean.stub.ts @@ -0,0 +1,33 @@ +import { Column, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @Column({ type: 'boolean', default: true }) + column1!: boolean; +} + +export const description = 'should register a table with a column with a default value (boolean)'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'boolean', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + default: 'true', + }, + ], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-default-date.stub.ts b/server/test/sql-tools/column-default-date.stub.ts new file mode 100644 index 0000000000..72c06b3bd9 --- /dev/null +++ b/server/test/sql-tools/column-default-date.stub.ts @@ -0,0 +1,35 @@ +import { Column, DatabaseSchema, Table } from 'src/sql-tools'; + +const date = new Date(2023, 0, 1); + +@Table() +export class Table1 { + @Column({ type: 'character varying', default: date }) + column1!: string; +} + +export const description = 'should register a table with a column with a default value (date)'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + default: "'2023-01-01T00:00:00.000Z'", + }, + ], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-default-function.stub.ts b/server/test/sql-tools/column-default-function.stub.ts new file mode 100644 index 0000000000..ceb03b50f0 --- /dev/null +++ b/server/test/sql-tools/column-default-function.stub.ts @@ -0,0 +1,33 @@ +import { Column, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @Column({ type: 'character varying', default: () => 'now()' }) + column1!: string; +} + +export const description = 'should register a table with a column with a default function'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + default: 'now()', + }, + ], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-default-null.stub.ts b/server/test/sql-tools/column-default-null.stub.ts new file mode 100644 index 0000000000..b4aa83788b --- /dev/null +++ b/server/test/sql-tools/column-default-null.stub.ts @@ -0,0 +1,32 @@ +import { Column, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @Column({ type: 'character varying', default: null }) + column1!: string; +} + +export const description = 'should register a nullable column from a default of null'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: true, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-default-number.stub.ts b/server/test/sql-tools/column-default-number.stub.ts new file mode 100644 index 0000000000..f3fac229c7 --- /dev/null +++ b/server/test/sql-tools/column-default-number.stub.ts @@ -0,0 +1,33 @@ +import { Column, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @Column({ type: 'integer', default: 0 }) + column1!: string; +} + +export const description = 'should register a table with a column with a default value (number)'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'integer', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + default: '0', + }, + ], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-default-string.stub.ts b/server/test/sql-tools/column-default-string.stub.ts new file mode 100644 index 0000000000..36aa584eeb --- /dev/null +++ b/server/test/sql-tools/column-default-string.stub.ts @@ -0,0 +1,33 @@ +import { Column, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @Column({ type: 'character varying', default: 'foo' }) + column1!: string; +} + +export const description = 'should register a table with a column with a default value (string)'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + default: "'foo'", + }, + ], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-enum-name.stub.ts b/server/test/sql-tools/column-enum-name.stub.ts new file mode 100644 index 0000000000..9ae1b4310d --- /dev/null +++ b/server/test/sql-tools/column-enum-name.stub.ts @@ -0,0 +1,39 @@ +import { Column, DatabaseSchema, Table } from 'src/sql-tools'; + +enum Test { + Foo = 'foo', + Bar = 'bar', +} + +@Table() +export class Table1 { + @Column({ enum: Test }) + column1!: string; +} + +export const description = 'should use a default enum naming convention'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'enum', + enumName: 'table1_column1_enum', + enumValues: ['foo', 'bar'], + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-index-name-default.ts b/server/test/sql-tools/column-index-name-default.ts new file mode 100644 index 0000000000..d3b5aba112 --- /dev/null +++ b/server/test/sql-tools/column-index-name-default.ts @@ -0,0 +1,41 @@ +import { Column, ColumnIndex, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @ColumnIndex() + @Column() + column1!: string; +} + +export const description = 'should create a column with an index'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [ + { + name: 'IDX_50c4f9905061b1e506d38a2a38', + columnNames: ['column1'], + tableName: 'table1', + unique: false, + synchronize: true, + }, + ], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-inferred-nullable.stub.ts b/server/test/sql-tools/column-inferred-nullable.stub.ts new file mode 100644 index 0000000000..d866b59093 --- /dev/null +++ b/server/test/sql-tools/column-inferred-nullable.stub.ts @@ -0,0 +1,32 @@ +import { Column, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @Column({ default: null }) + column1!: string; +} + +export const description = 'should infer nullable from the default value'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: true, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-name-default.stub.ts b/server/test/sql-tools/column-name-default.stub.ts new file mode 100644 index 0000000000..3c6df97fe4 --- /dev/null +++ b/server/test/sql-tools/column-name-default.stub.ts @@ -0,0 +1,32 @@ +import { Column, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @Column() + column1!: string; +} + +export const description = 'should register a table with a column with a default name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-name-override.stub.ts b/server/test/sql-tools/column-name-override.stub.ts new file mode 100644 index 0000000000..b5e86e47d0 --- /dev/null +++ b/server/test/sql-tools/column-name-override.stub.ts @@ -0,0 +1,32 @@ +import { Column, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @Column({ name: 'column-1' }) + column1!: string; +} + +export const description = 'should register a table with a column with a specific name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column-1', + tableName: 'table1', + type: 'character varying', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-name-string.stub.ts b/server/test/sql-tools/column-name-string.stub.ts new file mode 100644 index 0000000000..013e74e7da --- /dev/null +++ b/server/test/sql-tools/column-name-string.stub.ts @@ -0,0 +1,32 @@ +import { Column, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @Column('column-1') + column1!: string; +} + +export const description = 'should register a table with a column with a specific name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column-1', + tableName: 'table1', + type: 'character varying', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-nullable.stub.ts b/server/test/sql-tools/column-nullable.stub.ts new file mode 100644 index 0000000000..2704fb7cf6 --- /dev/null +++ b/server/test/sql-tools/column-nullable.stub.ts @@ -0,0 +1,32 @@ +import { Column, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @Column({ nullable: true }) + column1!: string; +} + +export const description = 'should set nullable correctly'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: true, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-unique-constraint-name-default.stub.ts b/server/test/sql-tools/column-unique-constraint-name-default.stub.ts new file mode 100644 index 0000000000..6446a2069d --- /dev/null +++ b/server/test/sql-tools/column-unique-constraint-name-default.stub.ts @@ -0,0 +1,40 @@ +import { Column, DatabaseConstraintType, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @Column({ type: 'uuid', unique: true }) + id!: string; +} + +export const description = 'should create a unique key constraint with a default name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [ + { + type: DatabaseConstraintType.UNIQUE, + name: 'UQ_b249cc64cf63b8a22557cdc8537', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-unique-constraint-name-override.stub.ts b/server/test/sql-tools/column-unique-constraint-name-override.stub.ts new file mode 100644 index 0000000000..fb96ff06b2 --- /dev/null +++ b/server/test/sql-tools/column-unique-constraint-name-override.stub.ts @@ -0,0 +1,40 @@ +import { Column, DatabaseConstraintType, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @Column({ type: 'uuid', unique: true, uniqueConstraintName: 'UQ_test' }) + id!: string; +} + +export const description = 'should create a unique key constraint with a specific name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [ + { + type: DatabaseConstraintType.UNIQUE, + name: 'UQ_test', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/foreign-key-inferred-type.stub.ts b/server/test/sql-tools/foreign-key-inferred-type.stub.ts new file mode 100644 index 0000000000..b88d834a76 --- /dev/null +++ b/server/test/sql-tools/foreign-key-inferred-type.stub.ts @@ -0,0 +1,73 @@ +import { DatabaseConstraintType, DatabaseSchema, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @PrimaryColumn({ type: 'uuid' }) + id!: string; +} + +@Table() +export class Table2 { + @ForeignKeyColumn(() => Table1, {}) + parentId!: string; +} + +export const description = 'should infer the column type from the reference column'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + ], + indexes: [], + constraints: [ + { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_b249cc64cf63b8a22557cdc8537', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + ], + synchronize: true, + }, + { + name: 'table2', + columns: [ + { + name: 'parentId', + tableName: 'table2', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [ + { + type: DatabaseConstraintType.FOREIGN_KEY, + name: 'FK_3fcca5cc563abf256fc346e3ff4', + tableName: 'table2', + columnNames: ['parentId'], + referenceColumnNames: ['id'], + referenceTableName: 'table1', + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts b/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts new file mode 100644 index 0000000000..8bf2328fc3 --- /dev/null +++ b/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts @@ -0,0 +1,80 @@ +import { DatabaseConstraintType, DatabaseSchema, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @PrimaryColumn({ type: 'uuid' }) + id!: string; +} + +@Table() +export class Table2 { + @ForeignKeyColumn(() => Table1, { unique: true }) + parentId!: string; +} + +export const description = 'should create a foreign key constraint with a unique constraint'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + ], + indexes: [], + constraints: [ + { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_b249cc64cf63b8a22557cdc8537', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + ], + synchronize: true, + }, + { + name: 'table2', + columns: [ + { + name: 'parentId', + tableName: 'table2', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [ + { + type: DatabaseConstraintType.FOREIGN_KEY, + name: 'FK_3fcca5cc563abf256fc346e3ff4', + tableName: 'table2', + columnNames: ['parentId'], + referenceColumnNames: ['id'], + referenceTableName: 'table1', + synchronize: true, + }, + { + type: DatabaseConstraintType.UNIQUE, + name: 'REL_3fcca5cc563abf256fc346e3ff', + tableName: 'table2', + columnNames: ['parentId'], + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/index-name-default.stub.ts b/server/test/sql-tools/index-name-default.stub.ts new file mode 100644 index 0000000000..ffadfb0b32 --- /dev/null +++ b/server/test/sql-tools/index-name-default.stub.ts @@ -0,0 +1,41 @@ +import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools'; + +@Table() +@Index({ columns: ['id'] }) +export class Table1 { + @Column({ type: 'uuid' }) + id!: string; +} + +export const description = 'should create an index with a default name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [ + { + name: 'IDX_b249cc64cf63b8a22557cdc853', + tableName: 'table1', + unique: false, + columnNames: ['id'], + synchronize: true, + }, + ], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/index-name-override.stub.ts b/server/test/sql-tools/index-name-override.stub.ts new file mode 100644 index 0000000000..f72a0cbeb1 --- /dev/null +++ b/server/test/sql-tools/index-name-override.stub.ts @@ -0,0 +1,41 @@ +import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools'; + +@Table() +@Index({ name: 'IDX_test', columns: ['id'] }) +export class Table1 { + @Column({ type: 'uuid' }) + id!: string; +} + +export const description = 'should create an index with a specific name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [ + { + name: 'IDX_test', + tableName: 'table1', + unique: false, + columnNames: ['id'], + synchronize: true, + }, + ], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/index-with-where.stub copy.ts b/server/test/sql-tools/index-with-where.stub copy.ts new file mode 100644 index 0000000000..0d22f4e115 --- /dev/null +++ b/server/test/sql-tools/index-with-where.stub copy.ts @@ -0,0 +1,41 @@ +import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools'; + +@Table() +@Index({ expression: '"id" IS NOT NULL' }) +export class Table1 { + @Column({ nullable: true }) + column1!: string; +} + +export const description = 'should create an index based off of an expression'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: true, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [ + { + name: 'IDX_376788d186160c4faa5aaaef63', + tableName: 'table1', + unique: false, + expression: '"id" IS NOT NULL', + synchronize: true, + }, + ], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/index-with-where.stub.ts b/server/test/sql-tools/index-with-where.stub.ts new file mode 100644 index 0000000000..e59d2ec36b --- /dev/null +++ b/server/test/sql-tools/index-with-where.stub.ts @@ -0,0 +1,42 @@ +import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools'; + +@Table() +@Index({ columns: ['id'], where: '"id" IS NOT NULL' }) +export class Table1 { + @Column({ nullable: true }) + column1!: string; +} + +export const description = 'should create an index with a where clause'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: true, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [ + { + name: 'IDX_9f4e073964c0395f51f9b39900', + tableName: 'table1', + unique: false, + columnNames: ['id'], + where: '"id" IS NOT NULL', + synchronize: true, + }, + ], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/primary-key-constraint-name-default.stub.ts b/server/test/sql-tools/primary-key-constraint-name-default.stub.ts new file mode 100644 index 0000000000..d4b426b9f1 --- /dev/null +++ b/server/test/sql-tools/primary-key-constraint-name-default.stub.ts @@ -0,0 +1,40 @@ +import { DatabaseConstraintType, DatabaseSchema, PrimaryColumn, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @PrimaryColumn({ type: 'uuid' }) + id!: string; +} + +export const description = 'should add a primary key constraint to the table with a default name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + ], + indexes: [], + constraints: [ + { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_b249cc64cf63b8a22557cdc8537', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/primary-key-constraint-name-override.stub.ts b/server/test/sql-tools/primary-key-constraint-name-override.stub.ts new file mode 100644 index 0000000000..717d9165b3 --- /dev/null +++ b/server/test/sql-tools/primary-key-constraint-name-override.stub.ts @@ -0,0 +1,40 @@ +import { DatabaseConstraintType, DatabaseSchema, PrimaryColumn, Table } from 'src/sql-tools'; + +@Table({ primaryConstraintName: 'PK_test' }) +export class Table1 { + @PrimaryColumn({ type: 'uuid' }) + id!: string; +} + +export const description = 'should add a primary key constraint to the table with a specific name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + ], + indexes: [], + constraints: [ + { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_test', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/table-name-default.stub.ts b/server/test/sql-tools/table-name-default.stub.ts new file mode 100644 index 0000000000..a76a5b6dbb --- /dev/null +++ b/server/test/sql-tools/table-name-default.stub.ts @@ -0,0 +1,19 @@ +import { DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 {} + +export const description = 'should register a table with a default name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/table-name-override.stub.ts b/server/test/sql-tools/table-name-override.stub.ts new file mode 100644 index 0000000000..3290fab6a4 --- /dev/null +++ b/server/test/sql-tools/table-name-override.stub.ts @@ -0,0 +1,19 @@ +import { DatabaseSchema, Table } from 'src/sql-tools'; + +@Table({ name: 'table-1' }) +export class Table1 {} + +export const description = 'should register a table with a specific name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table-1', + columns: [], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/table-name-string-option.stub.ts b/server/test/sql-tools/table-name-string-option.stub.ts new file mode 100644 index 0000000000..0c9a045d5b --- /dev/null +++ b/server/test/sql-tools/table-name-string-option.stub.ts @@ -0,0 +1,19 @@ +import { DatabaseSchema, Table } from 'src/sql-tools'; + +@Table('table-1') +export class Table1 {} + +export const description = 'should register a table with a specific name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table-1', + columns: [], + indexes: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/unique-constraint-name-default.stub.ts b/server/test/sql-tools/unique-constraint-name-default.stub.ts new file mode 100644 index 0000000000..42fc63bc46 --- /dev/null +++ b/server/test/sql-tools/unique-constraint-name-default.stub.ts @@ -0,0 +1,41 @@ +import { Column, DatabaseConstraintType, DatabaseSchema, Table, Unique } from 'src/sql-tools'; + +@Table() +@Unique({ columns: ['id'] }) +export class Table1 { + @Column({ type: 'uuid' }) + id!: string; +} + +export const description = 'should add a unique constraint to the table with a default name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [ + { + type: DatabaseConstraintType.UNIQUE, + name: 'UQ_b249cc64cf63b8a22557cdc8537', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/unique-constraint-name-override.stub.ts b/server/test/sql-tools/unique-constraint-name-override.stub.ts new file mode 100644 index 0000000000..e7f6fcf83c --- /dev/null +++ b/server/test/sql-tools/unique-constraint-name-override.stub.ts @@ -0,0 +1,41 @@ +import { Column, DatabaseConstraintType, DatabaseSchema, Table, Unique } from 'src/sql-tools'; + +@Table() +@Unique({ name: 'UQ_test', columns: ['id'] }) +export class Table1 { + @Column({ type: 'uuid' }) + id!: string; +} + +export const description = 'should add a unique constraint to the table with a specific name'; +export const schema: DatabaseSchema = { + name: 'public', + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [ + { + type: DatabaseConstraintType.UNIQUE, + name: 'UQ_test', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/vitest.config.mjs b/server/test/vitest.config.mjs index 071e4886f2..d3d1c98f5d 100644 --- a/server/test/vitest.config.mjs +++ b/server/test/vitest.config.mjs @@ -12,12 +12,13 @@ export default defineConfig({ include: ['src/**/*.spec.ts'], coverage: { provider: 'v8', - include: ['src/cores/**', 'src/interfaces/**', 'src/services/**', 'src/utils/**'], + include: ['src/cores/**', 'src/interfaces/**', 'src/services/**', 'src/utils/**', 'src/sql-tools/**'], exclude: [ 'src/services/*.spec.ts', 'src/services/api.service.ts', 'src/services/microservices.service.ts', 'src/services/index.ts', + 'src/sql-tools/schema-from-database.ts', ], thresholds: { lines: 85,