import type { DB as DefaultDB, PrimaryFieldType } from "core"; import { $console } from "core"; import { type EmitsEvents, EventManager } from "core/events"; import { type SelectQueryBuilder, sql } from "kysely"; import { InvalidSearchParamsException } from "../../errors"; import { MutatorEvents, RepositoryEvents } from "../../events"; import { type RepoQuery, getRepoQueryTemplate } from "data/server/query"; import { type Entity, type EntityData, type EntityManager, WhereBuilder, WithBuilder, } from "../index"; import { JoinBuilder } from "./JoinBuilder"; import { ensureInt } from "core/utils"; export type RepositoryQB = SelectQueryBuilder; export type RepositoryRawResponse = { sql: string; parameters: any[]; result: EntityData[]; }; export type RepositoryResponse = RepositoryRawResponse & { entity: Entity; data: T; meta: { items: number; total?: number; count?: number; time?: number; query?: { sql: string; parameters: readonly any[]; }; }; }; export type RepositoryCountResponse = RepositoryRawResponse & { count: number; }; export type RepositoryExistsResponse = RepositoryRawResponse & { exists: boolean; }; export type RepositoryOptions = { silent?: boolean; includeCounts?: boolean; emgr?: EventManager; }; export class Repository implements EmitsEvents { static readonly Events = RepositoryEvents; emgr: EventManager; constructor( public em: EntityManager, public entity: Entity, protected options: RepositoryOptions = {}, ) { this.emgr = options?.emgr ?? new EventManager(MutatorEvents); } private cloneFor(entity: Entity, opts: Partial = {}) { return new Repository(this.em, this.em.entity(entity), { ...this.options, ...opts, emgr: this.emgr, }); } private get conn() { return this.em.connection.kysely; } private checkIndex(entity: string, field: string, clause: string) { const indexed = this.em.getIndexedFields(entity).map((f) => f.name); if (!indexed.includes(field) && this.options?.silent !== true) { $console.warn(`Field "${entity}.${field}" used in "${clause}" is not indexed`); } } getValidOptions(options?: RepoQuery): RepoQuery { const entity = this.entity; // @todo: if not cloned deep, it will keep references and error if multiple requests come in const validated = { ...structuredClone(getRepoQueryTemplate()), sort: entity.getDefaultSort(), select: entity.getSelect(), } satisfies Required; if (!options) return validated; if (options.sort) { if (!validated.select.includes(options.sort.by)) { throw new InvalidSearchParamsException(`Invalid sort field "${options.sort.by}"`); } if (!["asc", "desc"].includes(options.sort.dir!)) { throw new InvalidSearchParamsException(`Invalid sort direction "${options.sort.dir}"`); } this.checkIndex(entity.name, options.sort.by, "sort"); validated.sort = { dir: "asc", ...options.sort, }; } if (options.select && options.select.length > 0) { const invalid = options.select.filter((field) => !validated.select.includes(field)); if (invalid.length > 0) { throw new InvalidSearchParamsException( `Invalid select field(s): ${invalid.join(", ")}`, ).context({ entity: entity.name, valid: validated.select, }); } validated.select = options.select; } if (options.with) { const depth = WithBuilder.validateWiths(this.em, entity.name, options.with); // @todo: determine allowed depth validated.with = options.with; } if (options.join && options.join.length > 0) { for (const entry of options.join) { const related = this.em.relationOf(entity.name, entry); if (!related) { throw new InvalidSearchParamsException( `JOIN: "${entry}" is not a relation of "${entity.name}"`, ); } validated.join.push(entry); } } if (options.where) { // @todo: auto-alias base entity when using joins! otherwise "id" is ambiguous const aliases = [entity.name]; if (validated.join.length > 0) { aliases.push(...JoinBuilder.getJoinedEntityNames(this.em, entity, validated.join)); } // @todo: add tests for aliased fields in where const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => { if (field.includes(".")) { const [alias, prop] = field.split(".") as [string, string]; if (!aliases.includes(alias)) { return true; } this.checkIndex(alias, prop, "where"); return !this.em.entity(alias).getField(prop); } this.checkIndex(entity.name, field, "where"); return typeof entity.getField(field) === "undefined"; }); if (invalid.length > 0) { throw new InvalidSearchParamsException( `Invalid where field(s): ${invalid.join(", ")}`, ).context({ aliases, entity: entity.name }); } validated.where = options.where; } // pass unfiltered if (options.limit) validated.limit = options.limit; if (options.offset) validated.offset = options.offset; return validated; } protected async executeQb(qb: RepositoryQB) { const compiled = qb.compile(); if (this.options?.silent !== true) { $console.debug(`Repository: query\n${compiled.sql}\n`, compiled.parameters); } let result: any; try { result = await qb.execute(); } catch (e) { if (this.options?.silent !== true) { if (e instanceof Error) { $console.error("[ERROR] Repository.executeQb", e.message); } throw e; } } return { result, sql: compiled.sql, parameters: [...compiled.parameters], }; } protected async performQuery(qb: RepositoryQB): Promise { const entity = this.entity; const compiled = qb.compile(); const payload = { entity, sql: compiled.sql, parameters: [...compiled.parameters], result: [], data: [], meta: { total: 0, count: 0, items: 0, time: 0, query: { sql: compiled.sql, parameters: compiled.parameters }, }, }; // don't batch (add counts) if `includeCounts` is set to false // or when explicitly set to true and batching is not supported if ( this.options?.includeCounts === false || (this.options?.includeCounts === true && !this.em.connection.supports("batching")) ) { const start = performance.now(); const res = await this.executeQb(qb); const time = Number.parseFloat((performance.now() - start).toFixed(2)); const result = res.result ?? []; const data = this.em.hydrate(entity.name, result); return { ...payload, result, data, meta: { ...payload.meta, total: undefined, count: undefined, items: data.length, time, }, }; } if (this.options?.silent !== true) { $console.debug(`Repository: query\n${compiled.sql}\n`, compiled.parameters); } const selector = (as = "count") => this.conn.fn.countAll().as(as); const countQuery = qb .clearSelect() .select(selector()) .clearLimit() .clearOffset() .clearGroupBy() .clearOrderBy(); const totalQuery = this.conn.selectFrom(entity.name).select(selector()); try { const start = performance.now(); const [_count, _total, result] = await this.em.connection.batchQuery([ countQuery, totalQuery, qb, ]); const time = Number.parseFloat((performance.now() - start).toFixed(2)); const data = this.em.hydrate(entity.name, result); return { ...payload, result, data, meta: { ...payload.meta, // parsing is important since pg returns string total: ensureInt(_total[0]?.count), count: ensureInt(_count[0]?.count), items: result.length, time, }, }; } catch (e) { if (this.options?.silent !== true) { if (e instanceof Error) { $console.error("[ERROR] Repository.performQuery", e.message); } throw e; } else { return payload; } } } private async triggerFindBefore(entity: Entity, options: RepoQuery): Promise { const event = options.limit === 1 ? Repository.Events.RepositoryFindOneBefore : Repository.Events.RepositoryFindManyBefore; await this.emgr.emit(new event({ entity, options })); } private async triggerFindAfter( entity: Entity, options: RepoQuery, data: EntityData[], ): Promise { if (options.limit === 1) { await this.emgr.emit( new Repository.Events.RepositoryFindOneAfter({ entity, options, data: data[0]! }), ); } else { await this.emgr.emit( new Repository.Events.RepositoryFindManyAfter({ entity, options, data }), ); } } protected async single( qb: RepositoryQB, options: RepoQuery, ): Promise> { await this.triggerFindBefore(this.entity, options); const { data, ...response } = await this.performQuery(qb); await this.triggerFindAfter(this.entity, options, data); return { ...response, data: data[0]! }; } addOptionsToQueryBuilder( _qb?: RepositoryQB, _options?: Partial, config?: { validate?: boolean; ignore?: (keyof RepoQuery)[]; alias?: string; defaults?: Pick; }, ) { const entity = this.entity; let qb = _qb ?? (this.conn.selectFrom(entity.name) as RepositoryQB); const options = config?.validate !== false ? this.getValidOptions(_options) : _options; if (!options) return qb; const alias = config?.alias ?? entity.name; const aliased = (field: string) => `${alias}.${field}`; const ignore = config?.ignore ?? []; const defaults = { limit: 10, offset: 0, ...config?.defaults, }; if (!ignore.includes("select") && options.select) { qb = qb.select(entity.getAliasedSelectFrom(options.select, alias)); } if (!ignore.includes("with") && options.with) { qb = WithBuilder.addClause(this.em, qb, entity, options.with); } if (!ignore.includes("join") && options.join) { qb = JoinBuilder.addClause(this.em, qb, entity, options.join); } // add where if present if (!ignore.includes("where") && options.where) { qb = WhereBuilder.addClause(qb, options.where); } if (!ignore.includes("limit")) qb = qb.limit(options.limit ?? defaults.limit); if (!ignore.includes("offset")) qb = qb.offset(options.offset ?? defaults.offset); // sorting if (!ignore.includes("sort")) { qb = qb.orderBy(aliased(options.sort?.by ?? "id"), options.sort?.dir ?? "asc"); } return qb as RepositoryQB; } private buildQuery( _options?: Partial, ignore: (keyof RepoQuery)[] = [], ): { qb: RepositoryQB; options: RepoQuery } { const entity = this.entity; const options = this.getValidOptions(_options); return { qb: this.addOptionsToQueryBuilder(undefined, options, { ignore, alias: entity.name, // already done validate: false, }), options, }; } async findId( id: PrimaryFieldType, _options?: Partial>, ): Promise> { const { qb, options } = this.buildQuery( { ..._options, where: { [this.entity.getPrimaryField().name]: id }, limit: 1, }, ["offset", "sort"], ); return this.single(qb, options) as any; } async findOne( where: RepoQuery["where"], _options?: Partial>, ): Promise> { const { qb, options } = this.buildQuery({ ..._options, where, limit: 1, }); return (await this.single(qb, options)) as any; } async findMany(_options?: Partial): Promise> { const { qb, options } = this.buildQuery(_options); await this.triggerFindBefore(this.entity, options); const res = await this.performQuery(qb); await this.triggerFindAfter(this.entity, options, res.data); return res as any; } // @todo: add unit tests, specially for many to many async findManyByReference( id: PrimaryFieldType, reference: string, _options?: Partial>, ): Promise> { const entity = this.entity; const listable_relations = this.em.relations.listableRelationsOf(entity); const relation = listable_relations.find((r) => r.ref(reference).reference === reference); if (!relation) { throw new Error( `Relation "${reference}" not found or not listable on entity "${entity.name}"`, ); } const newEntity = relation.other(entity).entity; const refQueryOptions = relation.getReferenceQuery(newEntity, id as number, reference); if (!("where" in refQueryOptions) || Object.keys(refQueryOptions.where as any).length === 0) { throw new Error( `Invalid reference query for "${reference}" on entity "${newEntity.name}"`, ); } const findManyOptions = { ..._options, ...refQueryOptions, where: { ...refQueryOptions.where, ..._options?.where, }, }; return this.cloneFor(newEntity).findMany(findManyOptions); } async count(where?: RepoQuery["where"]): Promise { const entity = this.entity; const options = this.getValidOptions({ where }); const selector = this.conn.fn.count(sql`*`).as("count"); let qb = this.conn.selectFrom(entity.name).select(selector); // add where if present if (options.where) { qb = WhereBuilder.addClause(qb, options.where); } const { result, ...compiled } = await this.executeQb(qb); return { sql: compiled.sql, parameters: [...compiled.parameters], result, count: result[0]?.count ?? 0, }; } async exists(where: Required["where"]): Promise { const entity = this.entity; const options = this.getValidOptions({ where }); const selector = this.conn.fn.count(sql`*`).as("count"); let qb = this.conn.selectFrom(entity.name).select(selector); // add mandatory where qb = WhereBuilder.addClause(qb, options.where!).limit(1); const { result, ...compiled } = await this.executeQb(qb); return { sql: compiled.sql, parameters: [...compiled.parameters], result, exists: result[0]!.count > 0, }; } }