From cfba6adf56fb61a8015a75bb05edd149eb1b71bf Mon Sep 17 00:00:00 2001 From: "Marc J. Schmidt" Date: Sat, 22 Jun 2024 01:18:27 +0200 Subject: [PATCH] feat(orm): new selector API, still work in progress 3 Don't use TypeFunction.function per default to make GC work correctly --- packages/orm/index.ts | 2 - packages/orm/src/database-adapter.ts | 7 - packages/orm/src/database-session.ts | 10 +- packages/orm/src/database.ts | 30 +- packages/orm/src/event.ts | 17 +- packages/orm/src/memory-db.ts | 85 +- packages/orm/src/plugin/log-plugin.ts | 37 +- packages/orm/src/plugin/soft-delete-plugin.ts | 106 +- packages/orm/src/query-filter.ts | 156 --- packages/orm/src/query.ts | 1151 ----------------- packages/orm/src/select.ts | 171 ++- packages/orm/src/type.ts | 6 +- packages/orm/tests/dql.spec.ts | 85 +- packages/orm/tests/log-plugin.spec.ts | 8 +- packages/orm/tests/soft-delete.spec.ts | 82 +- packages/sql/src/sql-adapter.ts | 295 +---- packages/type/src/reflection/processor.ts | 23 +- packages/type/src/reflection/reflection.ts | 54 +- packages/type/src/reflection/type.ts | 9 +- packages/type/tests/integration2.spec.ts | 3 +- packages/type/tests/processor.spec.ts | 2 +- packages/type/tests/type.spec.ts | 6 +- packages/type/tests/validation.spec.ts | 39 +- 23 files changed, 512 insertions(+), 1872 deletions(-) delete mode 100644 packages/orm/src/query-filter.ts delete mode 100644 packages/orm/src/query.ts diff --git a/packages/orm/index.ts b/packages/orm/index.ts index 34ce0a400..134ed5d9b 100644 --- a/packages/orm/index.ts +++ b/packages/orm/index.ts @@ -14,8 +14,6 @@ export * from './src/database-session.js'; export * from './src/database-registry.js'; export * from './src/identity-map.js'; export * from './src/formatter.js'; -export * from './src/query.js'; -export * from './src/query-filter.js'; export * from './src/utils.js'; export * from './src/memory-db.js'; export * from './src/type.js'; diff --git a/packages/orm/src/database-adapter.ts b/packages/orm/src/database-adapter.ts index 9a220a1cd..be7ef1612 100644 --- a/packages/orm/src/database-adapter.ts +++ b/packages/orm/src/database-adapter.ts @@ -10,7 +10,6 @@ import { OrmEntity } from './type.js'; import { - AbstractClassType, arrayRemoveItem, ClassType, getClassName, @@ -23,20 +22,14 @@ import { isSameType, ItemChanges, PrimaryKeyFields, - ReceiveType, ReflectionClass, ReflectionKind, stringifyType, Type, } from '@deepkit/type'; -import { Query } from './query.js'; import { DatabaseSession, DatabaseTransaction } from './database-session.js'; import { SelectorResolver } from './select.js'; -export abstract class DatabaseAdapterQueryFactory { - abstract createQuery(type?: ReceiveType | ClassType | AbstractClassType | ReflectionClass): Query; -} - export interface DatabasePersistenceChangeSet { changes: ItemChanges; item: T; diff --git a/packages/orm/src/database-session.ts b/packages/orm/src/database-session.ts index 116f01aa1..26e04410c 100644 --- a/packages/orm/src/database-session.ts +++ b/packages/orm/src/database-session.ts @@ -39,7 +39,7 @@ import { DatabaseLogger } from './logger.js'; import { Stopwatch } from '@deepkit/stopwatch'; import { EventDispatcher, EventDispatcherInterface, EventToken } from '@deepkit/event'; import { DatabasePluginRegistry } from './plugin/plugin.js'; -import { query, Query2, SelectorInferredState, SelectorRefs } from './select.js'; +import { query, Query2, SelectorInferredState, SelectorRefs, SelectorState, singleQuery } from './select.js'; let SESSION_IDS = 0; @@ -355,9 +355,13 @@ export class DatabaseSession // }; } - query2 | ((main: SelectorRefs, ...args: SelectorRefs[]) => R | undefined)>(cbOrQ?: Q): Query2 { + singleQuery(classType: ClassType, cb?: (main: SelectorRefs) => R | undefined): Query2 { + return this.query2(singleQuery(classType, cb)); + } + + query2 | SelectorState | ((main: SelectorRefs, ...args: SelectorRefs[]) => R | undefined)>(cbOrQ?: Q): Query2 { if (!cbOrQ) throw new Error('Query2 needs a callback or query object'); - const state: SelectorInferredState = isFunction(cbOrQ) ? query(cbOrQ) : cbOrQ; + const state: SelectorInferredState = isFunction(cbOrQ) ? query(cbOrQ) : 'state' in cbOrQ ? cbOrQ : { state: cbOrQ }; return new Query2(state.state, this, this.adapter.createSelectorResolver(this)); } diff --git a/packages/orm/src/database.ts b/packages/orm/src/database.ts index 93dd048c0..4b871db6b 100644 --- a/packages/orm/src/database.ts +++ b/packages/orm/src/database.ts @@ -24,7 +24,6 @@ import { import { DatabaseAdapter, DatabaseEntityRegistry, MigrateOptions } from './database-adapter.js'; import { DatabaseSession } from './database-session.js'; import { DatabaseLogger } from './logger.js'; -import { Query } from './query.js'; import { getReference } from './reference.js'; import { OrmEntity } from './type.js'; import { VirtualForeignKeyConstraint } from './virtual-foreign-key-constraint.js'; @@ -32,7 +31,8 @@ import { Stopwatch } from '@deepkit/stopwatch'; import { getClassState, getInstanceState, getNormalizedPrimaryKey } from './identity-map.js'; import { EventDispatcher, EventDispatcherUnsubscribe, EventListenerCallback, EventToken } from '@deepkit/event'; import { DatabasePlugin, DatabasePluginRegistry } from './plugin/plugin.js'; -import { Query2, SelectorInferredState, SelectorRefs } from './select.js'; +import { Query2, SelectorInferredState, SelectorRefs, singleQuery } from './select.js'; +import { onDeletePost, onPatchPost } from './event.js'; /** * Hydrates not completely populated item and makes it completely accessible. @@ -83,13 +83,14 @@ function setupVirtualForeignKey(database: Database, virtualForeignKeyConstraint: database.listen(DatabaseSession.onUpdatePost, async (event) => { await virtualForeignKeyConstraint.onUoWUpdate(event); }); - database.listen(Query.onPatchPost, async (event) => { + database.listen(onPatchPost, async (event) => { await virtualForeignKeyConstraint.onQueryPatch(event); }); - database.listen(Query.onDeletePost, async (event) => { + database.listen(onDeletePost, async (event) => { await virtualForeignKeyConstraint.onQueryDelete(event); }); } + /** * Database abstraction. Use createSession() to create a work session with transaction support. * @@ -129,9 +130,9 @@ export class Database { * await session.commit(); //only necessary when you changed items received by this session * ``` */ - // public readonly query: ReturnType['createQuery']; - // - // public readonly raw: ReturnType['create']; + // public readonly query: ReturnType['createQuery']; + // + // public readonly raw: ReturnType['create']; protected virtualForeignKeyConstraint: VirtualForeignKeyConstraint = new VirtualForeignKeyConstraint(this); @@ -143,7 +144,7 @@ export class Database { constructor( public readonly adapter: ADAPTER, - schemas: (Type | ClassType | ReflectionClass)[] = [] + schemas: (Type | ClassType | ReflectionClass)[] = [], ) { this.entityRegistry.add(...schemas); if (Database.registry) Database.registry.push(this); @@ -178,6 +179,12 @@ export class Database { throw new Error('Deprecated'); } + singleQuery(classType: ClassType, cb?: (main: SelectorRefs) => R | undefined): Query2 { + const session = this.createSession(); + session.withIdentityMap = false; + return session.query2(singleQuery(classType, cb)); + } + query2 | ((main: SelectorRefs, ...args: SelectorRefs[]) => R | undefined)>(cbOrQ?: Q): Query2 { const session = this.createSession(); session.withIdentityMap = false; @@ -419,9 +426,10 @@ export class ActiveRecord { await db.remove(this); } - public static query(this: T): Query> { - return this.getDatabase().query(this); - } + // todo implement query2 + // public static query(this: T): Query> { + // return this.getDatabase().query(this); + // } public static reference(this: T, primaryKey: any | PrimaryKeyFields>): InstanceType { return this.getDatabase().getReference(this, primaryKey) as InstanceType; diff --git a/packages/orm/src/event.ts b/packages/orm/src/event.ts index abd62c35d..d8e50137b 100644 --- a/packages/orm/src/event.ts +++ b/packages/orm/src/event.ts @@ -16,6 +16,10 @@ import type { DatabasePersistenceChangeSet } from './database-adapter.js'; import type { DatabaseSession } from './database-session.js'; import type { DeleteResult, PatchResult } from './type.js'; import { OrmEntity } from './type.js'; +import { SelectorState } from './select.js'; + +export class ItemNotFound extends Error { +} export class DatabaseEvent extends BaseEvent { stopped = false; @@ -78,8 +82,7 @@ export class QueryDatabaseEvent extends DatabaseEvent { constructor( public readonly databaseSession: DatabaseSession, public readonly classSchema: ReflectionClass, - // public query: Query2, - public query: any, //TODO change back + public readonly query: SelectorState, ) { super(); } @@ -94,8 +97,7 @@ export class DatabaseErrorEvent extends DatabaseEvent { public readonly error: Error, public readonly databaseSession: DatabaseSession, public readonly classSchema?: ReflectionClass, - // public readonly query?: Query2, - public readonly query?: any, //TODO change back + public readonly query?: SelectorState, ) { super(); } @@ -123,13 +125,11 @@ export class DatabaseErrorUpdateEvent extends DatabaseErrorEvent { */ export const onDatabaseError = new EventToken('database.error'); - export class QueryDatabaseDeleteEvent extends DatabaseEvent { constructor( public readonly databaseSession: DatabaseSession, public readonly classSchema: ReflectionClass, - // public query: Query2, - public query: any, //TODO change back + public readonly query: SelectorState, public readonly deleteResult: DeleteResult, ) { super(); @@ -144,8 +144,7 @@ export class QueryDatabasePatchEvent extends DatabaseEvent { constructor( public readonly databaseSession: DatabaseSession, public readonly classSchema: ReflectionClass, - // public query: Query2, - public query: any, //TODO change back + public readonly query: SelectorState, public readonly patch: Changes, public readonly patchResult: PatchResult, ) { diff --git a/packages/orm/src/memory-db.ts b/packages/orm/src/memory-db.ts index 4d88fcdbb..aa69b6cb2 100644 --- a/packages/orm/src/memory-db.ts +++ b/packages/orm/src/memory-db.ts @@ -53,25 +53,25 @@ function sortDesc(a: any, b: any) { return 0; } -type Accessor = (record: any) => any; +type Accessor = (record: any, params: any[]) => any; export type MemoryOpRegistry = { [tag: symbol]: (expression: OpExpression) => Accessor }; export const memoryOps: MemoryOpRegistry = { [eq.id](expression: OpExpression) { const [a, b] = expression.args.map(e => buildAccessor(e)); - return (record: any) => a(record) === b(record); + return (record: any, params: any[]) => a(record, params) === b(record, params); }, [and.id](expression: OpExpression) { const lines = expression.args.map(e => buildAccessor(e)); - return (record: any) => lines.every(v => v(record)); + return (record: any, params: any[]) => lines.every(v => v(record, params)); }, [where.id](expression: OpExpression) { const lines = expression.args.map(e => buildAccessor(e)); - return (record: any) => lines.every(v => v(record)); + return (record: any, params: any[]) => lines.every(v => v(record, params)); }, }; -function buildAccessor(op: OpExpression | SelectorProperty | unknown): Accessor { +function buildAccessor(op: OpExpression | SelectorProperty | number): Accessor { if (isOp(op)) { const fn = memoryOps[op[opTag].id]; if (!fn) throw new Error(`No memory op registered for ${op[opTag].id.toString()}`); @@ -79,50 +79,64 @@ function buildAccessor(op: OpExpression | SelectorProperty | unknown): Accessor } if (isProperty(op)) { - return (record: any) => { + return (record: any, params: any[]) => { //todo: handle if selector of joined table // and deep json path return record[op.name]; }; } - return () => op; + return (record: any, params: any[]) => { + return params[op]; + } } -function sort(items: any[], accessor: Accessor, sortFn: typeof sortAsc | typeof sortAsc): void { - items.sort((a, b) => { - return sortFn(accessor(a), accessor(b)); - }); -} +export type MemoryFinder = (records: T[], params: any[]) => T[]; + +export function buildFinder(model: SelectorState, cache: { [id: string]: MemoryFinder }): MemoryFinder { + const cacheId = model.schema.type.id + '_' + model.where?.tree.id + '_' + model.orderBy?.map(v => v.a.tree.id).join(':'); + let finder = cache[cacheId]; + if (finder) return finder; + + const whereCheck = model.where ? buildAccessor(model.where) : () => true; + const offset = model.offset || 0; + const limit = model.limit; + const limitCheck: (m: number) => boolean = 'undefined' === typeof limit ? () => false : ((m) => m >= limit); + + const orderBy = model.orderBy ? model.orderBy.map(v => { + const accessor = buildAccessor(v.a); + const direction = v.direction === 'asc' ? sortAsc : sortDesc; + return (records: T[], params: any[]) => records.sort((a, b) => direction(accessor(a, params), accessor(b, params))); + }) : []; + + finder = (records: T[], params: any[]) => { + const filtered: T[] = []; + let matched = 0; + for (const record of records) { + if (limitCheck(matched)) break; + if (whereCheck(record, params)) { + matched++; + if (matched <= offset) continue; + filtered.push(record); + } + } + + for (const order of orderBy) { + order(filtered, params); + } + + return filtered; + }; -function filterWhere(items: T[], where: OpExpression): T[] { - const accessor = buildAccessor(where); - console.log('accessor', accessor.toString()); - return items.filter(v => !!accessor(v)); + return cache[cacheId] = finder; } const find = (adapter: MemoryDatabaseAdapter, classSchema: ReflectionClass, model: SelectorState): T[] => { const rawItems = [...adapter.getStore(classSchema).items.values()]; const deserializer = getSerializeFunction(classSchema.type, memorySerializer.deserializeRegistry); const items = rawItems.map(v => deserializer(v)); - - console.log(items); - let filtered = model.where ? filterWhere(items, model.where) : items; - - if (model.orderBy) { - for (const order of model.orderBy) { - sort(filtered, buildAccessor(order.a), order.direction === 'asc' ? sortAsc : sortDesc); - } - } - - if (model.offset && model.limit) { - filtered = filtered.slice(model.offset, model.offset + model.limit); - } else if (model.limit) { - filtered = filtered.slice(0, model.limit); - } else if (model.offset) { - filtered = filtered.slice(model.offset); - } - return filtered; + const finder = buildFinder(model, adapter.finderCache); + return finder(items, model.params); }; const remove = (adapter: MemoryDatabaseAdapter, classSchema: ReflectionClass, toDelete: T[]) => { @@ -136,7 +150,7 @@ const remove = (adapter: MemoryDatabaseAdapter, classSchema: ReflectionClass< class Resolver extends SelectorResolver { get adapter() { - return this.session.adapter as any as MemoryDatabaseAdapter; + return this.session.adapter as MemoryDatabaseAdapter; } protected createFormatter(state: SelectorState, withIdentityMap: boolean = false) { @@ -278,6 +292,7 @@ export class MemoryPersistence extends DatabasePersistence { export class MemoryDatabaseAdapter extends DatabaseAdapter { protected store = new Map>(); + finderCache: { [id: string]: MemoryFinder } = {}; async migrate(options: MigrateOptions, entityRegistry: DatabaseEntityRegistry) { } diff --git a/packages/orm/src/plugin/log-plugin.ts b/packages/orm/src/plugin/log-plugin.ts index e1746221f..4d9046838 100644 --- a/packages/orm/src/plugin/log-plugin.ts +++ b/packages/orm/src/plugin/log-plugin.ts @@ -11,10 +11,18 @@ import { ClassType } from '@deepkit/core'; import { DatabaseSession } from '../database-session.js'; import { Database } from '../database.js'; -import { Query } from '../query.js'; -import { AutoIncrement, entity, InlineRuntimeType, PrimaryKey, PrimaryKeyFields, ReflectionClass, ResetAnnotation } from '@deepkit/type'; +import { + AutoIncrement, + entity, + InlineRuntimeType, + PrimaryKey, + PrimaryKeyFields, + ReflectionClass, + ResetAnnotation, +} from '@deepkit/type'; import { DatabasePlugin } from './plugin.js'; -import { OrmEntity } from '../type.js'; +import { onDeletePost, onPatchPost } from '../event.js'; +import { currentState } from '../select.js'; export enum LogType { Added, @@ -60,19 +68,8 @@ export class LogSession { } } -export class LogQuery extends Query { - logAuthor?: any; - - byLogAuthor(author?: any): this { - this.logAuthor = author; - return this; - } - - clone(): this { - const c = super.clone(); - c.logAuthor = this.logAuthor; - return c; - } +export function setLogAuthor(author: string) { + currentState().data.logAuthor = author; } interface LoginPluginOptions { @@ -154,25 +151,25 @@ export class LogPlugin implements DatabasePlugin { } } - database.listen(Query.onDeletePost, async event => { + database.listen(onDeletePost, async event => { if (!this.isIncluded(event.classSchema)) return; if (this.options.disableDelete) return; for (const primaryKey of event.deleteResult.primaryKeys) { const log = this.createLog(event.databaseSession, LogType.Deleted, event.classSchema, primaryKey); - if (Query.is(event.query, LogQuery)) log.author = event.query.logAuthor as any; + log.author = event.query.data.logAuthor || ''; event.databaseSession.add(log); } await event.databaseSession.commit(); }); - database.listen(Query.onPatchPost, async event => { + database.listen(onPatchPost, async event => { if (!this.isIncluded(event.classSchema)) return; if (this.options.disableUpdate) return; for (const primaryKey of event.patchResult.primaryKeys) { const log = this.createLog(event.databaseSession, LogType.Updated, event.classSchema, primaryKey); - if (Query.is(event.query, LogQuery)) log.author = event.query.logAuthor as any; + log.author = event.query.data.logAuthor || ''; log.changedFields = event.patch.fieldNames; event.databaseSession.add(log); } diff --git a/packages/orm/src/plugin/soft-delete-plugin.ts b/packages/orm/src/plugin/soft-delete-plugin.ts index eee29bfa8..ae1d8502e 100644 --- a/packages/orm/src/plugin/soft-delete-plugin.ts +++ b/packages/orm/src/plugin/soft-delete-plugin.ts @@ -13,10 +13,11 @@ import { EventDispatcherUnsubscribe } from '@deepkit/event'; import { DatabaseSession } from '../database-session.js'; import { Database } from '../database.js'; import { DatabaseAdapter } from '../database-adapter.js'; -import { Query } from '../query.js'; import { OrmEntity } from '../type.js'; import { ReflectionClass } from '@deepkit/type'; import { DatabasePlugin } from './plugin.js'; +import { onDeletePre, onFind, onPatchPre } from '../event.js'; +import { applySelect, currentState, eq, notEqual, Select, SelectorState, where } from '../select.js'; interface SoftDeleteEntity extends OrmEntity { deletedAt?: Date; @@ -60,60 +61,45 @@ export class SoftDeleteSession { } } -export class SoftDeleteQuery extends Query { - includeSoftDeleted: boolean = false; - setDeletedBy?: T['deletedBy']; - - clone(): this { - const c = super.clone(); - c.includeSoftDeleted = this.includeSoftDeleted; - c.setDeletedBy = this.setDeletedBy; - return c; - } +interface SoftDeleteData { + includeSoftDeleted?: boolean; + // softDeleteIncludeHardDelete?: boolean; + deletedBy?: any; + enableHardDelete?: boolean; +} - /** - * Enables fetching, updating, and deleting of soft-deleted records. - */ - withSoftDeleted(): this { - const m = this.clone(); - m.includeSoftDeleted = true; - return m; - } +function getSoftDeleteData(state: SelectorState): SoftDeleteData { + return state.data.softDelete ||= {}; +} - /** - * Includes only soft deleted records. - */ - isSoftDeleted(): this { - const m = this.clone(); - m.includeSoftDeleted = true; - return m.filterField('deletedAt', { $ne: undefined }); - } +/** + * Includes soft=deleted records additional to the normal records. + */ +export function includeSoftDeleted() { + getSoftDeleteData(currentState()).includeSoftDeleted = true; +} - deletedBy(value: T['deletedBy']): this { - const c = this.clone(); - c.setDeletedBy = value; - return c; - } +/** + * Includes only soft-deleted records. (normal records are excluded) + */ +export function includeOnlySoftDeleted(model: Select<{ deletedAt: Date }>) { + includeSoftDeleted(); + where(notEqual(model.deletedAt, undefined)); +} - async restoreOne() { - const patch = { [deletedAtName]: undefined } as Partial; - if (this.classSchema.hasProperty('deletedBy')) patch['deletedBy'] = undefined; - await this.withSoftDeleted().patchOne(patch); - } +export function setDeletedBy(deletedBy: string) { + getSoftDeleteData(currentState()).deletedBy = deletedBy; +} - async restoreMany() { - const patch = { [deletedAtName]: undefined } as Partial; - if (this.classSchema.hasProperty('deletedBy')) patch['deletedBy'] = undefined; - await this.withSoftDeleted().patchMany(patch); - } +//todo: how to handle this? +export function restoreOne() { +} - async hardDeleteOne() { - await this.withSoftDeleted().deleteOne(); - } +export function restoreMany() { +} - async hardDeleteMany() { - await this.withSoftDeleted().deleteMany(); - } +export function enableHardDelete() { + getSoftDeleteData(currentState()).enableHardDelete = true; } export class SoftDeletePlugin implements DatabasePlugin { @@ -165,33 +151,37 @@ export class SoftDeletePlugin implements DatabasePlugin { function queryFilter(event: { classSchema: ReflectionClass, query: any }) { //this is for each query method: count, find, findOne(), etc. - //we don't change SoftDeleteQuery instances as they operate on the raw records without filter - if (Query.is(event.query, SoftDeleteQuery) && event.query.includeSoftDeleted === true) return; + //when includeSoftDeleted is set, we don't want to filter out the deleted records + if (getSoftDeleteData(event.query).includeSoftDeleted) return; if (event.classSchema !== schema) return; //do nothing //attach the filter to exclude deleted records + applySelect(event.query, (q: Select<{ [deletedAtName]: any }>) => { + where(eq(q[deletedAtName], undefined)); + }); event.query = event.query.filterField(deletedAtName, undefined); } - const queryFetch = this.getDatabase().listen(Query.onFind, queryFilter); - const queryPatch = this.getDatabase().listen(Query.onPatchPre, queryFilter); + const queryFetch = this.getDatabase().listen(onFind, queryFilter); + const queryPatch = this.getDatabase().listen(onPatchPre, queryFilter); - const queryDelete = this.getDatabase().listen(Query.onDeletePre, async event => { + const queryDelete = this.getDatabase().listen(onDeletePre, async event => { if (event.classSchema !== schema) return; //do nothing - //we don't change SoftDeleteQuery instances as they operate on the raw records without filter - if (Query.is(event.query, SoftDeleteQuery) && event.query.includeSoftDeleted === true) return; + //when includeSoftDeleted is set, we don't want to filter out the deleted records + if (getSoftDeleteData(event.query).includeSoftDeleted) return; //stop actual query delete query event.stop(); const patch = { [deletedAtName]: new Date } as Partial; - if (hasDeletedBy && Query.is(event.query, SoftDeleteQuery) && event.query.setDeletedBy !== undefined) { - patch.deletedBy = event.query.setDeletedBy; + const deletedBy = getSoftDeleteData(event.query).deletedBy; + if (hasDeletedBy && deletedBy !== undefined) { + patch.deletedBy = deletedBy; } - await event.query.patchMany(patch); + await event.databaseSession.query2(event.query).patchMany(patch); }); const uowDelete = this.getDatabase().listen(DatabaseSession.onDeletePre, async event => { diff --git a/packages/orm/src/query-filter.ts b/packages/orm/src/query-filter.ts deleted file mode 100644 index c5e5b202b..000000000 --- a/packages/orm/src/query-filter.ts +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Deepkit Framework - * Copyright (C) 2021 Deepkit UG, Marc J. Schmidt - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the MIT License. - * - * You should have received a copy of the MIT License along with this program. - */ - -import { ReflectionClass, ReflectionKind, ReflectionProperty } from '@deepkit/type'; -import { ClassType, isArray, isPlainObject } from '@deepkit/core'; -import { FilterQuery } from './query.js'; - -export type Converter = (convertClass: ReflectionClass, path: string, value: any) => any; -export type QueryFieldNames = { [name: string]: boolean }; -export type QueryCustomFields = { [name: string]: (name: string, value: any, fieldNames: QueryFieldNames, converter: Converter) => any }; - -export function exportQueryFilterFieldNames(reflectionClass: ReflectionClass, filter: FilterQuery): string[] { - const filterFields: QueryFieldNames = {}; - convertQueryFilter(reflectionClass, filter, (c, p, v) => v, filterFields); - return Object.keys(filterFields); -} - -export function replaceQueryFilterParameter(reflectionClass: ReflectionClass, filter: FilterQuery, parameters: { [name: string]: any }): any { - return convertQueryFilter(reflectionClass, filter, (convertClass: ReflectionClass, path: string, value: any) => { - return value; - }, {}, { - $parameter: (name, value) => { - if (!(value in parameters)) { - throw new Error(`Parameter ${value} not defined in ${reflectionClass.getClassName()} query.`); - } - return parameters[value]; - } - }); -} - -function convertProperty( - schema: ReflectionClass, - property: ReflectionProperty, - fieldValue: any, - name: string, - converter: Converter, - fieldNamesMap: QueryFieldNames = {}, - customMapping: QueryCustomFields = {}, -) { - if (isPlainObject(fieldValue)) { - fieldValue = { ...fieldValue }; - - for (const key in fieldValue) { - if (!fieldValue.hasOwnProperty(key)) continue; - - let value: any = (fieldValue as any)[key]; - - if (key[0] !== '$') { - fieldValue = converter(schema, name, fieldValue); - break; - } else { - //we got a mongo query, e.g. `{$all: []}` as fieldValue - if (customMapping[key]) { - const mappingResult = customMapping[key](name, value, fieldNamesMap, converter); - if (mappingResult) { - fieldValue = mappingResult; - break; - } else { - fieldValue = undefined; - break; - } - } else if (key === '$not') { - fieldValue[key] = convertProperty(schema, property, value, name, converter, fieldNamesMap, customMapping); - } else if (key === '$all') { - if (isArray(value[0])) { - //Nested Array - for (const nestedArray of value) { - for (let i = 0; i < nestedArray.length; i++) { - nestedArray[i] = converter(schema, name + '.' + i, nestedArray[i]); - } - } - } else if (isArray(value)) { - for (let i = 0; i < value.length; i++) { - value[i] = converter(schema, name + '.' + i, value[i]); - } - } - } else if (key === '$in' || key === '$nin') { - fieldNamesMap[name] = true; - if (isArray(value)) { - (fieldValue as any)[key] = value.map(v => converter(schema, name, v)); - } else { - (fieldValue as any)[key] = []; - } - } else if (key === '$text' || key === '$exists' || key === '$mod' || key === '$size' || key === '$type' - || key === '$regex' || key === '$where' || key === '$elemMatch') { - fieldNamesMap[name] = true; - } else { - fieldNamesMap[name] = true; - if (property.getKind() === ReflectionKind.array && !isArray(value)) { - //implicit array conversion - (fieldValue as any)[key] = converter(schema, name + '.0', value); - } else { - (fieldValue as any)[key] = converter(schema, name, value); - } - } - } - } - } else { - fieldNamesMap[name] = true; - if (property.getKind() === ReflectionKind.array && !isArray(fieldValue)) { - //implicit array conversion - return converter(schema, name + '.0', fieldValue); - } else { - return converter(schema, name, fieldValue); - } - } - - return fieldValue; -} - -export function convertQueryFilter>( - classType: ClassType | ReflectionClass, - filter: Q, - converter: Converter, - fieldNamesMap: QueryFieldNames = {}, - customMapping: QueryCustomFields = {}, -): Q { - const result: { [i: string]: any } = {}; - const schema = ReflectionClass.from(classType); - - for (const key in filter) { - if (!filter.hasOwnProperty(key)) continue; - - let fieldValue: any = filter[key]; - const property = schema.getPropertyOrUndefined(key); - - //when i is a reference, we rewrite it to the foreign key name - let targetI = property && property.isReference() ? property.getForeignKeyName() : key; - - if (key[0] === '$' && isArray(fieldValue)) { - result[key] = (fieldValue as any[]).map(v => convertQueryFilter(classType, v, converter, fieldNamesMap, customMapping)); - continue; - } - - if (property) { - if ((filter[key] as any) instanceof RegExp) { - fieldValue = filter[key]; - } else { - fieldValue = convertProperty(schema, property, filter[key], key, converter, fieldNamesMap, customMapping); - } - } - - if (fieldValue !== undefined) { - result[targetI] = fieldValue; - } - } - - return result as Q; -} diff --git a/packages/orm/src/query.ts b/packages/orm/src/query.ts deleted file mode 100644 index f484202f1..000000000 --- a/packages/orm/src/query.ts +++ /dev/null @@ -1,1151 +0,0 @@ -/* - * Deepkit Framework - * Copyright (C) 2021 Deepkit UG, Marc J. Schmidt - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the MIT License. - * - * You should have received a copy of the MIT License along with this program. - */ - -import { ClassType, EmitterEvent, empty, EventEmitter } from '@deepkit/core'; -import { - assertType, - Changes, - ChangesInterface, - DeepPartial, - getSimplePrimaryKeyHashGenerator, - PrimaryKeyFields, - PrimaryKeyType, - ReferenceFields, - ReflectionClass, - ReflectionKind, - ReflectionProperty, - resolveForeignReflectionClass, -} from '@deepkit/type'; -import { DatabaseAdapter } from './database-adapter.js'; -import { DatabaseSession } from './database-session.js'; -import { - DatabaseErrorEvent, - onDatabaseError, - QueryDatabaseDeleteEvent, - QueryDatabaseEvent, - QueryDatabasePatchEvent, -} from './event.js'; -import { DeleteResult, OrmEntity, PatchResult } from './type.js'; -import { FieldName, FlattenIfArray, Replace, Resolve } from './utils.js'; -import { FrameCategory } from '@deepkit/stopwatch'; -import { EventToken } from '@deepkit/event'; - -export type SORT_ORDER = 'asc' | 'desc' | any; -export type Sort = { [P in keyof T & string]?: ORDER }; - -export interface DatabaseJoinModel { - //this is the parent classSchema, the foreign classSchema is stored in `query` - classSchema: ReflectionClass, - propertySchema: ReflectionProperty, - type: 'left' | 'inner' | string, - populate: boolean, - //defines the field name under which the database engine populated the results. - //necessary for the formatter to pick it up, convert and set correctly the real field name - as?: string, - query: BaseQuery, - foreignPrimaryKey: ReflectionProperty, -} - -export type BaseQuerySelector = { - $eq?: T; - $gt?: T; - $gte?: T; - $in?: T[]; - $lt?: T; - $lte?: T; - $ne?: T; - $nin?: T[]; - $like?: T; -} - -export type QuerySelector = BaseQuerySelector & { - // vector search - $l2Distance?: { query: number[]; filter: BaseQuerySelector; }; - $innerProduct?: { query: number[]; filter: BaseQuerySelector; }; - $cosineDistance?: { query: number[]; filter: BaseQuerySelector; }; - - // Logical - $not?: T extends string ? (QuerySelector | RegExp) : QuerySelector; - $regex?: T extends string ? (RegExp | string) : never; - - //special deepkit/type type - $parameter?: string; -}; - -export type RootQuerySelector = { - $and?: Array>; - $nor?: Array>; - $or?: Array>; - // we could not find a proper TypeScript generic to support nested queries e.g. 'user.friends.name' - // this will mark all unrecognized properties as any (including nested queries) - [deepPath: string]: any; -}; - -type RegExpForString = T extends string ? (RegExp | T) : T; -type MongoAltQuery = T extends Array ? (T | RegExpForString) : RegExpForString; -export type Condition = MongoAltQuery | QuerySelector>; - -export type FilterQuery = { - [P in keyof T & string]?: Condition; -} & - RootQuerySelector; - -export class DatabaseQueryModel = FilterQuery, SORT extends Sort = Sort> { - public withIdentityMap: boolean = true; - public withChangeDetection: boolean = true; - public filter?: FILTER; - public having?: FILTER; - public groupBy: Set = new Set(); - public for?: 'update' | 'share'; - public aggregate = new Map(); - public select: Set = new Set(); - public lazyLoad: Set = new Set(); - public joins: DatabaseJoinModel[] = []; - public skip?: number; - public itemsPerPage: number = 50; - public limit?: number; - public parameters: { [name: string]: any } = {}; - public sort?: SORT; - public readonly change = new EventEmitter(); - public returning: (keyof T & string)[] = []; - public batchSize?: number; - - /** - * The adapter name is set by the database adapter when the query is created. - */ - public adapterName: string = ''; - - isLazyLoaded(field: string): boolean { - return this.lazyLoad.has(field); - } - - changed(): void { - this.change.emit(new EmitterEvent); - } - - hasSort(): boolean { - return this.sort !== undefined; - } - - /** - * Whether limit/skip is activated. - */ - hasPaging(): boolean { - return this.limit !== undefined || this.skip !== undefined; - } - - setParameters(parameters: { [name: string]: any }) { - for (const [i, v] of Object.entries(parameters)) { - this.parameters[i] = v; - } - } - - clone(parentQuery: BaseQuery): this { - const constructor = this.constructor as ClassType; - const m = new constructor(); - m.filter = this.filter && { ...this.filter }; - m.having = this.having && { ...this.having }; - m.withIdentityMap = this.withIdentityMap; - m.select = new Set(this.select); - m.groupBy = new Set(this.groupBy); - m.lazyLoad = new Set(this.lazyLoad); - m.for = this.for; - m.batchSize = this.batchSize; - m.adapterName = this.adapterName; - m.aggregate = new Map(this.aggregate); - m.parameters = { ...this.parameters }; - - m.joins = this.joins.map((v) => { - return { - ...v, - query: v.query.clone(), - }; - }); - - for (const join of m.joins) { - join.query.model.parameters = m.parameters; - } - - m.skip = this.skip; - m.limit = this.limit; - m.returning = this.returning.slice(0); - m.itemsPerPage = this.itemsPerPage; - m.sort = this.sort ? { ...this.sort } : undefined; - - return m; - } - - /** - * Whether only a subset of fields are selected. - */ - isPartial() { - return this.select.size > 0 || this.groupBy.size > 0 || this.aggregate.size > 0; - } - - /** - * Whether only a subset of fields are selected. - */ - isAggregate() { - return this.groupBy.size > 0 || this.aggregate.size > 0; - } - - getFirstSelect() { - return this.select.values().next().value; - } - - isSelected(field: string): boolean { - return this.select.has(field); - } - - hasJoins() { - return this.joins.length > 0; - } - - hasParameters(): boolean { - return !empty(this.parameters); - } -} - -export class ItemNotFound extends Error { -} - -type FindEntity = FlattenIfArray> extends infer V ? V extends OrmEntity ? V : OrmEntity : OrmEntity; - -export interface QueryClassType { - create(query: BaseQuery): QueryClassType; -} - -export type Configure = (query: BaseQuery) => BaseQuery | void; - -export class BaseQuery { - //for higher kinded type for selected fields - _!: () => T; - - protected createModel() { - return new DatabaseQueryModel, Sort>(); - } - - public model: DatabaseQueryModel; - - constructor( - public readonly classSchema: ReflectionClass, - model?: DatabaseQueryModel, - ) { - this.model = model || this.createModel(); - } - - /** - * Returns a new query with the same model transformed by the modifier. - * - * This allows to use more dynamic query composition functions. - * - * To support joins queries `BaseQuery` is necessary as query type. - * - * @example - * ```typescript - * function joinFrontendData(query: BaseQuery) { - * return query - * .useJoinWith('images').select('sort').end() - * .useJoinWith('brand').select('id', 'name', 'website').end() - * } - * - * const products = await database.query(Product).use(joinFrontendData).find(); - * ``` - * @reflection never - */ - use(modifier: (query: Q, ...args: A) => R, ...args: A) : this - { - return modifier(this as any, ...args) as any; - } - - /** - * Same as `use`, but the method indicates it is terminating the query. - * @reflection never - */ - fetch(modifier: (query: Q) => R): R { - return modifier(this as any); - } - - /** - * For MySQL/Postgres SELECT FOR SHARE. - * Has no effect in SQLite/MongoDB. - */ - forShare(): this { - const c = this.clone(); - c.model.for = 'share'; - return c as any; - } - - /** - * For MySQL/Postgres SELECT FOR UPDATE. - * Has no effect in SQLite/MongoDB. - */ - forUpdate(): this { - const c = this.clone(); - c.model.for = 'update'; - return c as any; - } - - withBatchSize(batchSize: number): this { - const c = this.clone(); - c.model.batchSize = batchSize; - return c as any; - } - - groupBy[]>(...field: K): this { - const c = this.clone(); - c.model.groupBy = new Set([...field as string[]]); - return c as any; - } - - withSum, AS extends string>(field: K, as?: AS): Replace & { [K in [AS] as `${AS}`]: number }> { - return this.aggregateField(field, 'sum', as) as any; - } - - withGroupConcat, AS extends string>(field: K, as?: AS): Replace & { [C in [AS] as `${AS}`]: T[K][] }> { - return this.aggregateField(field, 'group_concat', as) as any; - } - - withCount, AS extends string>(field: K, as?: AS): Replace & { [K in [AS] as `${AS}`]: number }> { - return this.aggregateField(field, 'count', as) as any; - } - - withMax, AS extends string>(field: K, as?: AS): Replace & { [K in [AS] as `${AS}`]: number }> { - return this.aggregateField(field, 'max', as) as any; - } - - withMin, AS extends string>(field: K, as?: AS): Replace & { [K in [AS] as `${AS}`]: number }> { - return this.aggregateField(field, 'min', as) as any; - } - - withAverage, AS extends string>(field: K, as?: AS): Replace & { [K in [AS] as `${AS}`]: number }> { - return this.aggregateField(field, 'avg', as) as any; - } - - aggregateField, AS extends string>(field: K, func: string, as?: AS): Replace & { [K in [AS] as `${AS}`]: number }> { - const c = this.clone(); - (as as any) ||= field; - c.model.aggregate.set((as as any), { property: this.classSchema.getProperty(field), func }); - return c as any; - } - - /** - * Excludes given fields from the query. - * - * This is mainly useful for performance reasons, to prevent loading unnecessary data. - * - * Use `hydrateEntity(item)` to completely load the entity. - */ - lazyLoad)[]>(...select: K): this { - const c = this.clone(); - for (const s of select) c.model.lazyLoad.add(s as string); - return c; - } - - /** - * Limits the query to the given fields. - * - * This is useful for security reasons, to prevent leaking sensitive data, - * and also for performance reasons, to prevent loading unnecessary data. - * - * Note: This changes the return type of the query (findOne(), find(), etc) to exclude the fields that are not selected. - * This makes the query return simple objects instead of entities. To maintained nominal types use `lazyLoad()` instead. - */ - select)[]>(...select: K): Replace, K[number]>> { - const c = this.clone(); - for (const field of select) { - if (!this.classSchema.hasProperty(field)) throw new Error(`Field ${String(field)} unknown`); - } - c.model.select = new Set([...select as string[]]); - return c as any; - } - - returning(...fields: FieldName[]): this { - const c = this.clone(); - c.model.returning.push(...fields); - return c; - } - - skip(value?: number): this { - const c = this.clone(); - c.model.skip = value; - return c; - } - - /** - * Sets the page size when `page(x)` is used. - */ - itemsPerPage(value: number): this { - const c = this.clone(); - c.model.itemsPerPage = value; - return c; - } - - /** - * Applies limit/skip operations correctly to basically have a paging functionality. - * Make sure to call itemsPerPage() before you call page. - */ - page(page: number): this { - const c = this.clone(); - const skip = (page * c.model.itemsPerPage) - c.model.itemsPerPage; - c.model.skip = skip; - c.model.limit = c.model.itemsPerPage; - return c; - } - - limit(value?: number): this { - const c = this.clone(); - c.model.limit = value; - return c; - } - - parameter(name: string, value: any): this { - const c = this.clone(); - c.model.parameters[name] = value; - return c; - } - - parameters(parameters: { [name: string]: any }): this { - const c = this.clone(); - c.model.parameters = parameters; - return c; - } - - /** - * Identity mapping is used to store all created entity instances in a pool. - * If a query fetches an already known entity instance, the old will be picked. - * This ensures object instances uniqueness and generally saves CPU circles. - * - * This disabled entity tracking, forcing always to create new entity instances. - * - * For queries created on the database object (database.query(T)), this is disabled - * per default. Only on sessions (const session = database.createSession(); session.query(T)) - * is the identity map enabled per default, and can be disabled with this method. - */ - disableIdentityMap(): this { - const c = this.clone(); - c.model.withIdentityMap = false; - return c; - } - - /** - * When fetching objects from the database, for each object will a snapshot be generated, - * on which change-detection happens. This behavior is not necessary when only fetching - * data and never modifying its objects (when for example returning data to the client directly). - * When this is the case, you can disable change-detection entirely for the returned objects. - * Note: Persisting/committing (database.persist(), session.commit) won't detect any changes - * when change-detection is disabled. - */ - disableChangeDetection(): this { - const c = this.clone(); - c.model.withChangeDetection = false; - return c; - } - - having(filter?: this['model']['filter']): this { - const c = this.clone(); - c.model.having = filter; - return c; - } - - /** - * Narrow the query result. - * - * Note: previous filter conditions are preserved. - */ - filter(filter?: this['model']['filter']): this { - const c = this.clone(); - - if (filter && !Object.keys(filter as object).length) filter = undefined; - if (filter instanceof this.classSchema.getClassType()) { - const primaryKey = this.classSchema.getPrimary(); - filter = { [primaryKey.name]: (filter as any)[primaryKey.name] } as this['model']['filter']; - } - if (filter && c.model.filter) { - filter = { $and: [filter, c.model.filter] } as this['model']['filter']; - } - - c.model.filter = filter; - return c; - } - - /** - * Narrow the query result by field-specific conditions. - * - * This can be helpful to work around the type issue that when `T` is another - * generic type there must be a type assertion to use {@link filter}. - * - * Note: previous filter conditions are preserved. - */ - filterField(name: K, value: FilterQuery[K]): this { - return this.filter({ [name]: value } as any); - } - - /** - * Clear all filter conditions. - */ - clearFilter(): this { - const c = this.clone(); - c.model.filter = undefined; - return c; - } - - sort(sort?: this['model']['sort']): this { - const c = this.clone(); - c.model.sort = {}; - for (const [key, value] of Object.entries(sort || {})) { - this.applyOrderBy(c.model, key as FieldName, value as 'asc' | 'desc'); - } - return c; - } - - protected applyOrderBy>(model: this['model'], field: K, direction: 'asc' | 'desc' = 'asc'): void { - if (!model.sort) model.sort = {}; - if (field.includes('.')) { - const [relation, fieldName] = field.split(/\.(.*)/s); - const property = this.classSchema.getProperty(relation); - if (property.isReference() || property.isBackReference()) { - let found = false; - for (const join of model.joins) { - if (join.propertySchema === property) { - //join found - join.query = join.query.orderBy(fieldName, direction); - found = true; - break; - } - } - if (!found) { - throw new Error(`Cannot order by ${field} because the relation '${relation}' is not joined. Use join('${relation}'), useJoin('${relation}'), or joinWith('${relation}') etc first.`); - } - } else { - model.sort[field] = direction; - } - } else { - model.sort[field] = direction; - } - } - - orderBy>(field: K, direction: 'asc' | 'desc' = 'asc'): this { - const c = this.clone(); - this.applyOrderBy(c.model, field, direction); - return c; - } - - clone(): this { - const cloned = new (this['constructor'] as ClassType)(this.classSchema); - cloned.model = this.model.clone(cloned) as this['model']; - return cloned; - } - - /** - * Adds a left join in the filter. Does NOT populate the reference with values. - * Accessing `field` in the entity (if not optional field) results in an error. - */ - join, ENTITY extends OrmEntity = FindEntity>( - field: K, type: 'left' | 'inner' = 'left', populate: boolean = false, - configure?: Configure - ): this { - return this.addJoin(field, type, populate, configure)[0]; - } - - /** - * Adds a left join in the filter and returns new this query and the join query. - */ - protected addJoin, ENTITY extends OrmEntity = FindEntity>( - field: K, type: 'left' | 'inner' = 'left', populate: boolean = false, - configure?: Configure - ): [thisQuery: this, joinQuery: BaseQuery] { - const propertySchema = this.classSchema.getProperty(field as string); - if (!propertySchema.isReference() && !propertySchema.isBackReference()) { - throw new Error(`Field ${String(field)} is not marked as reference. Use Reference type`); - } - const c = this.clone(); - - const foreignReflectionClass = resolveForeignReflectionClass(propertySchema); - let query = new BaseQuery(foreignReflectionClass); - query.model.parameters = c.model.parameters; - if (configure) query = configure(query) || query; - - c.model.joins.push({ - propertySchema, query, populate, type, - foreignPrimaryKey: foreignReflectionClass.getPrimary(), - classSchema: this.classSchema, - }); - - return [c, query]; - } - - /** - * Adds a left join in the filter. Does NOT populate the reference with values. - * Accessing `field` in the entity (if not optional field) results in an error. - * Returns JoinDatabaseQuery to further specify the join, which you need to `.end()` - */ - useJoin, ENTITY extends OrmEntity = FindEntity>(field: K): JoinDatabaseQuery { - const c = this.addJoin(field, 'left'); - return new JoinDatabaseQuery(c[1].classSchema, c[1], c[0]); - } - - /** - * Adds a left join in the filter and populates the result set WITH reference field accordingly. - */ - joinWith, ENTITY extends OrmEntity = FindEntity>(field: K, configure?: Configure): this { - return this.addJoin(field, 'left', true, configure)[0]; - } - - /** - * Adds a left join in the filter and populates the result set WITH reference field accordingly. - * Returns JoinDatabaseQuery to further specify the join, which you need to `.end()` - */ - useJoinWith, ENTITY extends OrmEntity = FindEntity>(field: K): JoinDatabaseQuery { - const c = this.addJoin(field, 'left', true); - return new JoinDatabaseQuery(c[1].classSchema, c[1], c[0]); - } - - getJoin, ENTITY extends OrmEntity = FindEntity>(field: K): JoinDatabaseQuery { - for (const join of this.model.joins) { - if (join.propertySchema.name === field) return new JoinDatabaseQuery(join.query.classSchema, join.query, this); - } - throw new Error(`No join for reference ${String(field)} added.`); - } - - /** - * Adds an inner join in the filter and populates the result set WITH reference field accordingly. - */ - innerJoinWith, ENTITY extends OrmEntity = FindEntity>(field: K, configure?: Configure): this { - return this.addJoin(field, 'inner', true, configure)[0]; - } - - /** - * Adds an inner join in the filter and populates the result set WITH reference field accordingly. - * Returns JoinDatabaseQuery to further specify the join, which you need to `.end()` - */ - useInnerJoinWith, ENTITY extends OrmEntity = FindEntity>(field: K): JoinDatabaseQuery { - const c = this.addJoin(field, 'inner', true); - return new JoinDatabaseQuery(c[1].classSchema, c[1], c[0]); - } - - /** - * Adds an inner join in the filter. Does NOT populate the reference with values. - * Accessing `field` in the entity (if not optional field) results in an error. - */ - innerJoin, ENTITY extends OrmEntity = FindEntity>(field: K, configure?: Configure): this { - return this.addJoin(field, 'inner', false, configure)[0]; - } - - /** - * Adds an inner join in the filter. Does NOT populate the reference with values. - * Accessing `field` in the entity (if not optional field) results in an error. - * Returns JoinDatabaseQuery to further specify the join, which you need to `.end()` - */ - useInnerJoin, ENTITY extends OrmEntity = FindEntity>(field: K): JoinDatabaseQuery { - const c = this.addJoin(field, 'inner'); - return new JoinDatabaseQuery(c[1].classSchema, c[1], c[0]); - } -} - -/** - * @deprecated use SelectorResolver instead - */ -export abstract class GenericQueryResolver = DatabaseQueryModel> { - constructor( - protected classSchema: ReflectionClass, - protected session: DatabaseSession, - ) { - } - - abstract count(model: MODEL): Promise; - - abstract find(model: MODEL): Promise; - - abstract findOneOrUndefined(model: MODEL): Promise; - - abstract delete(model: MODEL, deleteResult: DeleteResult): Promise; - - abstract patch(model: MODEL, value: Changes, patchResult: PatchResult): Promise; -} - -export interface FindQuery { - findOneOrUndefined(): Promise; - - findOne(): Promise; - - find(): Promise; -} - -export type Methods = { [K in keyof T]: K extends keyof Query ? never : T[K] extends ((...args: any[]) => any) ? K : never }[keyof T]; - -/** - * This a generic query abstraction which should supports most basics database interactions. - * - * All query implementations should extend this since db agnostic consumers are probably - * coded against this interface via Database which uses this GenericQuery. - */ -export class Query extends BaseQuery { - protected lifts: ClassType[] = []; - - public static readonly onFind: EventToken> = new EventToken('orm.query.fetch'); - - public static readonly onDeletePre: EventToken> = new EventToken('orm.query.delete.pre'); - public static readonly onDeletePost: EventToken> = new EventToken('orm.query.delete.post'); - - public static readonly onPatchPre: EventToken> = new EventToken('orm.query.patch.pre'); - public static readonly onPatchPost: EventToken> = new EventToken('orm.query.patch.post'); - - static is>>(v: Query, type: T): v is InstanceType { - return v.lifts.includes(type) || v instanceof type; - } - - constructor( - classSchema: ReflectionClass, - protected session: DatabaseSession, - protected resolver: GenericQueryResolver, - ) { - super(classSchema); - this.model.withIdentityMap = session.withIdentityMap; - } - - static from & { - _: () => T - }, T extends ReturnType['_']>, B extends ClassType>>(this: B, query: Q): Replace, Resolve> { - const result = (new this(query.classSchema, query.session, query.resolver)); - result.model = query.model.clone(result); - return result as any; - } - - public lift>, T extends ReturnType['_']>, THIS extends Query & { _: () => T }>( - this: THIS, query: B, - ): Replace, Resolve> & Pick> { - const base = this['constructor'] as ClassType; - //we create a custom class to have our own prototype - const clazz = class extends base { - }; - - let obj: any = query; - const wasSet: { [name: string]: true } = {}; - const lifts: any[] = []; - do { - if (obj === Query) break; - lifts.push(obj); - - for (const i of Object.getOwnPropertyNames(obj.prototype)) { - if (i === 'constructor') continue; - if (wasSet[i]) continue; - Object.defineProperty(clazz.prototype, i, { - configurable: true, - writable: true, - value: obj.prototype[i], - }); - wasSet[i] = true; - } - } while (obj = Object.getPrototypeOf(obj)); - - const cloned = new clazz(this.classSchema, this.session, this.resolver); - - const lift = new query(this.classSchema, this.session, this.resolver, this.model); - for (const i in this) { - (cloned)[i] = (this as any)[i]; - } - for (const i in lift) { - (cloned)[i] = (lift as any)[i]; - } - cloned.model = this.model.clone(cloned as BaseQuery); - cloned.lifts = this.lifts; - cloned.lifts.push(...lifts); - - return cloned as any; - } - - /** - * Clones the query and returns a new instance. - * This happens automatically for each modification, so you don't need to call it manually. - * - * ```typescript - * let query1 = database.query(User); - * let query2 = query1.filter({name: 'Peter'}); - * // query1 is not modified, query2 is a new instance with the filter applied - * ``` - */ - clone(): this { - const cloned = new (this['constructor'] as ClassType)(this.classSchema, this.session, this.resolver); - cloned.model = this.model.clone(cloned) as this['model']; - cloned.lifts = this.lifts; - return cloned; - } - - protected async callOnFetchEvent(query: Query): Promise { - const hasEvents = this.session.eventDispatcher.hasListeners(Query.onFind); - if (!hasEvents) return query as this; - - const event = new QueryDatabaseEvent(this.session, this.classSchema, query); - await this.session.eventDispatcher.dispatch(Query.onFind, event); - return event.query as any; - } - - protected onQueryResolve(query: Query): this { - if (query.classSchema.singleTableInheritance && query.classSchema.parent) { - const discriminant = query.classSchema.parent.getSingleTableInheritanceDiscriminantName(); - const property = query.classSchema.getProperty(discriminant); - assertType(property.type, ReflectionKind.literal); - return query.filterField(discriminant as keyof T & string, property.type.literal) as this; - } - return query as this; - } - - /** - * Returns the number of items matching the query. - * - * @throws DatabaseError - */ - public async count(fromHas: boolean = false): Promise { - let query: Query | undefined = undefined; - - const frame = this.session.stopwatch?.start((fromHas ? 'Has:' : 'Count:') + this.classSchema.getClassName(), FrameCategory.database); - try { - frame?.data({ collection: this.classSchema.getCollectionName(), className: this.classSchema.getClassName() }); - const eventFrame = this.session.stopwatch?.start('Events'); - query = this.onQueryResolve(await this.callOnFetchEvent(this)); - eventFrame?.end(); - return await query.resolver.count(query.model); - } catch (error: any) { - await this.session.eventDispatcher.dispatch(onDatabaseError, new DatabaseErrorEvent(error, this.session, query?.classSchema, query)); - throw error; - } finally { - frame?.end(); - } - } - - /** - * Fetches all items matching the query. - * - * @throws DatabaseError - */ - public async find(): Promise[]> { - const frame = this.session.stopwatch?.start('Find:' + this.classSchema.getClassName(), FrameCategory.database); - let query: Query | undefined = undefined; - - try { - frame?.data({ collection: this.classSchema.getCollectionName(), className: this.classSchema.getClassName() }); - const eventFrame = this.session.stopwatch?.start('Events'); - query = this.onQueryResolve(await this.callOnFetchEvent(this)); - eventFrame?.end(); - return await query.resolver.find(query.model) as Resolve[]; - } catch (error: any) { - await this.session.eventDispatcher.dispatch(onDatabaseError, new DatabaseErrorEvent(error, this.session, query?.classSchema, query)); - throw error; - } finally { - frame?.end(); - } - } - - /** - * Fetches a single item matching the query or undefined. - * - * @throws DatabaseError - */ - public async findOneOrUndefined(): Promise | undefined> { - const frame = this.session.stopwatch?.start('FindOne:' + this.classSchema.getClassName(), FrameCategory.database); - let query: Query | undefined = undefined; - - try { - frame?.data({ collection: this.classSchema.getCollectionName(), className: this.classSchema.getClassName() }); - const eventFrame = this.session.stopwatch?.start('Events'); - query = this.onQueryResolve(await this.callOnFetchEvent(this.limit(1))); - eventFrame?.end(); - return await query.resolver.findOneOrUndefined(query.model) as Resolve; - } catch (error: any) { - await this.session.eventDispatcher.dispatch(onDatabaseError, new DatabaseErrorEvent(error, this.session, query?.classSchema, query)); - throw error; - } finally { - frame?.end(); - } - } - - /** - * Fetches a single item matching the query. - * - * @throws DatabaseError - */ - public async findOne(): Promise> { - const item = await this.findOneOrUndefined(); - if (!item) throw new ItemNotFound(`Item ${this.classSchema.getClassName()} not found`); - return item as Resolve; - } - - /** - * Deletes all items matching the query. - * - * @throws DatabaseDeleteError - */ - public async deleteMany(): Promise> { - return await this.delete(this) as any; - } - - /** - * Deletes a single item matching the query. - * - * @throws DatabaseDeleteError - */ - public async deleteOne(): Promise> { - return await this.delete(this.limit(1)); - } - - protected async delete(query: Query): Promise> { - const hasEvents = this.session.eventDispatcher.hasListeners(Query.onDeletePre) || this.session.eventDispatcher.hasListeners(Query.onDeletePost); - - const deleteResult: DeleteResult = { - modified: 0, - primaryKeys: [], - }; - - const frame = this.session.stopwatch?.start('Delete:' + this.classSchema.getClassName(), FrameCategory.database); - if (frame) frame.data({ collection: this.classSchema.getCollectionName(), className: this.classSchema.getClassName() }); - - try { - if (!hasEvents) { - query = this.onQueryResolve(query); - await this.resolver.delete(query.model, deleteResult); - this.session.identityMap.deleteManyBySimplePK(this.classSchema, deleteResult.primaryKeys); - return deleteResult; - } - - const event = new QueryDatabaseDeleteEvent(this.session, this.classSchema, query, deleteResult); - - if (this.session.eventDispatcher.hasListeners(Query.onDeletePre)) { - const eventFrame = this.session.stopwatch ? this.session.stopwatch.start('Events') : undefined; - await this.session.eventDispatcher.dispatch(Query.onDeletePre, event); - if (eventFrame) eventFrame.end(); - if (event.stopped) return deleteResult; - } - - //we need to use event.query in case someone overwrite it - event.query = this.onQueryResolve(event.query as this); - await event.query.resolver.delete(event.query.model, deleteResult); - this.session.identityMap.deleteManyBySimplePK(this.classSchema, deleteResult.primaryKeys); - - if (deleteResult.primaryKeys.length && this.session.eventDispatcher.hasListeners(Query.onDeletePost)) { - const eventFrame = this.session.stopwatch ? this.session.stopwatch.start('Events Post') : undefined; - await this.session.eventDispatcher.dispatch(Query.onDeletePost, event); - if (eventFrame) eventFrame.end(); - if (event.stopped) return deleteResult; - } - - return deleteResult; - } catch (error: any) { - await this.session.eventDispatcher.dispatch(onDatabaseError, new DatabaseErrorEvent(error, this.session, query.classSchema, query)); - throw error; - } finally { - if (frame) frame.end(); - } - } - - /** - * Updates all items matching the query with the given patch. - * - * @throws DatabasePatchError - * @throws UniqueConstraintFailure - */ - public async patchMany(patch: ChangesInterface | DeepPartial): Promise> { - return await this.patch(this, patch); - } - - /** - * Updates a single item matching the query with the given patch. - * - * @throws DatabasePatchError - * @throws UniqueConstraintFailure - */ - public async patchOne(patch: ChangesInterface | DeepPartial): Promise> { - return await this.patch(this.limit(1), patch); - } - - protected async patch(query: Query, patch: DeepPartial | ChangesInterface): Promise> { - const frame = this.session.stopwatch ? this.session.stopwatch.start('Patch:' + this.classSchema.getClassName(), FrameCategory.database) : undefined; - if (frame) frame.data({ collection: this.classSchema.getCollectionName(), className: this.classSchema.getClassName() }); - - try { - const changes: Changes = patch instanceof Changes ? patch as Changes : new Changes({ - $set: patch.$set || {}, - $inc: patch.$inc || {}, - $unset: patch.$unset || {}, - }); - - for (const i in patch) { - if (i.startsWith('$')) continue; - changes.set(i as any, (patch as any)[i]); - } - - const patchResult: PatchResult = { - modified: 0, - returning: {}, - primaryKeys: [], - }; - - if (changes.empty) return patchResult; - - const hasEvents = this.session.eventDispatcher.hasListeners(Query.onPatchPre) || this.session.eventDispatcher.hasListeners(Query.onPatchPost); - if (!hasEvents) { - query = this.onQueryResolve(query); - await this.resolver.patch(query.model, changes, patchResult); - return patchResult; - } - - const event = new QueryDatabasePatchEvent(this.session, this.classSchema, query, changes, patchResult); - if (this.session.eventDispatcher.hasListeners(Query.onPatchPre)) { - const eventFrame = this.session.stopwatch ? this.session.stopwatch.start('Events') : undefined; - await this.session.eventDispatcher.dispatch(Query.onPatchPre, event); - if (eventFrame) eventFrame.end(); - if (event.stopped) return patchResult; - } - - // for (const field of event.returning) { - // if (!event.query.model.returning.includes(field)) event.query.model.returning.push(field); - // } - - //whe need to use event.query in case someone overwrite it - query = this.onQueryResolve(query); - await event.query.resolver.patch(event.query.model, changes, patchResult); - - if (query.model.withIdentityMap) { - const pkHashGenerator = getSimplePrimaryKeyHashGenerator(this.classSchema); - for (let i = 0; i < patchResult.primaryKeys.length; i++) { - const item = this.session.identityMap.getByHash(this.classSchema, pkHashGenerator(patchResult.primaryKeys[i])); - if (!item) continue; - - if (changes.$set) for (const name in changes.$set) { - (item as any)[name] = (changes.$set as any)[name]; - } - - for (const name in patchResult.returning) { - (item as any)[name] = (patchResult.returning as any)[name][i]; - } - } - } - - if (this.session.eventDispatcher.hasListeners(Query.onPatchPost)) { - const eventFrame = this.session.stopwatch ? this.session.stopwatch.start('Events Post') : undefined; - await this.session.eventDispatcher.dispatch(Query.onPatchPost, event); - if (eventFrame) eventFrame.end(); - if (event.stopped) return patchResult; - } - - return patchResult; - } catch (error: any) { - await this.session.eventDispatcher.dispatch(onDatabaseError, new DatabaseErrorEvent(error, this.session, query.classSchema, query)); - throw error; - } finally { - if (frame) frame.end(); - } - } - - /** - * Returns true if the query matches at least one item. - * - * @throws DatabaseError - */ - public async has(): Promise { - return await this.count(true) > 0; - } - - /** - * Returns the primary keys of the query. - * - * ```typescript - * const ids = await database.query(User).ids(); - * // ids: number[] - * ``` - * - * @throws DatabaseError - */ - public async ids(singleKey?: false): Promise[]>; - public async ids(singleKey: true): Promise[]>; - public async ids(singleKey: boolean = false): Promise[] | PrimaryKeyType[]> { - const pks: any = this.classSchema.getPrimaries().map(v => v.name) as FieldName[]; - if (singleKey && pks.length > 1) { - throw new Error(`Entity ${this.classSchema.getClassName()} has more than one primary key`); - } - - const data = await this.clone().select(...pks).find() as Resolve[]; - if (singleKey) { - const pkName = pks[0] as keyof Resolve; - return data.map(v => v[pkName]) as any; - } - - return data; - } - - /** - * Returns the specified field of the query from all items. - * - * ```typescript - * const usernames = await database.query(User).findField('username'); - * // usernames: string[] - * ``` - * - * @throws DatabaseError - */ - public async findField>(name: K): Promise { - const items = await this.select(name as keyof Resolve).find() as T[]; - return items.map(v => v[name]); - } - - /** - * Returns the specified field of the query from a single item, throws if not found. - * - * ```typescript - * const username = await database.query(User).findOneField('username'); - * ``` - * - * @throws ItemNotFound if no item is found - * @throws DatabaseError - */ - public async findOneField>(name: K): Promise { - const item = await this.select(name as keyof Resolve).findOne() as T; - return item[name]; - } - - /** - * Returns the specified field of the query from a single item or undefined. - * - * @throws DatabaseError - */ - public async findOneFieldOrUndefined>(name: K): Promise { - const item = await this.select(name as keyof Resolve).findOneOrUndefined(); - if (item) return item[name]; - return; - } -} - -export class JoinDatabaseQuery> extends BaseQuery { - constructor( - // important to have this as first argument, since clone() uses it - classSchema: ReflectionClass, - public query: BaseQuery, - public parentQuery?: PARENT - ) { - super(classSchema); - if (query) this.model = query.model; - } - - clone(parentQuery?: PARENT): this { - const c = super.clone(); - c.parentQuery = parentQuery || this.parentQuery; - c.query = this.query; - return c; - } - - end(): PARENT { - if (!this.parentQuery) throw new Error('Join has no parent query'); - //the parentQuery has not the updated JoinDatabaseQuery stuff, we need to move it now to there - this.query.model = this.model; - return this.parentQuery; - } -} - -export type AnyQuery = BaseQuery; diff --git a/packages/orm/src/select.ts b/packages/orm/src/select.ts index c6f1410e4..51a8a45ba 100644 --- a/packages/orm/src/select.ts +++ b/packages/orm/src/select.ts @@ -4,19 +4,21 @@ import { ChangesInterface, DeepPartial, getSimplePrimaryKeyHashGenerator, - getTypeJitContainer, PrimaryKeyFields, PrimaryKeyType, ReflectionClass, ReflectionKind, resolveReceiveType, Type, + TypeClass, + TypeObjectLiteral, TypeProperty, TypePropertySignature, } from '@deepkit/type'; import { DeleteResult, OrmEntity, PatchResult } from './type.js'; import { DatabaseErrorEvent, + ItemNotFound, onDatabaseError, onDeletePost, onDeletePre, @@ -30,7 +32,7 @@ import { import { DatabaseSession } from './database-session.js'; import { FieldName } from './utils.js'; import { FrameCategory } from '@deepkit/stopwatch'; -import { ItemNotFound } from './query.js'; +import { ClassType } from '@deepkit/core'; let treeId = 10; type ExpressionTree = { id: number, nodes: { [id: number]: ExpressionTree }, cache?: { [name: string]: any } }; @@ -38,7 +40,7 @@ type ExpressionTree = { id: number, nodes: { [id: number]: ExpressionTree }, cac /** @reflection never */ export type SelectorProperty = { [propertyTag]: 'property'; - model: SelectorState; + // model: SelectorState; name: string; // as?: string; tree: ExpressionTree, @@ -68,6 +70,7 @@ export type SelectorState = { as?: string; select: (SelectorProperty | OpExpression)[]; + //join // TODO: this is not cacheable/deterministic // or how should we determine whether select, where, joins, offset, limit, etc is all the same @@ -83,6 +86,8 @@ export type SelectorState = { offset?: number; limit?: number; + data: { [name: string]: any }; + for?: string; previous?: SelectorState; @@ -102,6 +107,11 @@ function ensureState(state: SelectorState | undefined): asserts state is Selecto if (!state) throw new MissingStateError(); } +export function currentState(): SelectorState { + if (!state) throw new MissingStateError(); + return state; +} + export const average = (a: any): any => { ensureState(state); return { kind: 'call', method: 'average', args: [a] }; @@ -180,9 +190,11 @@ export function isOp(value: any): value is OpExpression { } -function getTree(...args: any[]) { +function getTree(args: any[]) { let tree: ExpressionTree | undefined; - for (const arg of args) { + const params = state!.params; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; if (isProperty(arg) || isOp(arg)) { if (tree) { const a = tree.nodes[arg.tree.id]; @@ -195,6 +207,9 @@ function getTree(...args: any[]) { tree = arg.tree; } } else { + const paramIndex = params.length; + params.push(arg); + args[i] = paramIndex; if (tree) { const a = tree.nodes[0]; if (a) { @@ -208,7 +223,7 @@ function getTree(...args: any[]) { return tree; } -export type OpExpression = { [opTag]: Op, tree: ExpressionTree, args: (OpExpression | SelectorProperty | unknown)[] }; +export type OpExpression = { [opTag]: Op, tree: ExpressionTree, args: (OpExpression | SelectorProperty | number)[] }; export type Op = ((...args: any[]) => OpExpression) & { id: symbol }; function makeOp(name: string, cb: (expression: OpExpression, args: any[]) => any): Op { @@ -219,7 +234,7 @@ function makeOp(name: string, cb: (expression: OpExpression, args: any[]) => any * @reflection never */ function operation(...args: any[]) { - const tree = getTree(...args) || opTree; + const tree = getTree(args) || opTree; const opExpression = { [opTag]: operation, tree, args }; cb(opExpression, args); return opExpression; @@ -237,6 +252,14 @@ export const eq = makeOp('eq', (expression, args: any[]) => { }); +export const notEqual = makeOp('notEqual', (expression, args: any[]) => { + +}); + +export const not = makeOp('not', (expression, args: any[]) => { + +}); + export const where = makeOp('where', (expression, args: any[]) => { ensureState(state); if (state.where) { @@ -246,6 +269,13 @@ export const where = makeOp('where', (expression, args: any[]) => { } }); +export const filter = (model: SelectorRefs, patch: Partial): void => { + ensureState(state); + for (const i in patch) { + where(eq(model[i], patch[i])); + } +}; + export const or = makeOp('or', (exp, args: any[]) => { }); @@ -299,35 +329,43 @@ export const join = (a: SelectorProperty, cb?: (m: SelectorRefs = R; +// todo: Do we really need that? We could add type arg Select to SelectorState instead export interface SelectorInferredState { state: SelectorState; } export function query(cb: (main: SelectorRefs, ...args: SelectorRefs[]) => R | undefined): SelectorInferredState> { - const fnType = resolveReceiveType(cb); + let fnType = (cb as any).__type?.__type ? (cb as any).__type.__type : undefined; + if (!fnType) fnType = resolveReceiveType(cb); assertType(fnType, ReflectionKind.function); - const selectorRefs = fnType.parameters.map(v => { - const arg = v.type.originTypes?.[0].typeArguments?.[0]; - return createModel(arg || v.type); - }); + const argTypes = fnType.parameters.map(v => v.type.originTypes?.[0].typeArguments?.[0] || v.type); + const states = argTypes.map(v => createModel(v)); + const selectorRefs = states.map(v => v.fields); if (selectorRefs.length === 0) { throw new Error('No main selector found in query callback'); } let previous = state; - const nextSelect = selectorRefs[0]; + const nextSelect = states[0]; state = nextSelect; - const args = selectorRefs.map(v => v.fields); try { - (cb as any)(...args); + const select = (cb as any)(...selectorRefs); + if (select) state.select = select; } finally { state = previous; } return { state: nextSelect }; } +export function singleQuery(classType: ClassType | Type, cb?: (main: SelectorRefs) => R | undefined): SelectorInferredState> { + const type = resolveReceiveType(classType); + const state = createModel(type); + if (cb) applySelect(state, cb); + return { state }; +} + export type Select = SelectorRefs; -export const applySelect = (a: (m: SelectorRefs) => any, nextSelect: SelectorState) => { +export const applySelect = (nextSelect: SelectorState, a: (m: SelectorRefs) => any) => { let previous = state; state = nextSelect; try { @@ -338,32 +376,44 @@ export const applySelect = (a: (m: SelectorRefs) => any, nextSelect: Selec return nextSelect; }; +const selectorStateCache: { [id: number]: { schema: ReflectionClass, fields: SelectorRefs } } = {}; + export function createModel(type: Type): SelectorState { - const jit = getTypeJitContainer(type); - if (jit.query2Model) return jit.query2Model; + const id = type.id; + if ('undefined' === typeof id) throw new Error(`Type ${type.typeName} is not nominal typed`); if (type.kind !== ReflectionKind.objectLiteral && type.kind !== ReflectionKind.class) { throw new Error('Type only supports object literals and classes'); } - const schema = ReflectionClass.fromType(type); - const fields: SelectorRefs = { - $$fields: [], - } as any; + let query2Model = selectorStateCache[id]; + if (!query2Model) { + selectorStateCache[id] = query2Model = { + schema: ReflectionClass.fromType(type), + fields: createFields(type), + }; + } - const model: SelectorState = { - schema, + return { + schema: query2Model.schema, + fields: query2Model.fields, params: [], select: [], - fields, + data: {}, previous: state, }; +} - for (const member of schema.type.types) { +export function createFields(type: TypeClass | TypeObjectLiteral) { + const fields: SelectorRefs = { + $$fields: [], + } as any; + + for (const member of type.types) { if (member.kind !== ReflectionKind.propertySignature && member.kind !== ReflectionKind.property) continue; const ref = { [propertyTag]: 'property', - model, + // model, property: member, tree: { id: treeId++, @@ -375,7 +425,7 @@ export function createModel(type: Type): SelectorState { (fields as any)[member.name] = ref; } - return jit.query2Model = model; + return fields; } export abstract class SelectorResolver { @@ -406,20 +456,24 @@ export class Query2 { this.classSchema = state.schema; } - createResolver() { - // return this.adapter.createQuery2Resolver(model.state, session) + filter(filter: Partial): this { + applySelect(this.state, () => { + for (const i in filter) { + where(eq(this.state.fields[i], filter[i])); + } + }); + return this; } - protected async callOnFetchEvent(query: Query2): Promise { + protected async callOnFetchEvent(state: SelectorState): Promise { const hasEvents = this.session.eventDispatcher.hasListeners(onFind); - if (!hasEvents) return query as this; + if (!hasEvents) return; - const event = new QueryDatabaseEvent(this.session, this.classSchema, query); + const event = new QueryDatabaseEvent(this.session, this.classSchema, state); await this.session.eventDispatcher.dispatch(onFind, event); - return event.query as any; } - protected onQueryResolve(query: Query2): this { + protected onQueryResolve(state: SelectorState): void { //TODO implement // if (query.classSchema.singleTableInheritance && query.classSchema.parent) { // const discriminant = query.classSchema.parent.getSingleTableInheritanceDiscriminantName(); @@ -427,7 +481,6 @@ export class Query2 { // assertType(property.type, ReflectionKind.literal); // return query.filterField(discriminant as keyof T & string, property.type.literal) as this; // } - return query as this; } // public select(cb: (m: Query2Fields) => any): Query2 { @@ -447,7 +500,6 @@ export class Query2 { * @throws DatabaseError */ public async count(fromHas: boolean = false): Promise { - let query: Query2 | undefined = undefined; const frame = this.session .stopwatch?.start((fromHas ? 'Has:' : 'Count:') + this.classSchema.getClassName(), FrameCategory.database); @@ -457,11 +509,12 @@ export class Query2 { className: this.classSchema.getClassName(), }); const eventFrame = this.session.stopwatch?.start('Events'); - query = this.onQueryResolve(await this.callOnFetchEvent(this)); + await this.callOnFetchEvent(this.state); + this.onQueryResolve(this.state); eventFrame?.end(); - return await this.resolver.count(query.state); + return await this.resolver.count(this.state); } catch (error: any) { - await this.session.eventDispatcher.dispatch(onDatabaseError, new DatabaseErrorEvent(error, this.session, query?.classSchema, query)); + await this.session.eventDispatcher.dispatch(onDatabaseError, new DatabaseErrorEvent(error, this.session, this.state.schema, this.state)); throw error; } finally { frame?.end(); @@ -477,19 +530,18 @@ export class Query2 { const frame = this.session .stopwatch?.start('Find:' + this.classSchema.getClassName(), FrameCategory.database); - let query: Query2 | undefined = undefined; - try { frame?.data({ collection: this.classSchema.getCollectionName(), className: this.classSchema.getClassName(), }); const eventFrame = this.session.stopwatch?.start('Events'); - query = this.onQueryResolve(await this.callOnFetchEvent(this)); + await this.callOnFetchEvent(this.state); + this.onQueryResolve(this.state); eventFrame?.end(); - return await query.resolver.find(query.state) as R[]; + return await this.resolver.find(this.state) as R[]; } catch (error: any) { - await this.session.eventDispatcher.dispatch(onDatabaseError, new DatabaseErrorEvent(error, this.session, query?.classSchema, query)); + await this.session.eventDispatcher.dispatch(onDatabaseError, new DatabaseErrorEvent(error, this.session, this.state.schema, this.state)); throw error; } finally { frame?.end(); @@ -503,19 +555,18 @@ export class Query2 { */ public async findOneOrUndefined(): Promise { const frame = this.session.stopwatch?.start('FindOne:' + this.classSchema.getClassName(), FrameCategory.database); - let query: Query2 | undefined = undefined; - try { frame?.data({ collection: this.classSchema.getCollectionName(), className: this.classSchema.getClassName(), }); const eventFrame = this.session.stopwatch?.start('Events'); - query = this.onQueryResolve(await this.callOnFetchEvent(this)); + await this.callOnFetchEvent(this.state); + this.onQueryResolve(this.state); eventFrame?.end(); - return await query.resolver.findOneOrUndefined(query.state); + return await this.resolver.findOneOrUndefined(this.state); } catch (error: any) { - await this.session.eventDispatcher.dispatch(onDatabaseError, new DatabaseErrorEvent(error, this.session, query?.classSchema, query)); + await this.session.eventDispatcher.dispatch(onDatabaseError, new DatabaseErrorEvent(error, this.session, this.state.schema, this.state)); throw error; } finally { frame?.end(); @@ -568,13 +619,13 @@ export class Query2 { try { if (!hasEvents) { - query = this.onQueryResolve(query); + this.onQueryResolve(query.state); await this.resolver.delete(query.state, deleteResult); this.session.identityMap.deleteManyBySimplePK(this.classSchema, deleteResult.primaryKeys); return deleteResult; } - const event = new QueryDatabaseDeleteEvent(this.session, this.classSchema, query, deleteResult); + const event = new QueryDatabaseDeleteEvent(this.session, this.classSchema, query.state, deleteResult); if (this.session.eventDispatcher.hasListeners(onDeletePre)) { const eventFrame = this.session.stopwatch ? this.session.stopwatch.start('Events') : undefined; @@ -584,8 +635,8 @@ export class Query2 { } //we need to use event.query in case someone overwrite it - event.query = this.onQueryResolve(event.query as this); - await event.query.resolver.delete(event.query.model, deleteResult); + this.onQueryResolve(event.query); + await this.resolver.delete(event.query, deleteResult); this.session.identityMap.deleteManyBySimplePK(this.classSchema, deleteResult.primaryKeys); if (deleteResult.primaryKeys.length && this.session.eventDispatcher.hasListeners(onDeletePost)) { @@ -597,7 +648,7 @@ export class Query2 { return deleteResult; } catch (error: any) { - await this.session.eventDispatcher.dispatch(onDatabaseError, new DatabaseErrorEvent(error, this.session, query.classSchema, query)); + await this.session.eventDispatcher.dispatch(onDatabaseError, new DatabaseErrorEvent(error, this.session, query.classSchema, query.state)); throw error; } finally { if (frame) frame.end(); @@ -654,12 +705,12 @@ export class Query2 { const hasEvents = this.session.eventDispatcher.hasListeners(onPatchPre) || this.session.eventDispatcher.hasListeners(onPatchPost); if (!hasEvents) { - query = this.onQueryResolve(query); + this.onQueryResolve(query.state); await this.resolver.patch(query.state, changes, patchResult); return patchResult; } - const event = new QueryDatabasePatchEvent(this.session, this.classSchema, query, changes, patchResult); + const event = new QueryDatabasePatchEvent(this.session, this.classSchema, query.state, changes, patchResult); if (this.session.eventDispatcher.hasListeners(onPatchPre)) { const eventFrame = this.session.stopwatch ? this.session.stopwatch.start('Events') : undefined; await this.session.eventDispatcher.dispatch(onPatchPre, event); @@ -672,8 +723,8 @@ export class Query2 { // } //whe need to use event.query in case someone overwrite it - query = this.onQueryResolve(query); - await event.query.resolver.patch(event.query.model, changes, patchResult); + this.onQueryResolve(query.state); + await this.resolver.patch(query.state, changes, patchResult); if (query.state.withIdentityMap) { const pkHashGenerator = getSimplePrimaryKeyHashGenerator(this.classSchema); @@ -700,7 +751,7 @@ export class Query2 { return patchResult; } catch (error: any) { - await this.session.eventDispatcher.dispatch(onDatabaseError, new DatabaseErrorEvent(error, this.session, query.classSchema, query)); + await this.session.eventDispatcher.dispatch(onDatabaseError, new DatabaseErrorEvent(error, this.session, query.classSchema, query.state)); throw error; } finally { if (frame) frame.end(); diff --git a/packages/orm/src/type.ts b/packages/orm/src/type.ts index 6e4455fb3..e95cf3a29 100644 --- a/packages/orm/src/type.ts +++ b/packages/orm/src/type.ts @@ -11,7 +11,7 @@ import { Changes, PrimaryKeyFields, PrimaryKeyType, ReflectionClass, ValidationErrorItem } from '@deepkit/type'; import { CustomError } from '@deepkit/core'; import { DatabasePersistenceChangeSet } from './database-adapter.js'; -import { DatabaseQueryModel } from './query.js'; +import { SelectorState } from './select.js'; export interface OrmEntity { } @@ -59,7 +59,7 @@ export class DatabaseUpdateError extends DatabaseError { export class DatabasePatchError extends DatabaseError { constructor( public readonly entity: ReflectionClass, - public readonly query: DatabaseQueryModel, + public readonly query: SelectorState, public readonly changeSets: Changes, ...args: ConstructorParameters ) { @@ -68,7 +68,7 @@ export class DatabasePatchError extends DatabaseError { } export class DatabaseDeleteError extends DatabaseError { - public readonly query?: DatabaseQueryModel; + public readonly query?: SelectorState; public readonly items?: OrmEntity[]; constructor( diff --git a/packages/orm/tests/dql.spec.ts b/packages/orm/tests/dql.spec.ts index bdeb9b5df..c8725e86f 100644 --- a/packages/orm/tests/dql.spec.ts +++ b/packages/orm/tests/dql.spec.ts @@ -107,21 +107,6 @@ test('ideal world', async () => { // const res2 = await db.delete(userQuery); }); -test('memory db', async () => { - const db = new Database(new MemoryDatabaseAdapter()); - db.register(); - - const user1: User = { id: 1, name: 'Peter', birthday: new Date() }; - const user2: User = { id: 2, name: 'John', birthday: new Date() }; - const user3: User = { id: 3, name: 'Jane', birthday: new Date() }; - await db.persist(user1, user2, user3); - - const user = await db.query2((user: Select) => { - where(eq(user.name, 'John')); - }).findOne(); - expect(user.name).toBe('John'); -}); - test('vector search', () => { interface Sentence { id: number & AutoIncrement & PrimaryKey; @@ -149,12 +134,12 @@ test('vector search', () => { // }); }); -function bench(title: string, cb: () => void) { +async function bench(title: string, cb: () => void) { const start = Date.now(); const count = 100_000; for (let i = 0; i < count; i++) { - cb(); + await cb(); } const took = Date.now() - start; @@ -181,9 +166,6 @@ test('graph', () => { where(eq(m.name, 'Peter2')); }); - console.log(a.state.params, b.state.where); - console.log(b.state.params, a.state.where); - expect(a.state.where!.tree === b.state.where!.tree).toBe(true); function filterByAge(model: Select<{ birthday: Date }>, age: number) { @@ -226,14 +208,57 @@ test('tree', () => { where(eq(m.name, 'Peter2')); }); - console.log(a.state.params, b.state.where); - console.log(b.state.params, a.state.where); + const c = query((m: Select) => { + }); + + console.log(a.state.params, a.state.where); + console.log(b.state.params, b.state.where); + console.log(c.state.params, c.state.where); expect(a.state.where!.tree === b.state.where!.tree).toBe(true); }); -test('performance', () => { - bench('select', () => { +test('memory db', async () => { + const db = new Database(new MemoryDatabaseAdapter()); + db.register(); + + const user1: User = { id: 1, name: 'Peter', birthday: new Date() }; + const user2: User = { id: 2, name: 'John', birthday: new Date() }; + const user3: User = { id: 3, name: 'Jane', birthday: new Date() }; + await db.persist(user1, user2, user3); + + { + const user1 = await db.query2((user: Select) => { + where(eq(user.name, 'John')); + }).findOne(); + expect(user1.name).toBe('John'); + + const user2 = await db.query2((user: Select) => { + where(eq(user.name, 'Jane')); + }).findOne(); + expect(user2.name).toBe('Jane'); + } +}); + +test('performance memory-db', async () => { + const db = new Database(new MemoryDatabaseAdapter()); + db.register(); + + const user1: User = { id: 1, name: 'Peter', birthday: new Date() }; + const user2: User = { id: 2, name: 'John', birthday: new Date() }; + const user3: User = { id: 3, name: 'Jane', birthday: new Date() }; + await db.persist(user1, user2, user3); + + await bench('select', async () => { + const res = await db.query2((user: Select) => { + where(eq(user.name, 'John')); + }).find(); + if (res.length !== 1) throw new Error('Invalid result'); + }); +}); + +test('performance state', async () => { + await bench('select', () => { query((user: Select) => { join(user.group, group => { where(eq(group.name, 'Admin')); @@ -248,10 +273,10 @@ test('performance', () => { // // }); - bench('database', () => { - database.query() - .select('id', 'name') - .useJoin('group').filter({ name: 'Admin' }).end() - .filter({ name: 'Peter' }); - }); + // bench('database', () => { + // database.query() + // .select('id', 'name') + // .useJoin('group').filter({ name: 'Admin' }).end() + // .filter({ name: 'Peter' }); + // }); }); diff --git a/packages/orm/tests/log-plugin.spec.ts b/packages/orm/tests/log-plugin.spec.ts index 2dade6852..9a6a90734 100644 --- a/packages/orm/tests/log-plugin.spec.ts +++ b/packages/orm/tests/log-plugin.spec.ts @@ -2,7 +2,7 @@ import { AutoIncrement, deserialize, entity, PrimaryKey } from '@deepkit/type'; import { expect, test } from '@jest/globals'; import { Database } from '../src/database.js'; import { MemoryDatabaseAdapter } from '../src/memory-db.js'; -import { LogPlugin, LogQuery, LogSession, LogType } from '../src/plugin/log-plugin.js'; +import { LogPlugin, LogSession, LogType, setLogAuthor } from '../src/plugin/log-plugin.js'; test('log query', async () => { @entity.name('logUser1') @@ -34,7 +34,7 @@ test('log query', async () => { ]); } - await database.query(User).filter({ id: 1 }).patchOne({ username: 'Peter2' }); + await database.singleQuery(User).filter({ id: 1 }).patchOne({ username: 'Peter2' }); { const logEntries = await database.query(userLogEntity).find(); @@ -63,7 +63,9 @@ test('log query', async () => { ]); } - await database.query(User).lift(LogQuery).byLogAuthor('Foo').deleteMany(); + await database.singleQuery(User, user => { + setLogAuthor('Foo'); + }).deleteMany(); { const logEntries = await database.query(userLogEntity).find(); diff --git a/packages/orm/tests/soft-delete.spec.ts b/packages/orm/tests/soft-delete.spec.ts index 5d8edd89d..f82cf85c7 100644 --- a/packages/orm/tests/soft-delete.spec.ts +++ b/packages/orm/tests/soft-delete.spec.ts @@ -3,8 +3,7 @@ import { expect, test } from '@jest/globals'; import { getInstanceStateFromItem } from '../src/identity-map.js'; import { Database } from '../src/database.js'; import { MemoryDatabaseAdapter } from '../src/memory-db.js'; -import { SoftDeletePlugin, SoftDeleteQuery, SoftDeleteSession } from '../src/plugin/soft-delete-plugin.js'; -import { Query } from '../src/query.js'; +import { enableHardDelete, includeOnlySoftDeleted, includeSoftDeleted, setDeletedBy, SoftDeletePlugin, SoftDeleteSession } from '../src/plugin/soft-delete-plugin.js'; test('soft-delete query', async () => { class User { @@ -25,51 +24,54 @@ test('soft-delete query', async () => { await database.persist(deserialize({ id: 2, username: 'Joe' })); await database.persist(deserialize({ id: 3, username: 'Lizz' })); - expect(await database.query(User).count()).toBe(3); + expect(await database.singleQuery(User).count()).toBe(3); - await database.query(User).filter({ id: 1 }).deleteOne(); + await database.singleQuery(User).filter({ id: 1 }).deleteOne(); - expect(await database.query(User).count()).toBe(2); - expect(await database.query(User).lift(SoftDeleteQuery).isSoftDeleted().count()).toBe(1); + expect(await database.singleQuery(User).count()).toBe(2); + expect(await database.singleQuery(User, user => { + includeOnlySoftDeleted(user); + }).count()).toBe(1); - await database.query(User).filter({ id: 2 }).lift(SoftDeleteQuery).deletedBy('me').deleteOne(); + await database.singleQuery(User, user => { + setDeletedBy('me'); + }).filter({ id: 2 }).deleteOne(); - const q = database.query(User).lift(SoftDeleteQuery).withSoftDeleted(); - expect(Query.is(q, SoftDeleteQuery)).toBe(true); - - if (Query.is(q, SoftDeleteQuery)) { - expect(q.includeSoftDeleted).toBe(true); - } - - expect(await database.query(User).count()).toBe(1); - expect(await database.query(User).lift(SoftDeleteQuery).withSoftDeleted().count()).toBe(3); - const deleted2 = await database.query(User).lift(SoftDeleteQuery).withSoftDeleted().filter({ id: 2 }).findOne(); + expect(await database.singleQuery(User).count()).toBe(1); + expect(await database.singleQuery(User, user => { + includeSoftDeleted(); + }).count()).toBe(3); + const deleted2 = await database.singleQuery(User, user => { + includeSoftDeleted(); + }).filter({ id: 2 }).findOne(); expect(deleted2.id).toBe(2); expect(deleted2.deletedAt).not.toBe(undefined); expect(deleted2.deletedBy).toBe('me'); - await database.query(User).lift(SoftDeleteQuery).filter({ id: 1 }).restoreOne(); + // how to restore? + await database.singleQuery(User).lift(SoftDeleteQuery).filter({ id: 1 }).restoreOne(); - expect(await database.query(User).count()).toBe(2); - expect(await database.query(User).lift(SoftDeleteQuery).withSoftDeleted().count()).toBe(3); + expect(await database.singleQuery(User).count()).toBe(2); + expect(await database.singleQuery(User, user=> includeSoftDeleted()).count()).toBe(3); - await database.query(User).lift(SoftDeleteQuery).restoreMany(); - expect(await database.query(User).count()).toBe(3); - expect(await database.query(User).lift(SoftDeleteQuery).withSoftDeleted().count()).toBe(3); + await database.singleQuery(User).lift(SoftDeleteQuery).restoreMany(); + expect(await database.singleQuery(User).count()).toBe(3); + expect(await database.singleQuery(User, user=> includeSoftDeleted()).count()).toBe(3); //soft delete everything - await database.query(User).deleteMany(); - expect(await database.query(User).count()).toBe(0); - expect(await database.query(User).lift(SoftDeleteQuery).withSoftDeleted().count()).toBe(3); + await database.singleQuery(User).deleteMany(); + expect(await database.singleQuery(User).count()).toBe(0); + expect(await database.singleQuery(User, user=> includeSoftDeleted()).count()).toBe(3); //hard delete everything - await database.query(User).lift(SoftDeleteQuery).hardDeleteMany(); - expect(await database.query(User).count()).toBe(0); - expect(await database.query(User).lift(SoftDeleteQuery).withSoftDeleted().count()).toBe(0); + await database.singleQuery(User, user => { + enableHardDelete(); + }).deleteMany(); + expect(await database.singleQuery(User).count()).toBe(0); + expect(await database.singleQuery(User, user=> includeSoftDeleted()).count()).toBe(0); }); test('soft-delete session', async () => { - @entity.name('softDeleteUser') class User { id: number & PrimaryKey & AutoIncrement = 0; @@ -94,22 +96,24 @@ test('soft-delete session', async () => { await session.commit(); expect(getInstanceStateFromItem(peter).isKnownInDatabase()).toBe(true); - expect(await database.query(User).count()).toBe(3); + expect(await database.singleQuery(User).count()).toBe(3); { - const peterDB = await session.query(User).filter({ id: 1 }).findOne(); + const peterDB = await session.singleQuery(User).filter({ id: 1 }).findOne(); session.remove(peterDB); await session.commit(); expect(getInstanceStateFromItem(peterDB).isKnownInDatabase()).toBe(true); - expect(await database.query(User).count()).toBe(2); - expect(await session.query(User).lift(SoftDeleteQuery).withSoftDeleted().count()).toBe(3); + expect(await database.singleQuery(User).count()).toBe(2); + expect(await session.singleQuery(User, user => { + includeSoftDeleted(); + }).count()).toBe(3); session.from(SoftDeleteSession).restore(peterDB); await session.commit(); - expect(await database.query(User).count()).toBe(3); + expect(await database.singleQuery(User).count()).toBe(3); { - const deletedPeter = await session.query(User).filter(peterDB).findOne(); + const deletedPeter = await session.singleQuery(User).filter(peterDB).findOne(); expect(deletedPeter.deletedAt).toBe(undefined); expect(deletedPeter.deletedBy).toBe(undefined); } @@ -117,8 +121,10 @@ test('soft-delete session', async () => { session.from(SoftDeleteSession).setDeletedBy(User, 'me'); session.remove(peterDB); await session.commit(); - expect(await database.query(User).count()).toBe(2); - const deletedPeter = await session.query(User).lift(SoftDeleteQuery).withSoftDeleted().filter(peterDB).findOne(); + expect(await database.singleQuery(User).count()).toBe(2); + const deletedPeter = await session.singleQuery(User, user => { + includeSoftDeleted(); + }).filter(peterDB).findOne(); expect(deletedPeter.deletedAt).toBeInstanceOf(Date); expect(deletedPeter.deletedBy).toBe('me'); diff --git a/packages/sql/src/sql-adapter.ts b/packages/sql/src/sql-adapter.ts index 848f4a4f9..0b636fb34 100644 --- a/packages/sql/src/sql-adapter.ts +++ b/packages/sql/src/sql-adapter.ts @@ -9,63 +9,45 @@ */ import { - BaseQuery, Database, DatabaseAdapter, - DatabaseAdapterQueryFactory, DatabaseDeleteError, DatabaseEntityRegistry, DatabaseError, DatabaseInsertError, DatabaseLogger, - DatabasePatchError, DatabasePersistence, DatabasePersistenceChangeSet, - DatabaseQueryModel, DatabaseSession, DatabaseTransaction, DatabaseUpdateError, DeleteResult, - FilterQuery, - FindQuery, - GenericQueryResolver, - ItemNotFound, + filter, MigrateOptions, + orderBy, OrmEntity, PatchResult, - Query, - RawFactory, - Replace, - Resolve, + Select, + SelectorResolver, SelectorState, - SORT_ORDER, } from '@deepkit/orm'; -import { AbstractClassType, ClassType, isArray, isClass } from '@deepkit/core'; +import { isClass } from '@deepkit/core'; import { - castFunction, Changes, entity, getPartialSerializeFunction, getSerializeFunction, PrimaryKey, - ReceiveType, ReflectionClass, - ReflectionKind, - resolveReceiveType, - Type, } from '@deepkit/type'; import { DefaultPlatform, SqlPlaceholderStrategy } from './platform/default-platform.js'; -import { Sql, SqlBuilder } from './sql-builder.js'; +import { SqlBuilder } from './sql-builder.js'; import { SqlFormatter } from './sql-formatter.js'; import { DatabaseComparator, DatabaseModel } from './schema/table.js'; import { Stopwatch } from '@deepkit/stopwatch'; -import { getPreparedEntity, PreparedEntity, PreparedField } from './prepare.js'; -import { SQLQuery2Resolver } from './select.js'; +import { getPreparedEntity, PreparedAdapter, PreparedEntity, PreparedField } from './prepare.js'; import { SqlBuilderRegistry } from './sql-builder-registry.js'; -export type SORT_TYPE = SORT_ORDER | { $meta: 'textScore' }; -export type DEEP_SORT = { [P in keyof T]?: SORT_TYPE } & { [P: string]: SORT_TYPE }; - /** * user.address[0].street => [user, address[0].street] * address[0].street => [address, [0].street] @@ -81,21 +63,6 @@ export function asAliasName(path: string): string { return path.replace(/[\[\]\.]/g, '__'); } -export class SQLQueryModel extends DatabaseQueryModel, DEEP_SORT> { - where?: SqlQuery; - sqlSelect?: SqlQuery; - - clone(parentQuery: BaseQuery): this { - const m = super.clone(parentQuery); - m.where = this.where ? this.where.clone() : undefined; - m.sqlSelect = this.sqlSelect ? this.sqlSelect.clone() : undefined; - return m; - } - - isPartial(): boolean { - return super.isPartial() || !!this.sqlSelect; - } -} export abstract class SQLStatement { abstract get(params?: any[]): Promise; @@ -196,7 +163,7 @@ function buildSetFromChanges(platform: DefaultPlatform, classSchema: ReflectionC return set; } -export class SQLQueryResolver extends GenericQueryResolver { +export class SQLQueryResolver extends SelectorResolver { protected tableId = this.platform.getTableIdentifier.bind(this.platform); protected quoteIdentifier = this.platform.quoteIdentifier.bind(this.platform); protected quote = this.platform.quoteValue.bind(this.platform); @@ -204,16 +171,15 @@ export class SQLQueryResolver extends GenericQueryResolver< constructor( protected connectionPool: SQLConnectionPool, protected platform: DefaultPlatform, - classSchema: ReflectionClass, protected adapter: SQLDatabaseAdapter, session: DatabaseSession, ) { - super(classSchema, session); + super(session); } - protected createFormatter(withIdentityMap: boolean = false) { + protected createFormatter(state: SelectorState, withIdentityMap: boolean = false) { return new SqlFormatter( - this.classSchema, + state.schema, this.platform.serializer, this.session.getHydrator(), withIdentityMap ? this.session.identityMap : undefined, @@ -232,7 +198,7 @@ export class SQLQueryResolver extends GenericQueryResolver< return error; } - async count(model: SQLQueryModel): Promise { + async count(model: SelectorState): Promise { const sqlBuilderFrame = this.session.stopwatch ? this.session.stopwatch.start('SQL Builder') : undefined; const sqlBuilder = new SqlBuilder(this.adapter); const sql = sqlBuilder.buildSql(model, 'SELECT COUNT(*) as count'); @@ -254,8 +220,8 @@ export class SQLQueryResolver extends GenericQueryResolver< } } - async delete(model: SQLQueryModel, deleteResult: DeleteResult): Promise { - if (model.hasJoins()) throw new Error('Delete with joins not supported. Fetch first the ids then delete.'); + async delete(model: SelectorState, deleteResult: DeleteResult): Promise { + if (model.joins?.length) throw new Error('Delete with joins not supported. Fetch first the ids then delete.'); const sqlBuilderFrame = this.session.stopwatch ? this.session.stopwatch.start('SQL Builder') : undefined; const sqlBuilder = new SqlBuilder(this.adapter); @@ -271,7 +237,7 @@ export class SQLQueryResolver extends GenericQueryResolver< deleteResult.modified = await connection.getChanges(); //todo, implement deleteResult.primaryKeys } catch (error: any) { - error = new DatabaseDeleteError(this.classSchema, `Could not delete ${this.classSchema.getClassName()} in database`, { cause: error }); + error = new DatabaseDeleteError(model.schema, `Could not delete ${model.schema.getClassName()} in database`, { cause: error }); error.query = model; throw this.handleSpecificError(error); } finally { @@ -279,10 +245,10 @@ export class SQLQueryResolver extends GenericQueryResolver< } } - async find(model: SQLQueryModel): Promise { + async find(model: SelectorState): Promise { const sqlBuilderFrame = this.session.stopwatch ? this.session.stopwatch.start('SQL Builder') : undefined; const sqlBuilder = new SqlBuilder(this.adapter); - const sql = sqlBuilder.select(this.classSchema, model); + const sql = sqlBuilder.select(model); if (sqlBuilderFrame) sqlBuilderFrame.end(); const connectionFrame = this.session.stopwatch ? this.session.stopwatch.start('Connection acquisition') : undefined; @@ -294,23 +260,23 @@ export class SQLQueryResolver extends GenericQueryResolver< rows = await connection.execAndReturnAll(sql.sql, sql.params); } catch (error: any) { error = this.handleSpecificError(error); - console.log(sql.sql, sql.params) - throw new DatabaseError(`Could not query ${this.classSchema.getClassName()} due to SQL error ${error.message}`, { cause: error }); + console.log(sql.sql, sql.params); + throw new DatabaseError(`Could not query ${model.schema.getClassName()} due to SQL error ${error.message}`, { cause: error }); } finally { connection.release(); } const formatterFrame = this.session.stopwatch ? this.session.stopwatch.start('Formatter') : undefined; const results: T[] = []; - if (model.isAggregate() || model.sqlSelect) { - //when aggregate the field types could be completely different, so don't normalize - for (const row of rows) results.push(row); //mysql returns not a real array, so we have to iterate - if (formatterFrame) formatterFrame.end(); - return results; - } - const formatter = this.createFormatter(model.withIdentityMap); - if (model.hasJoins()) { - const converted = sqlBuilder.convertRows(this.classSchema, model, rows); + // if (model.isAggregate() || model.sqlSelect) { + // //when aggregate the field types could be completely different, so don't normalize + // for (const row of rows) results.push(row); //mysql returns not a real array, so we have to iterate + // if (formatterFrame) formatterFrame.end(); + // return results; + // } + const formatter = this.createFormatter(model); + if (model.joins?.length) { + const converted = sqlBuilder.convertRows(model.schema, model, rows); for (const row of converted) results.push(formatter.hydrate(model, row)); } else { for (const row of rows) results.push(formatter.hydrate(model, row)); @@ -320,38 +286,38 @@ export class SQLQueryResolver extends GenericQueryResolver< return results; } - async findOneOrUndefined(model: SQLQueryModel): Promise { + async findOneOrUndefined(model: SelectorState): Promise { //when joins are used, it's important to fetch all rows const items = await this.find(model); return items[0]; } - async has(model: SQLQueryModel): Promise { + async has(model: SelectorState): Promise { return await this.count(model) > 0; } - async patch(model: SQLQueryModel, changes: Changes, patchResult: PatchResult): Promise { + async patch(model: SelectorState, changes: Changes, patchResult: PatchResult): Promise { //this is the default SQL implementation that does not support RETURNING functionality (e.g. returning values from changes.$inc) const sqlBuilderFrame = this.session.stopwatch ? this.session.stopwatch.start('SQL Builder') : undefined; - const set = buildSetFromChanges(this.platform, this.classSchema, changes); + const set = buildSetFromChanges(this.platform, model.schema, changes); const sqlBuilder = new SqlBuilder(this.adapter); - const sql = sqlBuilder.update(this.classSchema, model, set); - if (sqlBuilderFrame) sqlBuilderFrame.end(); - - const connectionFrame = this.session.stopwatch ? this.session.stopwatch.start('Connection acquisition') : undefined; - const connection = await this.connectionPool.getConnection(this.session.logger, this.session.assignedTransaction, this.session.stopwatch); - if (connectionFrame) connectionFrame.end(); - - try { - await connection.run(sql.sql, sql.params); - patchResult.modified = await connection.getChanges(); - } catch (error: any) { - error = new DatabasePatchError(this.classSchema, model, changes, `Could not patch ${this.classSchema.getClassName()} in database`, { cause: error }); - throw this.handleSpecificError(error); - } finally { - connection.release(); - } + // const sql = sqlBuilder.update(this.classSchema, model, set); + // if (sqlBuilderFrame) sqlBuilderFrame.end(); + // + // const connectionFrame = this.session.stopwatch ? this.session.stopwatch.start('Connection acquisition') : undefined; + // const connection = await this.connectionPool.getConnection(this.session.logger, this.session.assignedTransaction, this.session.stopwatch); + // if (connectionFrame) connectionFrame.end(); + // + // try { + // await connection.run(sql.sql, sql.params); + // patchResult.modified = await connection.getChanges(); + // } catch (error: any) { + // error = new DatabasePatchError(this.classSchema, model, changes, `Could not patch ${this.classSchema.getClassName()} in database`, { cause: error }); + // throw this.handleSpecificError(error); + // } finally { + // connection.release(); + // } } } @@ -437,57 +403,6 @@ export function sql(strings: TemplateStringsArray, ...params: ReadonlyArray return new SqlQuery(parts); } -export class SQLDatabaseQuery extends Query { - public model: SQLQueryModel = new SQLQueryModel(); - - constructor( - classSchema: ReflectionClass, - protected databaseSession: DatabaseSession, - protected resolver: SQLQueryResolver, - ) { - super(classSchema, databaseSession, resolver); - if (!databaseSession.withIdentityMap) this.model.withIdentityMap = false; - } - - /** - * Adds raw SQL to the where clause of the query. - * If there is a `filter()` set as well, the where is added after the filter using AND. - * - * ``` - * database.query(User).where(`id > ${id}`).find(); - * ``` - * - * Use `${identifier('name')} = ${'Peter'}` for column names that need to be quoted. - */ - where(sql: SqlQuery): this { - const c = this.clone(); - c.model.where = sql; - return c as any; - } - - /** - * Adds additional selects to the query. - * Automatically converts the query to a partial (no class instances). - */ - sqlSelect(sql: SqlQuery): Replace, any>> { - const c = this.clone(); - c.model.sqlSelect = sql; - return c as any; - } -} - -export class SQLDatabaseQueryFactory extends DatabaseAdapterQueryFactory { - constructor(protected connectionPool: SQLConnectionPool, protected platform: DefaultPlatform, protected databaseSession: DatabaseSession) { - super(); - } - - createQuery(classType?: ReceiveType | ClassType | AbstractClassType | ReflectionClass): SQLDatabaseQuery { - return new SQLDatabaseQuery(ReflectionClass.from(classType), this.databaseSession, - new SQLQueryResolver(this.connectionPool, this.platform, ReflectionClass.from(classType), this.databaseSession.adapter, this.databaseSession), - ); - } -} - @entity.name('migration_state') export class MigrationStateEntity { created: Date = new Date; @@ -508,13 +423,17 @@ export class SqlMigrationHandler { public async removeMigrationVersion(version: number): Promise { const session = this.database.createSession(); - await session.query(MigrationStateEntity).filter({ version }).deleteOne(); + await session.query2((m: Select) => { + filter(m, { version }); + }).deleteOne(); } public async getLatestMigrationVersion(): Promise { const session = this.database.createSession(); try { - const version = await session.query(MigrationStateEntity).sort({ version: 'desc' }).findOneOrUndefined(); + const version = await session.query2((m: Select) => { + orderBy(m.version, 'desc'); + }).findOneOrUndefined(); return version ? version.version : 0; } catch (error) { const connection = await this.database.adapter.connectionPool.getConnection(); @@ -532,110 +451,17 @@ export class SqlMigrationHandler { } } -export class RawQuery implements FindQuery { - constructor( - protected session: DatabaseSession, - protected connectionPool: SQLConnectionPool, - protected platform: DefaultPlatform, - protected sql: SqlQuery, - protected type: Type, - ) { - } - - /** - * Executes the raw query and returns nothing. - */ - async execute(): Promise { - const sql = this.sql.convertToSQL(this.platform, new this.platform.placeholderStrategy); - const connection = await this.connectionPool.getConnection(this.session.logger, this.session.assignedTransaction, this.session.stopwatch); - - try { - return await connection.run(sql.sql, sql.params); - } finally { - connection.release(); - } - } - - /** - * Returns the SQL statement with placeholders replaced with the actual values. - */ - getSql(): SqlStatement { - return this.sql.convertToSQL(this.platform, new this.platform.placeholderStrategy); - } - - /** - * Returns the raw result of a single row. - * - * Note that this does not resolve/map joins. Use the regular database.query() for that. - */ - async findOneOrUndefined(): Promise { - return (await this.find())[0]; - } - - /** - * Note that this does not resolve/map joins. Use the regular database.query() for that. - */ - async findOne(): Promise { - const item = await this.findOneOrUndefined(); - if (!item) throw new ItemNotFound('Item not found'); - return item; - } - - /** - * Returns the full result of a raw query. - * - * Note that this does not resolve/map joins. Use the regular database.query() for that. - */ - async find(): Promise { - const sql = this.sql.convertToSQL(this.platform, new this.platform.placeholderStrategy); - const connection = await this.connectionPool.getConnection(this.session.logger, this.session.assignedTransaction, this.session.stopwatch); - - try { - const caster = castFunction(undefined, undefined, this.type); - const res = await connection.execAndReturnAll(sql.sql, sql.params); - return (isArray(res) ? [...res] : []).map(v => caster(v)) as T[]; - } finally { - connection.release(); - } - } -} - -export class SqlRawFactory implements RawFactory<[SqlQuery]> { - constructor( - protected session: DatabaseSession, - protected connectionPool: SQLConnectionPool, - protected platform: DefaultPlatform, - ) { - } - - create(sql: SqlQuery, type?: ReceiveType): RawQuery { - type = type ? resolveReceiveType(type) : { kind: ReflectionKind.any }; - return new RawQuery(this.session, this.connectionPool, this.platform, sql, type); - } -} - -export abstract class SQLDatabaseAdapter extends DatabaseAdapter { +export abstract class SQLDatabaseAdapter extends DatabaseAdapter implements PreparedAdapter { public abstract platform: DefaultPlatform; public abstract connectionPool: SQLConnectionPool; public preparedEntities = new Map, PreparedEntity>(); - - abstract queryFactory(databaseSession: DatabaseSession): SQLDatabaseQueryFactory; + public builderRegistry: SqlBuilderRegistry = new SqlBuilderRegistry; abstract createPersistence(databaseSession: DatabaseSession): SQLPersistence; abstract getSchemaName(): string; - builderRegistry: SqlBuilderRegistry = new SqlBuilderRegistry; - - rawFactory(session: DatabaseSession): SqlRawFactory { - return new SqlRawFactory(session, this.connectionPool, this.platform); - } - - createQuery2Resolver(model: SelectorState, session: DatabaseSession) { - return new SQLQuery2Resolver(model, session, this.connectionPool); - } - async getInsertBatchSize(schema: ReflectionClass): Promise { return Math.floor(30000 / schema.getProperties().length); } @@ -648,11 +474,6 @@ export abstract class SQLDatabaseAdapter extends DatabaseAdapter { return true; } - createSelectSql(query: Query): Sql { - const sqlBuilder = new SqlBuilder(this); - return sqlBuilder.select(query.classSchema, query.model as any); - } - /** * Creates (and re-creates already existing) tables in the database. * This is only for testing purposes useful. @@ -678,7 +499,9 @@ export abstract class SQLDatabaseAdapter extends DatabaseAdapter { } } - public async getMigrations(options: MigrateOptions, entityRegistry: DatabaseEntityRegistry): Promise<{ [name: string]: { sql: string[], diff: string } }> { + public async getMigrations(options: MigrateOptions, entityRegistry: DatabaseEntityRegistry): Promise<{ + [name: string]: { sql: string[], diff: string } + }> { const migrations: { [name: string]: { sql: string[], diff: string } } = {}; const connection = await this.connectionPool.getConnection(); diff --git a/packages/type/src/reflection/processor.ts b/packages/type/src/reflection/processor.ts index 04eb44a9e..65feff9e8 100644 --- a/packages/type/src/reflection/processor.ts +++ b/packages/type/src/reflection/processor.ts @@ -182,6 +182,7 @@ interface Program { //used in inline-only programs like `typeOf()` where we want the type of (cached) MyAlias and not a new reference. directReturn?: boolean; object?: ClassType | Function | Packed | any; + keepReference?: boolean; } function assignResult(ref: Type, result: T, assignParents: boolean): T { @@ -230,6 +231,7 @@ function createProgram(options: Partial, inputs?: RuntimeStackEntry[]): // typeParameters: [], // previous: undefined, object: options.object, + keepReference: options.keepReference, }; if (options.initialStack) for (let i = 0; i < options.initialStack.length; i++) { @@ -307,6 +309,8 @@ export interface ReflectOptions { */ reuseCached?: boolean; + keepReference?: boolean; + inline?: boolean; typeName?: string; @@ -363,7 +367,7 @@ export class Processor { //functions without any type annotations do not have the overhead of an assigned __type return { kind: ReflectionKind.function, - function: object, name: object.name, + name: object.name, parameters: [], return: { kind: ReflectionKind.any }, }; } @@ -417,7 +421,7 @@ export class Processor { // process.stdout.write(`${options.reuseCached} Cache miss ${stringifyValueWithType(object)}(...${inputs.length})\n`); const pack = packed.__unpack ||= unpack(packed); - const program = createProgram({ ops: pack.ops, initialStack: pack.stack, object }, inputs); + const program = createProgram({ ops: pack.ops, initialStack: pack.stack, object, keepReference: options.keepReference }, inputs); const type = this.runProgram(program); type.typeName ||= options.typeName; @@ -476,7 +480,7 @@ export class Processor { programLoop: while (this.program.active) { const program = this.program; - // process.stdout.write(`jump to program: ${stringifyValueWithType(program.object)}\n`); + // process.stdout.write(`jump to program: ${stringifyValueWithType(program.object)} (keepReference=${program.keepReference})\n`); for (; program.program < program.end; program.program++) { const op = program.ops[program.program]; @@ -706,7 +710,7 @@ export class Processor { if (op === ReflectionOp.classReference) { this.pushType({ kind: ReflectionKind.class, classType: classOrFunction, typeArguments: inputs, types: [] }); } else if (op === ReflectionOp.functionReference) { - this.pushType({ kind: ReflectionKind.function, function: classOrFunction, parameters: [], return: { kind: ReflectionKind.unknown } }); + this.pushType({ kind: ReflectionKind.function, function: classOrFunction, name: classOrFunction.name || undefined, parameters: [], return: { kind: ReflectionKind.unknown } }); } } else { //when it's just a simple reference resolution like typeOf() then enable cache re-use (so always the same type is returned) @@ -1267,8 +1271,11 @@ export class Processor { result.classType = program.object; applyClassDecorators(result); } - if (result.kind === ReflectionKind.function && !result.function) { - result.function = program.object; + if (result.kind === ReflectionKind.function) { + if (!result.name && program.object.name) result.name = program.object.name; + if (program.keepReference) { + result.function = program.object; + } } } if (!program.directReturn) { @@ -1774,7 +1781,7 @@ export function typeInfer(value: any): Type { //with emitted types: function or class //don't use resolveRuntimeType since we don't allow cache here // console.log('typeInfer of', value.name); - return Processor.get().reflect(value, undefined, { inline: true }) as Type; + return Processor.get().reflect(value, undefined, { inline: true, keepReference: true }) as Type; } if (isClass(value)) { @@ -1782,7 +1789,7 @@ export function typeInfer(value: any): Type { return { kind: ReflectionKind.class, classType: value as ClassType, types: [] }; } - return { kind: ReflectionKind.function, function: value, name: value.name, return: { kind: ReflectionKind.any }, parameters: [] }; + return { kind: ReflectionKind.function, function: value, name: value.name || undefined, return: { kind: ReflectionKind.any }, parameters: [] }; } else if (isArray(value)) { return { kind: ReflectionKind.array, type: typeInferFromContainer(value) }; } else if ('object' === typeof value) { diff --git a/packages/type/src/reflection/reflection.ts b/packages/type/src/reflection/reflection.ts index 657bc28ef..38cc550bf 100644 --- a/packages/type/src/reflection/reflection.ts +++ b/packages/type/src/reflection/reflection.ts @@ -117,7 +117,7 @@ export function resolveReceiveType(type?: Packed | Type | ClassType | AbstractCl return resolveRuntimeType(type) as Type; } if (isFunction(type)) { - return (type as any).__cached_type = { kind: ReflectionKind.function, function: type as any, return: { kind: ReflectionKind.any }, parameters: [] } as any; + return (type as any).__cached_type = { kind: ReflectionKind.function, return: { kind: ReflectionKind.any }, parameters: [] } as any; } return resolvePacked(type, undefined, { reuseCached: true }); } @@ -550,7 +550,7 @@ export function resolveClassType(type: Type): ReflectionClass { throw new Error(`Cant resolve ReflectionClass of type ${type.kind} since its not a class or object literal`); } - return ReflectionClass.fromType(type); + return ReflectionClass.from(type); } /** @@ -973,13 +973,6 @@ export class ReflectionClass { this.collectionName = parent.collectionName; this.databaseSchemaName = parent.databaseSchemaName; this.description = parent.description; - - for (const member of parent.getProperties()) { - this.registerProperty(member.clone(this)); - } - for (const member of parent.getMethods()) { - this.registerMethod(member.clone(this)); - } } for (const member of type.types) { @@ -1295,39 +1288,35 @@ export class ReflectionClass { const jit = getTypeJitContainer(type); if (jit.reflectionClass) return jit.reflectionClass; - jit.reflectionClass = new ReflectionClass(type); + let parentReflectionClass: ReflectionClass | undefined; + + if (type.kind === ReflectionKind.class) { + const parentProto = Object.getPrototypeOf(type.classType.prototype); + parentReflectionClass = parentProto && parentProto.constructor !== Object ? ReflectionClass.from(parentProto, type.extendsArguments) : undefined; + } + + jit.reflectionClass = new ReflectionClass(type, parentReflectionClass); return jit.reflectionClass; } - static from(classTypeIn?: ReceiveType | AbstractClassType | TypeClass | TypeObjectLiteral | ReflectionClass): ReflectionClass { + static from(classTypeIn?: ReceiveType | AbstractClassType | TypeClass | TypeObjectLiteral | ReflectionClass, args: any[] = []): ReflectionClass { if (!classTypeIn) throw new Error(`No type given in ReflectionClass.from`); if (isArray(classTypeIn)) classTypeIn = resolveReceiveType(classTypeIn); - if (classTypeIn instanceof ReflectionClass) return classTypeIn; - if (isType(classTypeIn)) { - if (classTypeIn.kind === ReflectionKind.objectLiteral || classTypeIn.kind === ReflectionKind.class) { - const jit = getTypeJitContainer(classTypeIn); - if (jit.reflectionClass) return jit.reflectionClass; - return jit.reflectionClass = new ReflectionClass(classTypeIn); - } - throw new Error(`TypeClass or TypeObjectLiteral expected, not ${ReflectionKind[classTypeIn.kind]}`); - } + if (classTypeIn instanceof ReflectionClass) return classTypeIn; + if (isType(classTypeIn)) return ReflectionClass.fromType(classTypeIn); - const classType = isType(classTypeIn) - ? (classTypeIn as TypeClass).classType - : (classTypeIn as any)['prototype'] - ? classTypeIn as ClassType - : classTypeIn.constructor as ClassType; + const classType = isType(classTypeIn) ? (classTypeIn as TypeClass).classType : (classTypeIn as any)['prototype'] ? classTypeIn as ClassType : classTypeIn.constructor as ClassType; if (!classType.prototype.hasOwnProperty(reflectionClassSymbol)) { Object.defineProperty(classType.prototype, reflectionClassSymbol, { writable: true, enumerable: false }); } - if (classType.prototype[reflectionClassSymbol]) { + if (classType.prototype[reflectionClassSymbol] && args.length === 0) { return classType.prototype[reflectionClassSymbol]; } - const type = isType(classTypeIn) ? classTypeIn as TypeClass : ('__type' in classType ? resolveRuntimeType(classType) : { + const type = isType(classTypeIn) ? classTypeIn as TypeClass : ('__type' in classType ? resolveRuntimeType(classType, args) : { kind: ReflectionKind.class, classType, types: [], @@ -1337,12 +1326,17 @@ export class ReflectionClass { throw new Error(`Given class is not a class but kind ${ReflectionKind[type.kind]}. classType: ${stringifyValueWithType(classType)}`); } + const parentProto = Object.getPrototypeOf(classType.prototype); - const parentReflectionClass: ReflectionClass | undefined = parentProto && parentProto.constructor !== Object ? ReflectionClass.from(parentProto) : undefined; + const parentReflectionClass: ReflectionClass | undefined = parentProto && parentProto.constructor !== Object ? ReflectionClass.from(parentProto, type.extendsArguments) : undefined; const reflectionClass = new ReflectionClass(type, parentReflectionClass); - classType.prototype[reflectionClassSymbol] = reflectionClass; - return reflectionClass; + if (args.length === 0) { + classType.prototype[reflectionClassSymbol] = reflectionClass; + return reflectionClass; + } else { + return reflectionClass; + } } getIndexSignatures() { diff --git a/packages/type/src/reflection/type.ts b/packages/type/src/reflection/type.ts index f41f79ea8..4c9de1d58 100644 --- a/packages/type/src/reflection/type.ts +++ b/packages/type/src/reflection/type.ts @@ -320,7 +320,12 @@ export interface TypeFunction extends TypeAnnotations { parent?: Type; name?: number | string | symbol, description?: string; - function?: Function; //reference to the real function if available + /** + * Reference to the real function if available. + * This is only available in `typeof xy` and decorator scenarios, + * to avoid memory leaks or blocking garbage collection. + */ + function?: Function; parameters: TypeParameter[]; return: Type; } @@ -2304,7 +2309,7 @@ export function typeToObject(type?: Type, state: { stack: Type[] } = { stack: [] case ReflectionKind.intersection: return typeToObject(type.types[0]); case ReflectionKind.function: - return type.function; + return () => {}; case ReflectionKind.array: return [typeToObject(type.type)]; case ReflectionKind.tuple: diff --git a/packages/type/tests/integration2.spec.ts b/packages/type/tests/integration2.spec.ts index eaecfe55f..52c777288 100644 --- a/packages/type/tests/integration2.spec.ts +++ b/packages/type/tests/integration2.spec.ts @@ -48,7 +48,7 @@ import { TypeNumber, TypeObjectLiteral, TypeTuple, - Unique + Unique, } from '../src/reflection/type.js'; import { TypeNumberBrand } from '@deepkit/type-spec'; import { validate, ValidatorError } from '../src/validator.js'; @@ -485,7 +485,6 @@ test('function', () => { expectEqualType(type, { kind: ReflectionKind.function, name: 'pad', - function: pad, parameters: [ { kind: ReflectionKind.parameter, name: 'text', type: { kind: ReflectionKind.string } }, { kind: ReflectionKind.parameter, name: 'size', type: { kind: ReflectionKind.number } }, diff --git a/packages/type/tests/processor.spec.ts b/packages/type/tests/processor.spec.ts index c8043020d..0786dda5b 100644 --- a/packages/type/tests/processor.spec.ts +++ b/packages/type/tests/processor.spec.ts @@ -106,7 +106,7 @@ test('extends fn', () => { expect(isExtendable( { kind: ReflectionKind.function, return: { kind: ReflectionKind.literal, literal: true }, parameters: [] }, - { kind: ReflectionKind.function, function: Function, return: { kind: ReflectionKind.unknown }, parameters: [] } + { kind: ReflectionKind.function, return: { kind: ReflectionKind.unknown }, parameters: [] } )).toBe(true); }); diff --git a/packages/type/tests/type.spec.ts b/packages/type/tests/type.spec.ts index fd99b6507..a892e4583 100644 --- a/packages/type/tests/type.spec.ts +++ b/packages/type/tests/type.spec.ts @@ -1451,9 +1451,9 @@ test('function returns self reference', () => { const type = reflect(Option); assertType(type, ReflectionKind.function); - expect(type.function).toBe(Option); + expect(type.name).toBe('Option'); assertType(type.return, ReflectionKind.function); - expect(type.return.function).toBe(Option); + expect(type.return.name).toBe('Option'); }); test('no runtime types', () => { @@ -1482,7 +1482,7 @@ test('arrow function returns self reference', () => { const type = reflect(Option); assertType(type, ReflectionKind.function); - expect(type.function).toBe(Option); + expect(type.name).toBe(undefined); //we need to find out why TS does resolve Option in arrow function to the class and not the variable assertType(type.return, ReflectionKind.class); diff --git a/packages/type/tests/validation.spec.ts b/packages/type/tests/validation.spec.ts index 357b75185..032fb4ada 100644 --- a/packages/type/tests/validation.spec.ts +++ b/packages/type/tests/validation.spec.ts @@ -1,7 +1,7 @@ import { expect, jest, test } from '@jest/globals'; import { Email, MaxLength, MinLength, Positive, Validate, validate, validates, ValidatorError } from '../src/validator.js'; import { assert, is } from '../src/typeguard.js'; -import { AutoIncrement, Excluded, Group, integer, PrimaryKey, Type, Unique } from '../src/reflection/type.js'; +import { assertType, AutoIncrement, Excluded, Group, integer, PrimaryKey, ReflectionKind, Type, Unique, validationAnnotation } from '../src/reflection/type.js'; import { t } from '../src/decorator.js'; import { ReflectionClass, typeOf } from '../src/reflection/reflection.js'; import { cast, castFunction, validatedDeserialize } from '../src/serializer-facade.js'; @@ -45,6 +45,11 @@ test('custom validator pre defined', () => { }; } + const t = typeOf(); + assertType(t, ReflectionKind.function); + expect(t.name).toBe('startsWith'); + expect(t.function).toBe(startsWith); + const startsWithA = startsWith('a'); type MyType = string & Validate; @@ -73,14 +78,40 @@ test('multiple custom validators with identical signatures', () => { const validator1: (value: any) => void = jest.fn(); const validator2: (value: any) => void = jest.fn(); + (validator1 as any).name1 = 'validator1'; + (validator2 as any).name1 = 'validator2'; + + const t1 = typeOf(); + const t2 = typeOf(); + + assertType(t1, ReflectionKind.function); + assertType(t2, ReflectionKind.function); + + expect(t1.function).not.toBe(undefined); + expect(t1.function !== t2.function).toBe(true); + + const v1 = typeOf>(); + const v2 = typeOf>(); + + const v1A = validationAnnotation.getAnnotations(v1)[0]; + assertType(v1A.args[0], ReflectionKind.function); + expect(v1A.args[0].function === validator1).toBe(true); + + const v2A = validationAnnotation.getAnnotations(v2)[0]; + assertType(v2A.args[0], ReflectionKind.function); + expect(v2A.args[0].function === validator2).toBe(true); + + type TypeA = string; + type TypeB = string; + type MyType = { - a: string & Validate; - b: string & Validate; + a: TypeA & Validate; + b: TypeB & Validate; } expect(is({ a: 'a', b: 'b' })).toEqual(true); - expect(validator1).toHaveBeenCalledTimes(1); expect(validator2).toHaveBeenCalledTimes(1); + expect(validator1).toHaveBeenCalledTimes(1); }); test('decorator validator', () => {