mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
feat: schema diff sql tools (#17116)
This commit is contained in:
parent
3fde5a8328
commit
4b4bcd23f4
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@ -525,7 +525,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate new migrations
|
- name: Generate new migrations
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: npm run typeorm:migrations:generate ./src/migrations/TestMigration
|
run: npm run migrations:generate TestMigration
|
||||||
|
|
||||||
- name: Find file changes
|
- name: Find file changes
|
||||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20
|
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20
|
||||||
@ -538,7 +538,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "ERROR: Generated migration files not up to date!"
|
echo "ERROR: Generated migration files not up to date!"
|
||||||
echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}"
|
echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}"
|
||||||
cat ./src/migrations/*-TestMigration.ts
|
cat ./src/*-TestMigration.ts
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Run SQL generation
|
- 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",
|
"test:medium": "vitest --config test/vitest.config.medium.mjs",
|
||||||
"typeorm": "typeorm",
|
"typeorm": "typeorm",
|
||||||
"lifecycle": "node ./dist/utils/lifecycle.js",
|
"lifecycle": "node ./dist/utils/lifecycle.js",
|
||||||
"typeorm:migrations:create": "typeorm migration:create",
|
"migrations:generate": "node ./dist/bin/migrations.js generate",
|
||||||
"typeorm:migrations:generate": "typeorm migration:generate -d ./dist/bin/database.js",
|
"migrations:create": "node ./dist/bin/migrations.js create",
|
||||||
"typeorm:migrations:run": "typeorm migration:run -d ./dist/bin/database.js",
|
"typeorm:migrations:run": "typeorm migration:run -d ./dist/bin/database.js",
|
||||||
"typeorm:migrations:revert": "typeorm migration:revert -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;'",
|
"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 type { ColumnType } from 'kysely';
|
||||||
import { OnThisDayData } from 'src/entities/memory.entity';
|
|
||||||
import { AssetType, MemoryType, Permission, SyncEntityType } from 'src/enum';
|
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>;
|
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
|
||||||
|
|
||||||
@ -410,26 +411,6 @@ export interface UserMetadata {
|
|||||||
value: Json;
|
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 {
|
export interface UsersAudit {
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -495,7 +476,7 @@ export interface DB {
|
|||||||
tags_closure: TagsClosure;
|
tags_closure: TagsClosure;
|
||||||
typeorm_metadata: TypeormMetadata;
|
typeorm_metadata: TypeormMetadata;
|
||||||
user_metadata: UserMetadata;
|
user_metadata: UserMetadata;
|
||||||
users: Users;
|
users: UserTable;
|
||||||
users_audit: UsersAudit;
|
users_audit: UsersAudit;
|
||||||
'vectors.pg_vector_index_stat': VectorsPgVectorIndexStat;
|
'vectors.pg_vector_index_stat': VectorsPgVectorIndexStat;
|
||||||
version_history: VersionHistory;
|
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 { AlbumEntity } from 'src/entities/album.entity';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
import { UserEntity } from 'src/entities/user.entity';
|
||||||
import { AlbumUserRole } from 'src/enum';
|
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 {
|
export class AlbumUserEntity {
|
||||||
@PrimaryColumn({ type: 'uuid', name: 'albumsId' })
|
|
||||||
albumId!: string;
|
albumId!: string;
|
||||||
|
|
||||||
@PrimaryColumn({ type: 'uuid', name: 'usersId' })
|
|
||||||
userId!: string;
|
userId!: string;
|
||||||
|
|
||||||
@JoinColumn({ name: 'albumsId' })
|
|
||||||
@ManyToOne(() => AlbumEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
|
||||||
album!: AlbumEntity;
|
album!: AlbumEntity;
|
||||||
|
|
||||||
@JoinColumn({ name: 'usersId' })
|
|
||||||
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
|
||||||
user!: UserEntity;
|
user!: UserEntity;
|
||||||
|
|
||||||
@Column({ type: 'varchar', default: AlbumUserRole.EDITOR })
|
|
||||||
role!: AlbumUserRole;
|
role!: AlbumUserRole;
|
||||||
}
|
}
|
||||||
|
@ -3,69 +3,22 @@ import { AssetEntity } from 'src/entities/asset.entity';
|
|||||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
import { UserEntity } from 'src/entities/user.entity';
|
||||||
import { AssetOrder } from 'src/enum';
|
import { AssetOrder } from 'src/enum';
|
||||||
import {
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
DeleteDateColumn,
|
|
||||||
Entity,
|
|
||||||
Index,
|
|
||||||
JoinTable,
|
|
||||||
ManyToMany,
|
|
||||||
ManyToOne,
|
|
||||||
OneToMany,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('albums')
|
|
||||||
export class AlbumEntity {
|
export class AlbumEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
|
||||||
owner!: UserEntity;
|
owner!: UserEntity;
|
||||||
|
|
||||||
@Column()
|
|
||||||
ownerId!: string;
|
ownerId!: string;
|
||||||
|
|
||||||
@Column({ default: 'Untitled Album' })
|
|
||||||
albumName!: string;
|
albumName!: string;
|
||||||
|
|
||||||
@Column({ type: 'text', default: '' })
|
|
||||||
description!: string;
|
description!: string;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamptz' })
|
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ type: 'timestamptz' })
|
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
@Index('IDX_albums_update_id')
|
|
||||||
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
|
|
||||||
updateId?: string;
|
updateId?: string;
|
||||||
|
|
||||||
@DeleteDateColumn({ type: 'timestamptz' })
|
|
||||||
deletedAt!: Date | null;
|
deletedAt!: Date | null;
|
||||||
|
|
||||||
@ManyToOne(() => AssetEntity, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
|
|
||||||
albumThumbnailAsset!: AssetEntity | null;
|
albumThumbnailAsset!: AssetEntity | null;
|
||||||
|
|
||||||
@Column({ comment: 'Asset ID to be used as thumbnail', nullable: true })
|
|
||||||
albumThumbnailAssetId!: string | null;
|
albumThumbnailAssetId!: string | null;
|
||||||
|
|
||||||
@OneToMany(() => AlbumUserEntity, ({ album }) => album, { cascade: true, onDelete: 'CASCADE' })
|
|
||||||
albumUsers!: AlbumUserEntity[];
|
albumUsers!: AlbumUserEntity[];
|
||||||
|
|
||||||
@ManyToMany(() => AssetEntity, (asset) => asset.albums)
|
|
||||||
@JoinTable({ synchronize: false })
|
|
||||||
assets!: AssetEntity[];
|
assets!: AssetEntity[];
|
||||||
|
|
||||||
@OneToMany(() => SharedLinkEntity, (link) => link.album)
|
|
||||||
sharedLinks!: SharedLinkEntity[];
|
sharedLinks!: SharedLinkEntity[];
|
||||||
|
|
||||||
@Column({ default: true })
|
|
||||||
isActivityEnabled!: boolean;
|
isActivityEnabled!: boolean;
|
||||||
|
|
||||||
@Column({ type: 'varchar', default: AssetOrder.DESC })
|
|
||||||
order!: AssetOrder;
|
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 { FaceSearchEntity } from 'src/entities/face-search.entity';
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
import { PersonEntity } from 'src/entities/person.entity';
|
||||||
import { SourceType } from 'src/enum';
|
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 {
|
export class AssetFaceEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Column()
|
|
||||||
assetId!: string;
|
assetId!: string;
|
||||||
|
|
||||||
@Column({ nullable: true, type: 'uuid' })
|
|
||||||
personId!: string | null;
|
personId!: string | null;
|
||||||
|
|
||||||
@OneToOne(() => FaceSearchEntity, (faceSearchEntity) => faceSearchEntity.face, { cascade: ['insert'] })
|
|
||||||
faceSearch?: FaceSearchEntity;
|
faceSearch?: FaceSearchEntity;
|
||||||
|
|
||||||
@Column({ default: 0, type: 'int' })
|
|
||||||
imageWidth!: number;
|
imageWidth!: number;
|
||||||
|
|
||||||
@Column({ default: 0, type: 'int' })
|
|
||||||
imageHeight!: number;
|
imageHeight!: number;
|
||||||
|
|
||||||
@Column({ default: 0, type: 'int' })
|
|
||||||
boundingBoxX1!: number;
|
boundingBoxX1!: number;
|
||||||
|
|
||||||
@Column({ default: 0, type: 'int' })
|
|
||||||
boundingBoxY1!: number;
|
boundingBoxY1!: number;
|
||||||
|
|
||||||
@Column({ default: 0, type: 'int' })
|
|
||||||
boundingBoxX2!: number;
|
boundingBoxX2!: number;
|
||||||
|
|
||||||
@Column({ default: 0, type: 'int' })
|
|
||||||
boundingBoxY2!: number;
|
boundingBoxY2!: number;
|
||||||
|
|
||||||
@Column({ default: SourceType.MACHINE_LEARNING, type: 'enum', enum: SourceType })
|
|
||||||
sourceType!: SourceType;
|
sourceType!: SourceType;
|
||||||
|
|
||||||
@ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
|
||||||
asset!: AssetEntity;
|
asset!: AssetEntity;
|
||||||
|
|
||||||
@ManyToOne(() => PersonEntity, (person) => person.faces, {
|
|
||||||
onDelete: 'SET NULL',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
person!: PersonEntity | null;
|
person!: PersonEntity | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamptz' })
|
|
||||||
deletedAt!: Date | null;
|
deletedAt!: Date | null;
|
||||||
}
|
}
|
||||||
|
@ -1,42 +1,13 @@
|
|||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { AssetFileType } from 'src/enum';
|
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 {
|
export class AssetFileEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Index('IDX_asset_files_assetId')
|
|
||||||
@Column()
|
|
||||||
assetId!: string;
|
assetId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
|
||||||
asset?: AssetEntity;
|
asset?: AssetEntity;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamptz' })
|
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ type: 'timestamptz' })
|
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
@Index('IDX_asset_files_update_id')
|
|
||||||
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
|
|
||||||
updateId?: string;
|
updateId?: string;
|
||||||
|
|
||||||
@Column()
|
|
||||||
type!: AssetFileType;
|
type!: AssetFileType;
|
||||||
|
|
||||||
@Column()
|
|
||||||
path!: string;
|
path!: string;
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,11 @@
|
|||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('asset_job_status')
|
|
||||||
export class AssetJobStatusEntity {
|
export class AssetJobStatusEntity {
|
||||||
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
|
||||||
@JoinColumn()
|
|
||||||
asset!: AssetEntity;
|
asset!: AssetEntity;
|
||||||
|
|
||||||
@PrimaryColumn()
|
|
||||||
assetId!: string;
|
assetId!: string;
|
||||||
|
|
||||||
@Column({ type: 'timestamptz', nullable: true })
|
|
||||||
facesRecognizedAt!: Date | null;
|
facesRecognizedAt!: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamptz', nullable: true })
|
|
||||||
metadataExtractedAt!: Date | null;
|
metadataExtractedAt!: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamptz', nullable: true })
|
|
||||||
duplicatesDetectedAt!: Date | null;
|
duplicatesDetectedAt!: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamptz', nullable: true })
|
|
||||||
previewAt!: Date | null;
|
previewAt!: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamptz', nullable: true })
|
|
||||||
thumbnailAt!: Date | null;
|
thumbnailAt!: Date | null;
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,6 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
|||||||
import { AssetFileEntity } from 'src/entities/asset-files.entity';
|
import { AssetFileEntity } from 'src/entities/asset-files.entity';
|
||||||
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
|
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
|
||||||
import { ExifEntity } from 'src/entities/exif.entity';
|
import { ExifEntity } from 'src/entities/exif.entity';
|
||||||
import { LibraryEntity } from 'src/entities/library.entity';
|
|
||||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||||
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
|
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
|
||||||
import { StackEntity } from 'src/entities/stack.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 { TimeBucketSize } from 'src/repositories/asset.repository';
|
||||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||||
import { anyUuid, asUuid } from 'src/utils/database';
|
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';
|
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 {
|
export class AssetEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Column()
|
|
||||||
deviceAssetId!: string;
|
deviceAssetId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
|
||||||
owner!: UserEntity;
|
owner!: UserEntity;
|
||||||
|
|
||||||
@Column()
|
|
||||||
ownerId!: string;
|
ownerId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => LibraryEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
|
||||||
library?: LibraryEntity | null;
|
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
libraryId?: string | null;
|
libraryId?: string | null;
|
||||||
|
|
||||||
@Column()
|
|
||||||
deviceId!: string;
|
deviceId!: string;
|
||||||
|
|
||||||
@Column()
|
|
||||||
type!: AssetType;
|
type!: AssetType;
|
||||||
|
|
||||||
@Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE })
|
|
||||||
status!: AssetStatus;
|
status!: AssetStatus;
|
||||||
|
|
||||||
@Column()
|
|
||||||
originalPath!: string;
|
originalPath!: string;
|
||||||
|
|
||||||
@OneToMany(() => AssetFileEntity, (assetFile) => assetFile.asset)
|
|
||||||
files!: AssetFileEntity[];
|
files!: AssetFileEntity[];
|
||||||
|
|
||||||
@Column({ type: 'bytea', nullable: true })
|
|
||||||
thumbhash!: Buffer | null;
|
thumbhash!: Buffer | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true, default: '' })
|
|
||||||
encodedVideoPath!: string | null;
|
encodedVideoPath!: string | null;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamptz' })
|
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ type: 'timestamptz' })
|
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
@Index('IDX_assets_update_id')
|
|
||||||
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
|
|
||||||
updateId?: string;
|
updateId?: string;
|
||||||
|
|
||||||
@DeleteDateColumn({ type: 'timestamptz', nullable: true })
|
|
||||||
deletedAt!: Date | null;
|
deletedAt!: Date | null;
|
||||||
|
|
||||||
@Index('idx_asset_file_created_at')
|
|
||||||
@Column({ type: 'timestamptz', nullable: true, default: null })
|
|
||||||
fileCreatedAt!: Date;
|
fileCreatedAt!: Date;
|
||||||
|
|
||||||
@Column({ type: 'timestamptz', nullable: true, default: null })
|
|
||||||
localDateTime!: Date;
|
localDateTime!: Date;
|
||||||
|
|
||||||
@Column({ type: 'timestamptz', nullable: true, default: null })
|
|
||||||
fileModifiedAt!: Date;
|
fileModifiedAt!: Date;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
|
||||||
isFavorite!: boolean;
|
isFavorite!: boolean;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
|
||||||
isArchived!: boolean;
|
isArchived!: boolean;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
|
||||||
isExternal!: boolean;
|
isExternal!: boolean;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
|
||||||
isOffline!: boolean;
|
isOffline!: boolean;
|
||||||
|
|
||||||
@Column({ type: 'bytea' })
|
|
||||||
@Index()
|
|
||||||
checksum!: Buffer; // sha1 checksum
|
checksum!: Buffer; // sha1 checksum
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
duration!: string | null;
|
duration!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: true })
|
|
||||||
isVisible!: boolean;
|
isVisible!: boolean;
|
||||||
|
|
||||||
@ManyToOne(() => AssetEntity, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' })
|
|
||||||
@JoinColumn()
|
|
||||||
livePhotoVideo!: AssetEntity | null;
|
livePhotoVideo!: AssetEntity | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
livePhotoVideoId!: string | null;
|
livePhotoVideoId!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar' })
|
|
||||||
@Index()
|
|
||||||
originalFileName!: string;
|
originalFileName!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
sidecarPath!: string | null;
|
sidecarPath!: string | null;
|
||||||
|
|
||||||
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
|
|
||||||
exifInfo?: ExifEntity;
|
exifInfo?: ExifEntity;
|
||||||
|
|
||||||
@OneToOne(() => SmartSearchEntity, (smartSearchEntity) => smartSearchEntity.asset)
|
|
||||||
smartSearch?: SmartSearchEntity;
|
smartSearch?: SmartSearchEntity;
|
||||||
|
|
||||||
@ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true })
|
|
||||||
@JoinTable({ name: 'tag_asset', synchronize: false })
|
|
||||||
tags!: TagEntity[];
|
tags!: TagEntity[];
|
||||||
|
|
||||||
@ManyToMany(() => SharedLinkEntity, (link) => link.assets, { cascade: true })
|
|
||||||
@JoinTable({ name: 'shared_link__asset' })
|
|
||||||
sharedLinks!: SharedLinkEntity[];
|
sharedLinks!: SharedLinkEntity[];
|
||||||
|
|
||||||
@ManyToMany(() => AlbumEntity, (album) => album.assets, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
|
||||||
albums?: AlbumEntity[];
|
albums?: AlbumEntity[];
|
||||||
|
|
||||||
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset)
|
|
||||||
faces!: AssetFaceEntity[];
|
faces!: AssetFaceEntity[];
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
stackId?: string | null;
|
stackId?: string | null;
|
||||||
|
|
||||||
@ManyToOne(() => StackEntity, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
|
|
||||||
@JoinColumn()
|
|
||||||
stack?: StackEntity | null;
|
stack?: StackEntity | null;
|
||||||
|
|
||||||
@OneToOne(() => AssetJobStatusEntity, (jobStatus) => jobStatus.asset, { nullable: true })
|
|
||||||
jobStatus?: AssetJobStatusEntity;
|
jobStatus?: AssetJobStatusEntity;
|
||||||
|
|
||||||
@Index('IDX_assets_duplicateId')
|
|
||||||
@Column({ type: 'uuid', nullable: true })
|
|
||||||
duplicateId!: string | null;
|
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 { 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 {
|
export class ExifEntity {
|
||||||
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
|
||||||
@JoinColumn()
|
|
||||||
asset?: AssetEntity;
|
asset?: AssetEntity;
|
||||||
|
|
||||||
@PrimaryColumn()
|
|
||||||
assetId!: string;
|
assetId!: string;
|
||||||
|
|
||||||
@UpdateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' })
|
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
|
|
||||||
@Index('IDX_asset_exif_update_id')
|
|
||||||
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
|
|
||||||
updateId?: string;
|
updateId?: string;
|
||||||
|
|
||||||
/* General info */
|
|
||||||
@Column({ type: 'text', default: '' })
|
|
||||||
description!: string; // or caption
|
description!: string; // or caption
|
||||||
|
|
||||||
@Column({ type: 'integer', nullable: true })
|
|
||||||
exifImageWidth!: number | null;
|
exifImageWidth!: number | null;
|
||||||
|
|
||||||
@Column({ type: 'integer', nullable: true })
|
|
||||||
exifImageHeight!: number | null;
|
exifImageHeight!: number | null;
|
||||||
|
|
||||||
@Column({ type: 'bigint', nullable: true })
|
|
||||||
fileSizeInByte!: number | null;
|
fileSizeInByte!: number | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
orientation!: string | null;
|
orientation!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamptz', nullable: true })
|
|
||||||
dateTimeOriginal!: Date | null;
|
dateTimeOriginal!: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamptz', nullable: true })
|
|
||||||
modifyDate!: Date | null;
|
modifyDate!: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
timeZone!: string | null;
|
timeZone!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'float', nullable: true })
|
|
||||||
latitude!: number | null;
|
latitude!: number | null;
|
||||||
|
|
||||||
@Column({ type: 'float', nullable: true })
|
|
||||||
longitude!: number | null;
|
longitude!: number | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
projectionType!: string | null;
|
projectionType!: string | null;
|
||||||
|
|
||||||
@Index('exif_city')
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
city!: string | null;
|
city!: string | null;
|
||||||
|
|
||||||
@Index('IDX_live_photo_cid')
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
livePhotoCID!: string | null;
|
livePhotoCID!: string | null;
|
||||||
|
|
||||||
@Index('IDX_auto_stack_id')
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
autoStackId!: string | null;
|
autoStackId!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
state!: string | null;
|
state!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
country!: string | null;
|
country!: string | null;
|
||||||
|
|
||||||
/* Image info */
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
make!: string | null;
|
make!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
model!: string | null;
|
model!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
lensModel!: string | null;
|
lensModel!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'float8', nullable: true })
|
|
||||||
fNumber!: number | null;
|
fNumber!: number | null;
|
||||||
|
|
||||||
@Column({ type: 'float8', nullable: true })
|
|
||||||
focalLength!: number | null;
|
focalLength!: number | null;
|
||||||
|
|
||||||
@Column({ type: 'integer', nullable: true })
|
|
||||||
iso!: number | null;
|
iso!: number | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
exposureTime!: string | null;
|
exposureTime!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
profileDescription!: string | null;
|
profileDescription!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
colorspace!: string | null;
|
colorspace!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'integer', nullable: true })
|
|
||||||
bitsPerSample!: number | null;
|
bitsPerSample!: number | null;
|
||||||
|
|
||||||
@Column({ type: 'integer', nullable: true })
|
|
||||||
rating!: number | null;
|
rating!: number | null;
|
||||||
|
|
||||||
/* Video info */
|
|
||||||
@Column({ type: 'float8', nullable: true })
|
|
||||||
fps?: number | null;
|
fps?: number | null;
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,7 @@
|
|||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
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 {
|
export class FaceSearchEntity {
|
||||||
@OneToOne(() => AssetFaceEntity, { onDelete: 'CASCADE', nullable: true })
|
|
||||||
@JoinColumn({ name: 'faceId', referencedColumnName: 'id' })
|
|
||||||
face?: AssetFaceEntity;
|
face?: AssetFaceEntity;
|
||||||
|
|
||||||
@PrimaryColumn()
|
|
||||||
faceId!: string;
|
faceId!: string;
|
||||||
|
|
||||||
@Index('face_index', { synchronize: false })
|
|
||||||
@Column({ type: 'float4', array: true })
|
|
||||||
embedding!: string;
|
embedding!: string;
|
||||||
}
|
}
|
||||||
|
@ -1,73 +1,13 @@
|
|||||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('geodata_places', { synchronize: false })
|
|
||||||
export class GeodataPlacesEntity {
|
export class GeodataPlacesEntity {
|
||||||
@PrimaryColumn({ type: 'integer' })
|
|
||||||
id!: number;
|
id!: number;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 200 })
|
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@Column({ type: 'float' })
|
|
||||||
longitude!: number;
|
longitude!: number;
|
||||||
|
|
||||||
@Column({ type: 'float' })
|
|
||||||
latitude!: number;
|
latitude!: number;
|
||||||
|
|
||||||
@Column({ type: 'char', length: 2 })
|
|
||||||
countryCode!: string;
|
countryCode!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
|
||||||
admin1Code!: string;
|
admin1Code!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 80, nullable: true })
|
|
||||||
admin2Code!: string;
|
admin2Code!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
admin1Name!: string;
|
admin1Name!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
admin2Name!: string;
|
admin2Name!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
alternateNames!: string;
|
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;
|
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 { 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 {
|
export class MoveEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Column({ type: 'uuid' })
|
|
||||||
entityId!: string;
|
entityId!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar' })
|
|
||||||
pathType!: PathType;
|
pathType!: PathType;
|
||||||
|
|
||||||
@Column({ type: 'varchar' })
|
|
||||||
oldPath!: string;
|
oldPath!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar' })
|
|
||||||
newPath!: string;
|
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 {
|
export class NaturalEarthCountriesTempEntity {
|
||||||
@PrimaryGeneratedColumn('identity', { generatedIdentity: 'ALWAYS' })
|
|
||||||
id!: number;
|
id!: number;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 50 })
|
|
||||||
admin!: string;
|
admin!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 3 })
|
|
||||||
admin_a3!: string;
|
admin_a3!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 50 })
|
|
||||||
type!: string;
|
type!: string;
|
||||||
|
|
||||||
@Column({ type: 'polygon' })
|
|
||||||
coordinates!: string;
|
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 { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
import { UserEntity } from 'src/entities/user.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 {
|
export class PersonEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamptz' })
|
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ type: 'timestamptz' })
|
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
@Index('IDX_person_update_id')
|
|
||||||
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
|
|
||||||
updateId?: string;
|
updateId?: string;
|
||||||
|
|
||||||
@Column()
|
|
||||||
ownerId!: string;
|
ownerId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
|
||||||
owner!: UserEntity;
|
owner!: UserEntity;
|
||||||
|
|
||||||
@Column({ default: '' })
|
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@Column({ type: 'date', nullable: true })
|
|
||||||
birthDate!: Date | string | null;
|
birthDate!: Date | string | null;
|
||||||
|
|
||||||
@Column({ default: '' })
|
|
||||||
thumbnailPath!: string;
|
thumbnailPath!: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
faceAssetId!: string | null;
|
faceAssetId!: string | null;
|
||||||
|
|
||||||
@ManyToOne(() => AssetFaceEntity, { onDelete: 'SET NULL', nullable: true })
|
|
||||||
faceAsset!: AssetFaceEntity | null;
|
faceAsset!: AssetFaceEntity | null;
|
||||||
|
|
||||||
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.person)
|
|
||||||
faces!: AssetFaceEntity[];
|
faces!: AssetFaceEntity[];
|
||||||
|
|
||||||
@Column({ default: false })
|
|
||||||
isHidden!: boolean;
|
isHidden!: boolean;
|
||||||
|
|
||||||
@Column({ default: false })
|
|
||||||
isFavorite!: boolean;
|
isFavorite!: boolean;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true, default: null })
|
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
}
|
}
|
||||||
|
@ -1,36 +1,16 @@
|
|||||||
import { ExpressionBuilder } from 'kysely';
|
import { ExpressionBuilder } from 'kysely';
|
||||||
import { DB } from 'src/db';
|
import { DB } from 'src/db';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
import { UserEntity } from 'src/entities/user.entity';
|
||||||
import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('sessions')
|
|
||||||
export class SessionEntity {
|
export class SessionEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Column({ select: false })
|
|
||||||
token!: string;
|
token!: string;
|
||||||
|
|
||||||
@Column()
|
|
||||||
userId!: string;
|
userId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => UserEntity, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
|
|
||||||
user!: UserEntity;
|
user!: UserEntity;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamptz' })
|
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ type: 'timestamptz' })
|
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
@Index('IDX_sessions_update_id')
|
|
||||||
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
|
|
||||||
updateId!: string;
|
updateId!: string;
|
||||||
|
|
||||||
@Column({ default: '' })
|
|
||||||
deviceType!: string;
|
deviceType!: string;
|
||||||
|
|
||||||
@Column({ default: '' })
|
|
||||||
deviceOS!: string;
|
deviceOS!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,64 +2,21 @@ import { AlbumEntity } from 'src/entities/album.entity';
|
|||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
import { UserEntity } from 'src/entities/user.entity';
|
||||||
import { SharedLinkType } from 'src/enum';
|
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 {
|
export class SharedLinkEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
description!: string | null;
|
description!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
password!: string | null;
|
password!: string | null;
|
||||||
|
|
||||||
@Column()
|
|
||||||
userId!: string;
|
userId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
|
||||||
user!: UserEntity;
|
user!: UserEntity;
|
||||||
|
|
||||||
@Index('IDX_sharedlink_key')
|
|
||||||
@Column({ type: 'bytea' })
|
|
||||||
key!: Buffer; // use to access the inidividual asset
|
key!: Buffer; // use to access the inidividual asset
|
||||||
|
|
||||||
@Column()
|
|
||||||
type!: SharedLinkType;
|
type!: SharedLinkType;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamptz' })
|
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@Column({ type: 'timestamptz', nullable: true })
|
|
||||||
expiresAt!: Date | null;
|
expiresAt!: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
|
||||||
allowUpload!: boolean;
|
allowUpload!: boolean;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: true })
|
|
||||||
allowDownload!: boolean;
|
allowDownload!: boolean;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: true })
|
|
||||||
showExif!: boolean;
|
showExif!: boolean;
|
||||||
|
|
||||||
@ManyToMany(() => AssetEntity, (asset) => asset.sharedLinks, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
|
||||||
assets!: AssetEntity[];
|
assets!: AssetEntity[];
|
||||||
|
|
||||||
@Index('IDX_sharedlink_albumId')
|
|
||||||
@ManyToOne(() => AlbumEntity, (album) => album.sharedLinks, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
|
||||||
album?: AlbumEntity;
|
album?: AlbumEntity;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
albumId!: string | null;
|
albumId!: string | null;
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,7 @@
|
|||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { Column, Entity, Index, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('smart_search', { synchronize: false })
|
|
||||||
export class SmartSearchEntity {
|
export class SmartSearchEntity {
|
||||||
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
|
||||||
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
|
||||||
asset?: AssetEntity;
|
asset?: AssetEntity;
|
||||||
|
|
||||||
@PrimaryColumn()
|
|
||||||
assetId!: string;
|
assetId!: string;
|
||||||
|
|
||||||
@Index('clip_index', { synchronize: false })
|
|
||||||
@Column({ type: 'float4', array: true })
|
|
||||||
embedding!: string;
|
embedding!: string;
|
||||||
}
|
}
|
||||||
|
@ -1,28 +1,12 @@
|
|||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
import { UserEntity } from 'src/entities/user.entity';
|
||||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('asset_stack')
|
|
||||||
export class StackEntity {
|
export class StackEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
|
||||||
owner!: UserEntity;
|
owner!: UserEntity;
|
||||||
|
|
||||||
@Column()
|
|
||||||
ownerId!: string;
|
ownerId!: string;
|
||||||
|
|
||||||
@OneToMany(() => AssetEntity, (asset) => asset.stack)
|
|
||||||
assets!: AssetEntity[];
|
assets!: AssetEntity[];
|
||||||
|
|
||||||
@OneToOne(() => AssetEntity)
|
|
||||||
@JoinColumn()
|
|
||||||
//TODO: Add constraint to ensure primary asset exists in the assets array
|
|
||||||
primaryAsset!: AssetEntity;
|
primaryAsset!: AssetEntity;
|
||||||
|
|
||||||
@Column({ nullable: false })
|
|
||||||
primaryAssetId!: string;
|
primaryAssetId!: string;
|
||||||
|
|
||||||
assetCount?: number;
|
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 { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { UserEntity } from 'src/entities/user.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 {
|
export class TagEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Column()
|
|
||||||
value!: string;
|
value!: string;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamptz' })
|
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ type: 'timestamptz' })
|
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
@Index('IDX_tags_update_id')
|
|
||||||
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
|
|
||||||
updateId?: string;
|
updateId?: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true, default: null })
|
|
||||||
color!: string | null;
|
color!: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
|
|
||||||
@TreeParent({ onDelete: 'CASCADE' })
|
|
||||||
parent?: TagEntity;
|
parent?: TagEntity;
|
||||||
|
|
||||||
@TreeChildren()
|
|
||||||
children?: TagEntity[];
|
children?: TagEntity[];
|
||||||
|
|
||||||
@ManyToOne(() => UserEntity, (user) => user.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
|
|
||||||
user?: UserEntity;
|
user?: UserEntity;
|
||||||
|
|
||||||
@Column()
|
|
||||||
userId!: string;
|
userId!: string;
|
||||||
|
|
||||||
@ManyToMany(() => AssetEntity, (asset) => asset.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
|
|
||||||
assets?: AssetEntity[];
|
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 { UserAvatarColor, UserMetadataKey } from 'src/enum';
|
||||||
import { DeepPartial } from 'src/types';
|
import { DeepPartial } from 'src/types';
|
||||||
import { HumanReadableSize } from 'src/utils/bytes';
|
import { HumanReadableSize } from 'src/utils/bytes';
|
||||||
import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
|
|
||||||
|
|
||||||
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
|
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
|
||||||
key: T;
|
key: T;
|
||||||
value: UserMetadata[T];
|
value: UserMetadata[T];
|
||||||
};
|
};
|
||||||
|
|
||||||
@Entity('user_metadata')
|
|
||||||
export class UserMetadataEntity<T extends keyof UserMetadata = UserMetadataKey> implements UserMetadataItem<T> {
|
export class UserMetadataEntity<T extends keyof UserMetadata = UserMetadataKey> implements UserMetadataItem<T> {
|
||||||
@PrimaryColumn({ type: 'uuid' })
|
|
||||||
userId!: string;
|
userId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => UserEntity, (user) => user.metadata, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
|
|
||||||
user?: UserEntity;
|
user?: UserEntity;
|
||||||
|
|
||||||
@PrimaryColumn({ type: 'varchar' })
|
|
||||||
key!: T;
|
key!: T;
|
||||||
|
|
||||||
@Column({ type: 'jsonb' })
|
|
||||||
value!: UserMetadata[T];
|
value!: UserMetadata[T];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,82 +2,28 @@ import { ExpressionBuilder } from 'kysely';
|
|||||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||||
import { DB } from 'src/db';
|
import { DB } from 'src/db';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { TagEntity } from 'src/entities/tag.entity';
|
|
||||||
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
|
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
|
||||||
import { UserStatus } from 'src/enum';
|
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 {
|
export class UserEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Column({ default: '' })
|
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@Column({ default: false })
|
|
||||||
isAdmin!: boolean;
|
isAdmin!: boolean;
|
||||||
|
|
||||||
@Column({ unique: true })
|
|
||||||
email!: string;
|
email!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', unique: true, default: null })
|
|
||||||
storageLabel!: string | null;
|
storageLabel!: string | null;
|
||||||
|
|
||||||
@Column({ default: '', select: false })
|
|
||||||
password?: string;
|
password?: string;
|
||||||
|
|
||||||
@Column({ default: '' })
|
|
||||||
oauthId!: string;
|
oauthId!: string;
|
||||||
|
|
||||||
@Column({ default: '' })
|
|
||||||
profileImagePath!: string;
|
profileImagePath!: string;
|
||||||
|
|
||||||
@Column({ default: true })
|
|
||||||
shouldChangePassword!: boolean;
|
shouldChangePassword!: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamptz' })
|
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@DeleteDateColumn({ type: 'timestamptz' })
|
|
||||||
deletedAt!: Date | null;
|
deletedAt!: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', default: UserStatus.ACTIVE })
|
|
||||||
status!: UserStatus;
|
status!: UserStatus;
|
||||||
|
|
||||||
@UpdateDateColumn({ type: 'timestamptz' })
|
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
@Index('IDX_users_update_id')
|
|
||||||
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
|
|
||||||
updateId?: string;
|
updateId?: string;
|
||||||
|
|
||||||
@OneToMany(() => TagEntity, (tag) => tag.user)
|
|
||||||
tags!: TagEntity[];
|
|
||||||
|
|
||||||
@OneToMany(() => AssetEntity, (asset) => asset.owner)
|
|
||||||
assets!: AssetEntity[];
|
assets!: AssetEntity[];
|
||||||
|
|
||||||
@Column({ type: 'bigint', nullable: true })
|
|
||||||
quotaSizeInBytes!: number | null;
|
quotaSizeInBytes!: number | null;
|
||||||
|
|
||||||
@Column({ type: 'bigint', default: 0 })
|
|
||||||
quotaUsageInBytes!: number;
|
quotaUsageInBytes!: number;
|
||||||
|
|
||||||
@OneToMany(() => UserMetadataEntity, (metadata) => metadata.user)
|
|
||||||
metadata!: UserMetadataEntity[];
|
metadata!: UserMetadataEntity[];
|
||||||
|
|
||||||
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
|
||||||
profileChangedAt!: Date;
|
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: {
|
config: {
|
||||||
typeorm: {
|
typeorm: {
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
entities: [`${folders.dist}/entities` + '/*.entity.{js,ts}'],
|
entities: [],
|
||||||
migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'],
|
migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'],
|
||||||
subscribers: [`${folders.dist}/subscribers` + '/*.{js,ts}'],
|
subscribers: [],
|
||||||
migrationsRun: false,
|
migrationsRun: false,
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
connectTimeoutMS: 10_000, // 10 seconds
|
connectTimeoutMS: 10_000, // 10 seconds
|
||||||
|
@ -9,7 +9,6 @@ import { PersonEntity } from 'src/entities/person.entity';
|
|||||||
import { SourceType } from 'src/enum';
|
import { SourceType } from 'src/enum';
|
||||||
import { removeUndefinedKeys } from 'src/utils/database';
|
import { removeUndefinedKeys } from 'src/utils/database';
|
||||||
import { Paginated, PaginationOptions } from 'src/utils/pagination';
|
import { Paginated, PaginationOptions } from 'src/utils/pagination';
|
||||||
import { FindOptionsRelations } from 'typeorm';
|
|
||||||
|
|
||||||
export interface PersonSearchOptions {
|
export interface PersonSearchOptions {
|
||||||
minimumFaceCount: number;
|
minimumFaceCount: number;
|
||||||
@ -247,7 +246,7 @@ export class PersonRepository {
|
|||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
getFaceByIdWithAssets(
|
getFaceByIdWithAssets(
|
||||||
id: string,
|
id: string,
|
||||||
relations?: FindOptionsRelations<AssetFaceEntity>,
|
relations?: { faceSearch?: boolean },
|
||||||
select?: SelectFaceOptions,
|
select?: SelectFaceOptions,
|
||||||
): Promise<AssetFaceEntity | undefined> {
|
): Promise<AssetFaceEntity | undefined> {
|
||||||
return this.db
|
return this.db
|
||||||
|
@ -4,7 +4,7 @@ import { InjectKysely } from 'nestjs-kysely';
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { DB, SystemMetadata as DbSystemMetadata } from 'src/db';
|
import { DB, SystemMetadata as DbSystemMetadata } from 'src/db';
|
||||||
import { GenerateSql } from 'src/decorators';
|
import { GenerateSql } from 'src/decorators';
|
||||||
import { SystemMetadata } from 'src/entities/system-metadata.entity';
|
import { SystemMetadata } from 'src/types';
|
||||||
|
|
||||||
type Upsert = Insertable<DbSystemMetadata>;
|
type Upsert = Insertable<DbSystemMetadata>;
|
||||||
|
|
||||||
|
@ -3,11 +3,12 @@ import { Insertable, Kysely, sql, Updateable } from 'kysely';
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { columns, UserAdmin } from 'src/database';
|
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 { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { UserMetadata, UserMetadataItem } from 'src/entities/user-metadata.entity';
|
import { UserMetadata, UserMetadataItem } from 'src/entities/user-metadata.entity';
|
||||||
import { UserEntity, withMetadata } from 'src/entities/user.entity';
|
import { UserEntity, withMetadata } from 'src/entities/user.entity';
|
||||||
import { AssetType, UserStatus } from 'src/enum';
|
import { AssetType, UserStatus } from 'src/enum';
|
||||||
|
import { UserTable } from 'src/tables/user.table';
|
||||||
import { asUuid } from 'src/utils/database';
|
import { asUuid } from 'src/utils/database';
|
||||||
|
|
||||||
type Upsert = Insertable<DbUserMetadata>;
|
type Upsert = Insertable<DbUserMetadata>;
|
||||||
@ -128,7 +129,7 @@ export class UserRepository {
|
|||||||
.execute() as Promise<UserAdmin[]>;
|
.execute() as Promise<UserAdmin[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: Insertable<Users>): Promise<UserEntity> {
|
async create(dto: Insertable<UserTable>): Promise<UserEntity> {
|
||||||
return this.db
|
return this.db
|
||||||
.insertInto('users')
|
.insertInto('users')
|
||||||
.values(dto)
|
.values(dto)
|
||||||
@ -136,7 +137,7 @@ export class UserRepository {
|
|||||||
.executeTakeFirst() as unknown as Promise<UserEntity>;
|
.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
|
return this.db
|
||||||
.updateTable('users')
|
.updateTable('users')
|
||||||
.set(dto)
|
.set(dto)
|
||||||
|
@ -4,7 +4,6 @@ import sanitize from 'sanitize-filename';
|
|||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/config';
|
||||||
import { SALT_ROUNDS } from 'src/constants';
|
import { SALT_ROUNDS } from 'src/constants';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { Users } from 'src/db';
|
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
import { UserEntity } from 'src/entities/user.entity';
|
||||||
import { AccessRepository } from 'src/repositories/access.repository';
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
import { ActivityRepository } from 'src/repositories/activity.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 { UserRepository } from 'src/repositories/user.repository';
|
||||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||||
import { ViewRepository } from 'src/repositories/view-repository';
|
import { ViewRepository } from 'src/repositories/view-repository';
|
||||||
|
import { UserTable } from 'src/tables/user.table';
|
||||||
import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access';
|
import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access';
|
||||||
import { getConfig, updateConfig } from 'src/utils/config';
|
import { getConfig, updateConfig } from 'src/utils/config';
|
||||||
|
|
||||||
@ -138,7 +138,7 @@ export class BaseService {
|
|||||||
return checkAccess(this.accessRepository, request);
|
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);
|
const user = await this.userRepository.getByEmail(dto.email);
|
||||||
if (user) {
|
if (user) {
|
||||||
throw new BadRequestException('User exists');
|
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) {
|
if (payload.password) {
|
||||||
payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
|
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 { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.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 { JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
|
import { OnThisDayData } from 'src/types';
|
||||||
import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util';
|
import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util';
|
||||||
|
|
||||||
const DAYS = 3;
|
const DAYS = 3;
|
||||||
|
@ -451,11 +451,11 @@ export class PersonService extends BaseService {
|
|||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const face = await this.personRepository.getFaceByIdWithAssets(
|
const face = await this.personRepository.getFaceByIdWithAssets(id, { faceSearch: true }, [
|
||||||
id,
|
'id',
|
||||||
{ person: true, asset: true, faceSearch: true },
|
'personId',
|
||||||
['id', 'personId', 'sourceType'],
|
'sourceType',
|
||||||
);
|
]);
|
||||||
if (!face || !face.asset) {
|
if (!face || !face.asset) {
|
||||||
this.logger.warn(`Face ${id} not found`);
|
this.logger.warn(`Face ${id} not found`);
|
||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
|
@ -2,10 +2,9 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
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 { DatabaseLock, JobName, JobStatus, QueueName, StorageFolder, SystemMetadataKey } from 'src/enum';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { JobOf } from 'src/types';
|
import { JobOf, SystemFlags } from 'src/types';
|
||||||
import { ImmichStartupError } from 'src/utils/misc';
|
import { ImmichStartupError } from 'src/utils/misc';
|
||||||
|
|
||||||
const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`;
|
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 { serverVersion } from 'src/constants';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
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 { DatabaseLock, ImmichEnvironment, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
|
||||||
import { ArgOf } from 'src/repositories/event.repository';
|
import { ArgOf } from 'src/repositories/event.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
|
import { VersionCheckMetadata } from 'src/types';
|
||||||
|
|
||||||
const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => {
|
const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => {
|
||||||
return {
|
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 {
|
import {
|
||||||
AssetType,
|
AssetType,
|
||||||
DatabaseExtension,
|
DatabaseExtension,
|
||||||
ExifOrientation,
|
ExifOrientation,
|
||||||
ImageFormat,
|
ImageFormat,
|
||||||
JobName,
|
JobName,
|
||||||
|
MemoryType,
|
||||||
QueueName,
|
QueueName,
|
||||||
|
StorageFolder,
|
||||||
SyncEntityType,
|
SyncEntityType,
|
||||||
|
SystemMetadataKey,
|
||||||
TranscodeTarget,
|
TranscodeTarget,
|
||||||
VideoCodec,
|
VideoCodec,
|
||||||
} from 'src/enum';
|
} from 'src/enum';
|
||||||
@ -454,3 +458,27 @@ export type StorageAsset = {
|
|||||||
sidecarPath: string | null;
|
sidecarPath: string | null;
|
||||||
fileSizeInByte: number | 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 { 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`;
|
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;
|
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 { HttpException } from '@nestjs/common';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { TypeORMError } from 'typeorm';
|
|
||||||
|
|
||||||
export const logGlobalError = (logger: LoggingRepository, error: Error) => {
|
export const logGlobalError = (logger: LoggingRepository, error: Error) => {
|
||||||
if (error instanceof HttpException) {
|
if (error instanceof HttpException) {
|
||||||
@ -10,11 +9,6 @@ export const logGlobalError = (logger: LoggingRepository, error: Error) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof TypeORMError) {
|
|
||||||
logger.error(`Database error: ${error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
logger.error(`Unknown error: ${error}`, error?.stack);
|
logger.error(`Unknown error: ${error}`, error?.stack);
|
||||||
return;
|
return;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Insertable, Kysely } from 'kysely';
|
import { Insertable, Kysely } from 'kysely';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { Writable } from 'node:stream';
|
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 { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { AssetType } from 'src/enum';
|
import { AssetType } from 'src/enum';
|
||||||
import { AccessRepository } from 'src/repositories/access.repository';
|
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 { UserRepository } from 'src/repositories/user.repository';
|
||||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||||
import { ViewRepository } from 'src/repositories/view-repository';
|
import { ViewRepository } from 'src/repositories/view-repository';
|
||||||
|
import { UserTable } from 'src/tables/user.table';
|
||||||
import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
|
import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
|
||||||
import { newUuid } from 'test/small.factory';
|
import { newUuid } from 'test/small.factory';
|
||||||
import { automock } from 'test/utils';
|
import { automock } from 'test/utils';
|
||||||
@ -57,7 +58,7 @@ class CustomWritable extends Writable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Asset = Partial<Insertable<Assets>>;
|
type Asset = Partial<Insertable<Assets>>;
|
||||||
type User = Partial<Insertable<Users>>;
|
type User = Partial<Insertable<UserTable>>;
|
||||||
type Session = Omit<Insertable<Sessions>, 'token'> & { token?: string };
|
type Session = Omit<Insertable<Sessions>, 'token'> & { token?: string };
|
||||||
type Partner = Insertable<Partners>;
|
type Partner = Insertable<Partners>;
|
||||||
|
|
||||||
@ -103,7 +104,7 @@ export class TestFactory {
|
|||||||
|
|
||||||
static user(user: User = {}) {
|
static user(user: User = {}) {
|
||||||
const userId = user.id || newUuid();
|
const userId = user.id || newUuid();
|
||||||
const defaults: Insertable<Users> = {
|
const defaults: Insertable<UserTable> = {
|
||||||
email: `${userId}@immich.cloud`,
|
email: `${userId}@immich.cloud`,
|
||||||
name: `User ${userId}`,
|
name: `User ${userId}`,
|
||||||
deletedAt: null,
|
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'),
|
createdAt: new Date('2021-01-01'),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
tags: [],
|
|
||||||
assets: [],
|
assets: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
quotaSizeInBytes: null,
|
quotaSizeInBytes: null,
|
||||||
@ -36,7 +35,6 @@ export const userStub = {
|
|||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
tags: [],
|
|
||||||
assets: [],
|
assets: [],
|
||||||
metadata: [
|
metadata: [
|
||||||
{
|
{
|
||||||
@ -62,7 +60,6 @@ export const userStub = {
|
|||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
tags: [],
|
|
||||||
assets: [],
|
assets: [],
|
||||||
quotaSizeInBytes: null,
|
quotaSizeInBytes: null,
|
||||||
quotaUsageInBytes: 0,
|
quotaUsageInBytes: 0,
|
||||||
@ -81,7 +78,6 @@ export const userStub = {
|
|||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
tags: [],
|
|
||||||
assets: [],
|
assets: [],
|
||||||
quotaSizeInBytes: null,
|
quotaSizeInBytes: null,
|
||||||
quotaUsageInBytes: 0,
|
quotaUsageInBytes: 0,
|
||||||
@ -100,7 +96,6 @@ export const userStub = {
|
|||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
tags: [],
|
|
||||||
assets: [],
|
assets: [],
|
||||||
quotaSizeInBytes: null,
|
quotaSizeInBytes: null,
|
||||||
quotaUsageInBytes: 0,
|
quotaUsageInBytes: 0,
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user