mirror of
https://github.com/immich-app/immich.git
synced 2025-04-08 03:01:32 -05:00
feat: schema diff sql tools (#17116)
This commit is contained in:
parent
3fde5a8328
commit
4b4bcd23f4
132 changed files with 5837 additions and 1246 deletions
.github/workflows
server
eslint.config.mjspackage.json
src
bin
db.d.tsentities
activity.entity.tsalbum-user.entity.tsalbum.entity.tsapi-key.entity.tsasset-audit.entity.tsasset-face.entity.tsasset-files.entity.tsasset-job-status.entity.tsasset.entity.tsaudit.entity.tsexif.entity.tsface-search.entity.tsgeodata-places.entity.tslibrary.entity.tsmemory.entity.tsmove.entity.tsnatural-earth-countries.entity.tspartner-audit.entity.tspartner.entity.tsperson.entity.tssession.entity.tsshared-link.entity.tssmart-search.entity.tsstack.entity.tssync-checkpoint.entity.tssystem-metadata.entity.tstag.entity.tsuser-audit.entity.tsuser-metadata.entity.tsuser.entity.tsversion-history.entity.ts
repositories
services
sql-tools
decorators.tsindex.tspublic_api.tsschema-diff-to-sql.spec.tsschema-diff-to-sql.tsschema-diff.spec.tsschema-diff.tsschema-from-database.tsschema-from-decorators.spec.tsschema-from-decorators.tstypes.ts
subscribers
tables
activity.table.tsalbum-asset.table.tsalbum-user.table.tsalbum.table.tsapi-key.table.tsasset-audit.table.tsasset-face.table.tsasset-files.table.tsasset-job-status.table.tsasset.table.tsaudit.table.tsexif.table.tsface-search.table.tsgeodata-places.table.tsindex.tslibrary.table.tsmemory.table.tsmemory_asset.table.tsmove.table.tsnatural-earth-countries.table.tspartner-audit.table.tspartner.table.tsperson.table.tssession.table.tsshared-link-asset.table.tsshared-link.table.tssmart-search.table.tsstack.table.tssync-checkpoint.table.tssystem-metadata.table.tstag-asset.table.tstag-closure.table.tstag.table.tsuser-audit.table.tsuser-metadata.table.tsuser.table.tsversion-history.table.ts
types.tsutils
test
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
|
@ -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
|
||||
|
|
|
@ -77,6 +77,14 @@ export default [
|
|||
],
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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;'",
|
||||
|
|
112
server/src/bin/migrations.ts
Normal file
112
server/src/bin/migrations.ts
Normal file
|
@ -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 <name>
|
||||
node dist/bin/migrations.js generate <name>
|
||||
`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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<void> {
|
||||
${upSql}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
${downSql}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
console.log('Something went wrong');
|
||||
process.exit(1);
|
||||
});
|
25
server/src/db.d.ts
vendored
25
server/src/db.d.ts
vendored
|
@ -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<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
|
||||
|
||||
|
@ -410,26 +411,6 @@ export interface UserMetadata {
|
|||
value: Json;
|
||||
}
|
||||
|
||||
export interface Users {
|
||||
createdAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
email: string;
|
||||
id: Generated<string>;
|
||||
isAdmin: Generated<boolean>;
|
||||
name: Generated<string>;
|
||||
oauthId: Generated<string>;
|
||||
password: Generated<string>;
|
||||
profileChangedAt: Generated<Timestamp>;
|
||||
profileImagePath: Generated<string>;
|
||||
quotaSizeInBytes: Int8 | null;
|
||||
quotaUsageInBytes: Generated<Int8>;
|
||||
shouldChangePassword: Generated<boolean>;
|
||||
status: Generated<string>;
|
||||
storageLabel: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
updateId: Generated<string>;
|
||||
}
|
||||
|
||||
export interface UsersAudit {
|
||||
id: Generated<string>;
|
||||
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;
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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<T extends MemoryType = MemoryType> {
|
||||
@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[];
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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<T extends keyof SystemMetadata = SystemMetadataKey> {
|
||||
@PrimaryColumn({ type: 'varchar' })
|
||||
key!: T;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
value!: SystemMetadata[T];
|
||||
}
|
||||
|
||||
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
|
||||
export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> };
|
||||
export type MemoriesState = {
|
||||
/** memories have already been created through this date */
|
||||
lastOnThisDayDate: string;
|
||||
};
|
||||
|
||||
export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> {
|
||||
[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<SystemConfig>;
|
||||
[SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial<SystemFlags>;
|
||||
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
|
||||
[SystemMetadataKey.MEMORIES_STATE]: MemoriesState;
|
||||
}
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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<T extends keyof UserMetadata = UserMetadataKey> = {
|
||||
key: T;
|
||||
value: UserMetadata[T];
|
||||
};
|
||||
|
||||
@Entity('user_metadata')
|
||||
export class UserMetadataEntity<T extends keyof UserMetadata = UserMetadataKey> implements UserMetadataItem<T> {
|
||||
@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];
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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<AssetFaceEntity>,
|
||||
relations?: { faceSearch?: boolean },
|
||||
select?: SelectFaceOptions,
|
||||
): Promise<AssetFaceEntity | undefined> {
|
||||
return this.db
|
||||
|
|
|
@ -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<DbSystemMetadata>;
|
||||
|
||||
|
|
|
@ -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<DbUserMetadata>;
|
||||
|
@ -128,7 +129,7 @@ export class UserRepository {
|
|||
.execute() as Promise<UserAdmin[]>;
|
||||
}
|
||||
|
||||
async create(dto: Insertable<Users>): Promise<UserEntity> {
|
||||
async create(dto: Insertable<UserTable>): Promise<UserEntity> {
|
||||
return this.db
|
||||
.insertInto('users')
|
||||
.values(dto)
|
||||
|
@ -136,7 +137,7 @@ export class UserRepository {
|
|||
.executeTakeFirst() as unknown as Promise<UserEntity>;
|
||||
}
|
||||
|
||||
update(id: string, dto: Updateable<Users>): Promise<UserEntity> {
|
||||
update(id: string, dto: Updateable<UserTable>): Promise<UserEntity> {
|
||||
return this.db
|
||||
.updateTable('users')
|
||||
.set(dto)
|
||||
|
|
|
@ -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<Users> & { email: string }): Promise<UserEntity> {
|
||||
async createUser(dto: Insertable<UserTable> & { email: string }): Promise<UserEntity> {
|
||||
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<Users> = { ...dto };
|
||||
const payload: Insertable<UserTable> = { ...dto };
|
||||
if (payload.password) {
|
||||
payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.`;
|
||||
|
|
|
@ -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 {
|
||||
|
|
107
server/src/sql-tools/decorators.ts
Normal file
107
server/src/sql-tools/decorators.ts
Normal file
|
@ -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<GenerateColumnOptions, 'primary'> = {}) =>
|
||||
GeneratedColumn({ type: 'v4', ...options, primary: true });
|
||||
|
||||
export const PrimaryColumn = (options: Omit<ColumnOptions, 'primary'> = {}) => Column({ ...options, primary: true });
|
||||
|
||||
export const GeneratedColumn = ({ type = 'v4', ...options }: GenerateColumnOptions): PropertyDecorator => {
|
||||
const columnType = type === 'v4' || type === 'v7' ? 'uuid' : type;
|
||||
|
||||
let columnDefault: ColumnDefaultValue | undefined;
|
||||
switch (type) {
|
||||
case 'v4': {
|
||||
columnDefault = () => 'uuid_generate_v4()';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'v7': {
|
||||
columnDefault = () => 'immich_uuid_v7()';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Column({
|
||||
type: columnType,
|
||||
default: columnDefault,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const UpdateIdColumn = () => GeneratedColumn({ type: 'v7', nullable: false });
|
||||
|
||||
const asOptions = <T extends { name?: string }>(options: string | T): T => {
|
||||
if (typeof options === 'string') {
|
||||
return { name: options } as T;
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
1
server/src/sql-tools/index.ts
Normal file
1
server/src/sql-tools/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from 'src/sql-tools/public_api';
|
6
server/src/sql-tools/public_api.ts
Normal file
6
server/src/sql-tools/public_api.ts
Normal file
|
@ -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';
|
473
server/src/sql-tools/schema-diff-to-sql.spec.ts
Normal file
473
server/src/sql-tools/schema-diff-to-sql.spec.ts
Normal file
|
@ -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`]);
|
||||
});
|
||||
});
|
||||
});
|
204
server/src/sql-tools/schema-diff-to-sql.ts
Normal file
204
server/src/sql-tools/schema-diff-to-sql.ts
Normal file
|
@ -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 = <T>(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}";`;
|
||||
};
|
635
server/src/sql-tools/schema-diff.spec.ts
Normal file
635
server/src/sql-tools/schema-diff.spec.ts
Normal file
|
@ -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<Omit<DatabaseColumn, 'tableName'>>): 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)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
449
server/src/sql-tools/schema-diff.ts
Normal file
449
server/src/sql-tools/schema-diff.ts
Normal file
|
@ -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<unknown>, target: Set<unknown>) =>
|
||||
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 = <T extends DatabaseConstraint>(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 },
|
||||
];
|
||||
};
|
394
server/src/sql-tools/schema-from-database.ts
Normal file
394
server/src/sql-tools/schema-from-database.ts
Normal file
|
@ -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<DatabaseSchema> => {
|
||||
const db = createDatabaseClient(postgres);
|
||||
|
||||
const warnings: string[] = [];
|
||||
const warn = (message: string) => {
|
||||
warnings.push(message);
|
||||
};
|
||||
|
||||
const schemaName = options.schemaName || 'public';
|
||||
const tablesMap: Record<string, DatabaseTable> = {};
|
||||
|
||||
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<PostgresDB>({ 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<string>('pg_get_expr', ['ix.indexprs', 'ix.indrelid']).as('expression'),
|
||||
eb.fn<string>('pg_get_expr', ['ix.indpred', 'ix.indrelid']).as('where'),
|
||||
eb
|
||||
.selectFrom('pg_attribute as a')
|
||||
.where('t.relkind', '=', sql.lit('r'))
|
||||
.whereRef('a.attrelid', '=', 't.oid')
|
||||
// list of columns numbers in the index
|
||||
.whereRef('a.attnum', '=', sql`any("ix"."indkey")`)
|
||||
.select((eb) => eb.fn<string[]>('json_agg', ['a.attname']).as('column_name'))
|
||||
.as('column_names'),
|
||||
])
|
||||
.where('pg_namespace.nspname', '=', 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<string[]>('json_agg', ['pg_attribute.attname']).as('column_name'))
|
||||
.as('column_names'),
|
||||
eb
|
||||
.selectFrom('pg_attribute')
|
||||
// matching foreign table for FK
|
||||
.whereRef('pg_attribute.attrelid', '=', 'pg_constraint.confrelid')
|
||||
.whereRef('pg_attribute.attnum', '=', sql`any("pg_constraint"."confkey")`)
|
||||
.select((eb) => eb.fn<string[]>('json_agg', ['pg_attribute.attname']).as('column_name'))
|
||||
.as('reference_column_names'),
|
||||
eb.fn<string>('pg_get_constraintdef', ['pg_constraint.oid']).as('expression'),
|
||||
])
|
||||
.where('pg_namespace.nspname', '=', schemaName)
|
||||
.execute();
|
||||
};
|
31
server/src/sql-tools/schema-from-decorators.spec.ts
Normal file
31
server/src/sql-tools/schema-from-decorators.spec.ts
Normal file
|
@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
443
server/src/sql-tools/schema-from-decorators.ts
Normal file
443
server/src/sql-tools/schema-from-decorators.ts
Normal file
|
@ -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<T> = { object: Function } & T;
|
||||
type PropertyBased<T> = { 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 = <T extends { name: string }>(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,
|
||||
});
|
||||
}
|
||||
};
|
363
server/src/sql-tools/types.ts
Normal file
363
server/src/sql-tools/types.ts
Normal file
|
@ -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<PostgresDB>;
|
||||
|
||||
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<ColumnOptions, 'type'> & {
|
||||
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;
|
||||
};
|
|
@ -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<AssetEntity | AlbumEntity> {
|
||||
async afterRemove(event: RemoveEvent<AssetEntity>): Promise<void> {
|
||||
await this.onEvent(DatabaseAction.DELETE, event);
|
||||
}
|
||||
|
||||
private async onEvent<T>(action: DatabaseAction, event: RemoveEvent<T>): Promise<any> {
|
||||
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<AuditEntity> | 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;
|
||||
}
|
||||
}
|
56
server/src/tables/activity.table.ts
Normal file
56
server/src/tables/activity.table.ts
Normal file
|
@ -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;
|
||||
}
|
27
server/src/tables/album-asset.table.ts
Normal file
27
server/src/tables/album-asset.table.ts
Normal file
|
@ -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;
|
||||
}
|
29
server/src/tables/album-user.table.ts
Normal file
29
server/src/tables/album-user.table.ts
Normal file
|
@ -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;
|
||||
}
|
51
server/src/tables/album.table.ts
Normal file
51
server/src/tables/album.table.ts
Normal file
|
@ -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;
|
||||
}
|
40
server/src/tables/api-key.table.ts
Normal file
40
server/src/tables/api-key.table.ts
Normal file
|
@ -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;
|
||||
}
|
19
server/src/tables/asset-audit.table.ts
Normal file
19
server/src/tables/asset-audit.table.ts
Normal file
|
@ -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;
|
||||
}
|
42
server/src/tables/asset-face.table.ts
Normal file
42
server/src/tables/asset-face.table.ts
Normal file
|
@ -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;
|
||||
}
|
40
server/src/tables/asset-files.table.ts
Normal file
40
server/src/tables/asset-files.table.ts
Normal file
|
@ -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;
|
||||
}
|
23
server/src/tables/asset-job-status.table.ts
Normal file
23
server/src/tables/asset-job-status.table.ts
Normal file
|
@ -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;
|
||||
}
|
138
server/src/tables/asset.table.ts
Normal file
138
server/src/tables/asset.table.ts
Normal file
|
@ -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;
|
||||
}
|
24
server/src/tables/audit.table.ts
Normal file
24
server/src/tables/audit.table.ts
Normal file
|
@ -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;
|
||||
}
|
105
server/src/tables/exif.table.ts
Normal file
105
server/src/tables/exif.table.ts
Normal file
|
@ -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;
|
||||
}
|
16
server/src/tables/face-search.table.ts
Normal file
16
server/src/tables/face-search.table.ts
Normal file
|
@ -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;
|
||||
}
|
73
server/src/tables/geodata-places.table.ts
Normal file
73
server/src/tables/geodata-places.table.ts
Normal file
|
@ -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;
|
||||
}
|
70
server/src/tables/index.ts
Normal file
70
server/src/tables/index.ts
Normal file
|
@ -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,
|
||||
];
|
46
server/src/tables/library.table.ts
Normal file
46
server/src/tables/library.table.ts
Normal file
|
@ -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;
|
||||
}
|
60
server/src/tables/memory.table.ts
Normal file
60
server/src/tables/memory.table.ts
Normal file
|
@ -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<T extends MemoryType = MemoryType> {
|
||||
@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;
|
||||
}
|
14
server/src/tables/memory_asset.table.ts
Normal file
14
server/src/tables/memory_asset.table.ts
Normal file
|
@ -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;
|
||||
}
|
24
server/src/tables/move.table.ts
Normal file
24
server/src/tables/move.table.ts
Normal file
|
@ -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;
|
||||
}
|
37
server/src/tables/natural-earth-countries.table.ts
Normal file
37
server/src/tables/natural-earth-countries.table.ts
Normal file
|
@ -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;
|
||||
}
|
19
server/src/tables/partner-audit.table.ts
Normal file
19
server/src/tables/partner-audit.table.ts
Normal file
|
@ -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;
|
||||
}
|
32
server/src/tables/partner.table.ts
Normal file
32
server/src/tables/partner.table.ts
Normal file
|
@ -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;
|
||||
}
|
54
server/src/tables/person.table.ts
Normal file
54
server/src/tables/person.table.ts
Normal file
|
@ -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;
|
||||
}
|
40
server/src/tables/session.table.ts
Normal file
40
server/src/tables/session.table.ts
Normal file
|
@ -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;
|
||||
}
|
14
server/src/tables/shared-link-asset.table.ts
Normal file
14
server/src/tables/shared-link-asset.table.ts
Normal file
|
@ -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;
|
||||
}
|
54
server/src/tables/shared-link.table.ts
Normal file
54
server/src/tables/shared-link.table.ts
Normal file
|
@ -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;
|
||||
}
|
16
server/src/tables/smart-search.table.ts
Normal file
16
server/src/tables/smart-search.table.ts
Normal file
|
@ -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;
|
||||
}
|
16
server/src/tables/stack.table.ts
Normal file
16
server/src/tables/stack.table.ts
Normal file
|
@ -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;
|
||||
}
|
34
server/src/tables/sync-checkpoint.table.ts
Normal file
34
server/src/tables/sync-checkpoint.table.ts
Normal file
|
@ -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;
|
||||
}
|
12
server/src/tables/system-metadata.table.ts
Normal file
12
server/src/tables/system-metadata.table.ts
Normal file
|
@ -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<T extends keyof SystemMetadata = SystemMetadataKey> {
|
||||
@PrimaryColumn({ type: 'character varying' })
|
||||
key!: T;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
value!: SystemMetadata[T];
|
||||
}
|
15
server/src/tables/tag-asset.table.ts
Normal file
15
server/src/tables/tag-asset.table.ts
Normal file
|
@ -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;
|
||||
}
|
15
server/src/tables/tag-closure.table.ts
Normal file
15
server/src/tables/tag-closure.table.ts
Normal file
|
@ -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;
|
||||
}
|
41
server/src/tables/tag.table.ts
Normal file
41
server/src/tables/tag.table.ts
Normal file
|
@ -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;
|
||||
}
|
14
server/src/tables/user-audit.table.ts
Normal file
14
server/src/tables/user-audit.table.ts
Normal file
|
@ -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;
|
||||
}
|
16
server/src/tables/user-metadata.table.ts
Normal file
16
server/src/tables/user-metadata.table.ts
Normal file
|
@ -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<T extends keyof UserMetadata = UserMetadataKey> implements UserMetadataItem<T> {
|
||||
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
|
||||
userId!: string;
|
||||
|
||||
@PrimaryColumn({ type: 'character varying' })
|
||||
key!: T;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
value!: UserMetadata[T];
|
||||
}
|
73
server/src/tables/user.table.ts
Normal file
73
server/src/tables/user.table.ts
Normal file
|
@ -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<Date, Date | string, Date | string>;
|
||||
type Generated<T> =
|
||||
T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
|
||||
|
||||
@Table('users')
|
||||
@Index({ name: 'IDX_users_updated_at_asc_id_asc', columns: ['updatedAt', 'id'] })
|
||||
export class UserTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
|
||||
@Column({ default: '' })
|
||||
name!: Generated<string>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isAdmin!: Generated<boolean>;
|
||||
|
||||
@Column({ unique: true })
|
||||
email!: string;
|
||||
|
||||
@Column({ unique: true, nullable: true, default: null })
|
||||
storageLabel!: string | null;
|
||||
|
||||
@Column({ default: '' })
|
||||
password!: Generated<string>;
|
||||
|
||||
@Column({ default: '' })
|
||||
oauthId!: Generated<string>;
|
||||
|
||||
@Column({ default: '' })
|
||||
profileImagePath!: Generated<string>;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
shouldChangePassword!: Generated<boolean>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Generated<Timestamp>;
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt!: Timestamp | null;
|
||||
|
||||
@Column({ type: 'character varying', default: UserStatus.ACTIVE })
|
||||
status!: Generated<UserStatus>;
|
||||
|
||||
@ColumnIndex({ name: 'IDX_users_update_id' })
|
||||
@UpdateIdColumn()
|
||||
updateId!: Generated<string>;
|
||||
|
||||
@Column({ type: 'bigint', nullable: true })
|
||||
quotaSizeInBytes!: ColumnType<number> | null;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
quotaUsageInBytes!: Generated<ColumnType<number>>;
|
||||
|
||||
@Column({ type: 'timestamp with time zone', default: () => 'now()' })
|
||||
profileChangedAt!: Generated<Timestamp>;
|
||||
}
|
13
server/src/tables/version-history.table.ts
Normal file
13
server/src/tables/version-history.table.ts
Normal file
|
@ -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;
|
||||
}
|
|
@ -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<StorageFolder, boolean> };
|
||||
export type MemoriesState = {
|
||||
/** memories have already been created through this date */
|
||||
lastOnThisDayDate: string;
|
||||
};
|
||||
|
||||
export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> {
|
||||
[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<SystemConfig>;
|
||||
[SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial<SystemFlags>;
|
||||
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
|
||||
[SystemMetadataKey.MEMORIES_STATE]: MemoriesState;
|
||||
}
|
||||
|
|
|
@ -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<T>(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<string>) => sql<string>`${id}::uuid`;
|
||||
|
||||
|
@ -32,16 +17,3 @@ export const removeUndefinedKeys = <T extends object>(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> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|
||||
|
||||
/** Recursive version of {@link Expand} from the same source. */
|
||||
export type ExpandRecursively<T> = T extends object
|
||||
? T extends infer O
|
||||
? { [K in keyof O]: ExpandRecursively<O[K]> }
|
||||
: never
|
||||
: T;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<Insertable<Assets>>;
|
||||
type User = Partial<Insertable<Users>>;
|
||||
type User = Partial<Insertable<UserTable>>;
|
||||
type Session = Omit<Insertable<Sessions>, 'token'> & { token?: string };
|
||||
type Partner = Insertable<Partners>;
|
||||
|
||||
|
@ -103,7 +104,7 @@ export class TestFactory {
|
|||
|
||||
static user(user: User = {}) {
|
||||
const userId = user.id || newUuid();
|
||||
const defaults: Insertable<Users> = {
|
||||
const defaults: Insertable<UserTable> = {
|
||||
email: `${userId}@immich.cloud`,
|
||||
name: `User ${userId}`,
|
||||
deletedAt: null,
|
||||
|
|
14
server/test/fixtures/audit.stub.ts
vendored
14
server/test/fixtures/audit.stub.ts
vendored
|
@ -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<AuditEntity>({
|
||||
id: 3,
|
||||
entityId: 'asset-deleted',
|
||||
action: DatabaseAction.DELETE,
|
||||
entityType: EntityType.ASSET,
|
||||
ownerId: authStub.admin.user.id,
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
};
|
5
server/test/fixtures/user.stub.ts
vendored
5
server/test/fixtures/user.stub.ts
vendored
|
@ -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,
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue