UNPKG

@opra/sqb

Version:

Opra SQB adapter package

669 lines (668 loc) 24.1 kB
import { ComplexType, DataType, InternalServerError } from '@opra/common'; import { ExecutionContext, ServiceBase } from '@opra/core'; import { sql, SqlElement } from '@sqb/builder'; import { EntityMetadata, Repository } from '@sqb/connect'; import { isNotNullish, vg } from 'valgen'; import { SQBAdapter } from './sqb-adapter.js'; import { SqbServiceBase } from './sqb-service-base.js'; /** * Base service providing CRUD operations over an SQB entity. * * @typeParam T - The entity type managed by this service */ export class SqbEntityService extends SqbServiceBase { _dataTypeScope; _dataType_; _dataType; _dataTypeClass; _entityMetadata; _inputCodecs = {}; _outputCodecs = {}; /** * Comma-delimited scopes used to filter the API document. */ scope; /** * Override for the resource name exposed in error messages and API metadata. * Accepts a static string or a function that returns one. */ resourceName; /** * Filter(s) automatically applied to every query for this service. * Useful for multi-tenant isolation or other cross-cutting constraints. */ commonFilter; /** * Called whenever a command throws. Useful for logging or transforming errors. * * @param error - The thrown error. * @param command - The service command during which the error was thrown. * @param _this - The service instance. */ onError; /** * Constructs a new instance. * * @param dataType - The entity class or its registered name. * @param options - Options for the service. */ constructor(dataType, options) { super(options); this._dataType_ = dataType; this.resourceName = options?.resourceName; this.commonFilter = options?.commonFilter; this.interceptor = options?.interceptor; } /** * Returns the resolved OPRA `ComplexType` for this service's entity. * * @throws If the data type is not registered as a `ComplexType`. */ get dataType() { if (this._dataType && this._dataTypeScope !== this.scope) this._dataType = undefined; if (!this._dataType) this._dataType = this.context.__docNode.getComplexType(this._dataType_); this._dataTypeScope = this.scope; return this._dataType; } /** * Returns the constructor class of the entity data type. * * @throws If the data type is not registered as a `ComplexType`. */ get dataTypeClass() { if (!this._dataTypeClass) this._dataTypeClass = this.entityMetadata.ctor; return this._dataTypeClass; } /** * Returns the SQB `EntityMetadata` for the entity class. * * @throws If the class is not decorated with `@Entity()`. */ get entityMetadata() { if (!this._entityMetadata) { const t = this.dataType.ctor; const metadata = EntityMetadata.get(t); if (!metadata) throw new TypeError(`Class (${t}) is not decorated with $Entity() decorator`); this._entityMetadata = metadata; } return this._entityMetadata; } for(context, overwriteProperties, overwriteContext) { if (overwriteProperties?.commonFilter && this.commonFilter) { overwriteProperties.commonFilter = [ ...(Array.isArray(this.commonFilter) ? this.commonFilter : [this.commonFilter]), ...(Array.isArray(overwriteProperties?.commonFilter) ? overwriteProperties.commonFilter : [overwriteProperties.commonFilter]), ]; } return super.for(context, overwriteProperties, overwriteContext); } /** * Returns the resource name used in error messages and API metadata. * * @throws If neither `resourceName` nor the data type name is available. */ getResourceName() { const out = typeof this.resourceName === 'function' ? this.resourceName(this) : this.resourceName || this.dataType.name; if (out) return out; throw new Error('resourceName is not defined'); } /** * Returns the input codec for the given operation (e.g. `'create'`, `'update'`). * * @param operation - The operation name. */ getInputCodec(operation) { const dataType = this.dataType; const cacheKey = operation + (this._dataTypeScope ? ':' + this._dataTypeScope : ''); let validator = this._inputCodecs[cacheKey]; if (validator) return validator; const options = { projection: '*', scope: this._dataTypeScope, fieldHook: (_, __, defaultGenerator) => { return vg.oneOf([defaultGenerator(), vg.isInstanceOf(SqlElement)]); }, }; if (operation === 'update') { options.partial = 'deep'; options.allowNullOptionals = true; } validator = dataType.generateCodec('decode', options); this._inputCodecs[cacheKey] = validator; return validator; } /** * Returns the output codec for the given operation. * * @param operation - The operation name. */ getOutputCodec(operation) { const dataType = this.dataType; const cacheKey = operation + (this._dataTypeScope ? ':' + this._dataTypeScope : ''); let validator = this._outputCodecs[cacheKey]; if (validator) return validator; const options = { projection: '*', partial: 'deep', scope: this._dataTypeScope, }; validator = dataType.generateCodec('decode', options); this._outputCodecs[cacheKey] = validator; return validator; } /** * Inserts a new record into the database and returns the created document. * * @param command - The create command. * @returns The created document. * @protected */ async _create(command) { const { input, options } = command; isNotNullish(command.input, { label: 'input' }); const inputCodec = this.getInputCodec('create'); const outputCodec = this.getOutputCodec('create'); const data = inputCodec(input); const conn = await this.getConnection(); const repo = conn.getRepository(this.dataTypeClass); const out = await repo.create(data, options); if (out) return outputCodec(out); throw new InternalServerError(`Unknown error while creating document for "${this.getResourceName()}"`); } /** * Inserts a new record into the database without returning it. * * @param command - The create command. * @protected */ async _createOnly(command) { const { input, options } = command; isNotNullish(command.input, { label: 'input' }); const inputCodec = this.getInputCodec('create'); const data = inputCodec(input); const conn = await this.getConnection(); const repo = conn.getRepository(this.dataTypeClass); return await repo.createOnly(data, options); } /** * Returns the count of records matching the command options. * * @param command - The count command. * @protected */ async _count(command) { const filter = command.options?.filter ? SQBAdapter.prepareFilter(command.options.filter) : undefined; return this._dbCount({ ...command.options, filter }); } /** * Deletes the record identified by `command.documentId`. * * @param command - The delete command. * @returns The number of records deleted. * @protected */ async _delete(command) { isNotNullish(command.documentId, { label: 'documentId' }); const filter = command.options?.filter ? SQBAdapter.prepareFilter(command.options.filter) : undefined; return this._dbDelete(command.documentId, { ...command.options, filter }); } /** * Deletes all records matching the command filter. * * @param command - The deleteMany command. * @returns The number of records deleted. * @protected */ async _deleteMany(command) { const filter = command.options?.filter ? SQBAdapter.prepareFilter(command.options.filter) : undefined; return await this._dbDeleteMany({ ...command.options, filter }); } /** * Checks whether the record identified by `command.documentId` exists. * * @param command - The exists command. * @protected */ async _exists(command) { isNotNullish(command.documentId, { label: 'documentId' }); const filter = command.options?.filter ? SQBAdapter.prepareFilter(command.options.filter) : undefined; return await this._dbExists(command.documentId, { ...command.options, filter, }); } /** * Checks whether any record matching the command filter exists. * * @param command - The existsOne command. * @protected */ async _existsOne(command) { const filter = command.options?.filter ? SQBAdapter.prepareFilter(command.options.filter) : undefined; return await this._dbExistsOne({ ...command.options, filter }); } /** * Finds the record identified by `command.documentId`. * * @param command - The findById command. * @returns The found record, or `undefined` if not found. * @protected */ async _findById(command) { isNotNullish(command.documentId, { label: 'documentId' }); const decode = this.getOutputCodec('find'); const filter = command.options?.filter ? SQBAdapter.prepareFilter(command.options.filter) : undefined; const out = await this._dbFindById(command.documentId, { ...command.options, filter, }); return out ? decode(out) : undefined; } /** * Finds the first record matching the command filter. * * @param command - The findOne command. * @returns The found record, or `undefined` if not found. * @protected */ async _findOne(command) { const decode = this.getOutputCodec('find'); const filter = command.options?.filter ? SQBAdapter.prepareFilter(command.options.filter) : undefined; const out = await this._dbFindOne({ ...command.options, filter }); return out ? decode(out) : undefined; } /** * Finds all records matching the command filter. * * @param command - The findMany command. * @returns An array of matching records. * @protected */ async _findMany(command) { const decode = this.getOutputCodec('find'); const filter = command.options?.filter ? SQBAdapter.prepareFilter(command.options.filter) : undefined; const out = await this._dbFindMany({ ...command.options, filter }); if (out?.length) { return out.map(x => decode(x)); } return out; } /** * Updates the record identified by `command.documentId` and returns it. * * @param command - The update command. * @returns The updated record, or `undefined` if not found. * @protected */ async _update(command) { isNotNullish(command.documentId, { label: 'documentId' }); isNotNullish(command.input, { label: 'input' }); const { documentId, input, options } = command; const inputCodec = this.getInputCodec('update'); const data = inputCodec(input); const filter = command.options?.filter ? SQBAdapter.prepareFilter(command.options.filter) : undefined; const out = await this._dbUpdate(documentId, data, { ...options, filter }); const outputCodec = this.getOutputCodec('update'); if (out) return outputCodec(out); } /** * Updates the record identified by `command.documentId` without returning it. * * @param command - The updateOnly command. * @returns The number of records modified. * @protected */ async _updateOnly(command) { isNotNullish(command.documentId, { label: 'documentId' }); isNotNullish(command.input, { label: 'input' }); const { documentId, input, options } = command; const inputCodec = this.getInputCodec('update'); const data = inputCodec(input); const filter = command.options?.filter ? SQBAdapter.prepareFilter(command.options.filter) : undefined; return await this._dbUpdateOnly(documentId, data, { ...options, filter }); } /** * Updates all records matching the command filter. * * @param command - The updateMany command. * @returns The number of records modified. * @protected */ async _updateMany(command) { isNotNullish(command.input, { label: 'input' }); const inputCodec = this.getInputCodec('update'); const data = inputCodec(command.input); const filter = command.options?.filter ? SQBAdapter.prepareFilter(command.options.filter) : undefined; return await this._dbUpdateMany(data, { ...command.options, filter }); } /** * Acquires a connection and performs `Repository.create`. * * @param input - The document to insert. * @param options - Optional settings. * @protected */ async _dbCreate(input, options) { const conn = await this.getConnection(); const repo = conn.getRepository(this.dataTypeClass); return await repo.create(input, options); } /** * Acquires a connection and performs `Repository.count`. * * @param options - Optional settings. * @protected */ async _dbCount(options) { const conn = await this.getConnection(); const repo = conn.getRepository(this.dataTypeClass); if (options?.filter) options.filter = SQBAdapter.prepareFilter(options.filter); return await repo.count(options); } /** * Acquires a connection and performs `Repository.delete`. * * @param id - The key field value identifying the record. * @param options - Optional settings. * @protected */ async _dbDelete(id, options) { const conn = await this.getConnection(); const repo = conn.getRepository(this.dataTypeClass); if (options?.filter) options.filter = SQBAdapter.prepareFilter(options.filter); return (await repo.delete(id, options)) ? 1 : 0; } /** * Acquires a connection and performs `Repository.deleteMany`. * * @param options - Optional settings. * @protected */ async _dbDeleteMany(options) { const conn = await this.getConnection(); const repo = conn.getRepository(this.dataTypeClass); if (options?.filter) options.filter = SQBAdapter.prepareFilter(options.filter); return await repo.deleteMany(options); } /** * Acquires a connection and performs `Repository.exists`. * * @param id - The key field value identifying the record. * @param options - Optional settings. * @protected */ async _dbExists(id, options) { const conn = await this.getConnection(); const repo = conn.getRepository(this.dataTypeClass); if (options?.filter) options.filter = SQBAdapter.prepareFilter(options.filter); return await repo.exists(id, options); } /** * Acquires a connection and performs `Repository.existsOne`. * * @param options - Optional settings. * @protected */ async _dbExistsOne(options) { const conn = await this.getConnection(); const repo = conn.getRepository(this.dataTypeClass); if (options?.filter) options.filter = SQBAdapter.prepareFilter(options.filter); return await repo.existsOne(options); } /** * Acquires a connection and performs `Repository.findById`. * * @param id - The key field value identifying the record. * @param options - Optional settings. * @protected */ async _dbFindById(id, options) { const conn = await this.getConnection(); const repo = conn.getRepository(this.dataTypeClass); if (options?.filter) options.filter = SQBAdapter.prepareFilter(options.filter); return await repo.findById(id, options); } /** * Acquires a connection and performs `Repository.findOne`. * * @param options - Optional settings. * @protected */ async _dbFindOne(options) { const conn = await this.getConnection(); const repo = conn.getRepository(this.dataTypeClass); if (options?.filter) options.filter = SQBAdapter.prepareFilter(options.filter); return await repo.findOne({ ...options, offset: options?.skip }); } /** * Acquires a connection and performs `Repository.findMany`. * * @param options - Optional settings. * @protected */ async _dbFindMany(options) { const conn = await this.getConnection(); const repo = conn.getRepository(this.dataTypeClass); if (options?.filter) options.filter = SQBAdapter.prepareFilter(options.filter); return await repo.findMany({ ...options, offset: options?.skip }); } /** * Acquires a connection and performs `Repository.update`. * * @param id - The key field value identifying the record. * @param data - The update values. * @param options - Optional settings. * @protected */ async _dbUpdate(id, data, options) { const conn = await this.getConnection(); const repo = conn.getRepository(this.dataTypeClass); if (options?.filter) options.filter = SQBAdapter.prepareFilter(options.filter); return await repo.update(id, data, options); } /** * Acquires a connection and performs `Repository.updateOnly`. * * @param id - The key field value identifying the record. * @param data - The update values. * @param options - Optional settings. * @protected */ async _dbUpdateOnly(id, data, options) { const conn = await this.getConnection(); const repo = conn.getRepository(this.dataTypeClass); if (options?.filter) options.filter = SQBAdapter.prepareFilter(options.filter); return (await repo.updateOnly(id, data, options)) ? 1 : 0; } /** * Acquires a connection and performs `Repository.updateMany`. * * @param data - The update values. * @param options - Optional settings. * @protected */ async _dbUpdateMany(data, options) { const conn = await this.getConnection(); const repo = conn.getRepository(this.dataTypeClass); if (options?.filter) options.filter = SQBAdapter.prepareFilter(options.filter); return await repo.updateMany(data, options); } /** * Builds the common filter for the given command. * Used primarily for multi-tenant isolation and similar cross-cutting concerns. * * @protected * @returns The resolved filter input, or `undefined` if none is configured. */ _getCommonFilter(command) { const commonFilter = Array.isArray(this.commonFilter) ? this.commonFilter : [this.commonFilter]; const mapped = commonFilter.map(f => typeof f === 'function' ? f(command, this) : f); return mapped.length > 1 ? sql.And(...mapped) : mapped[0]; } async _executeCommand(command, commandFn) { let proto; const next = async () => { proto = proto ? Object.getPrototypeOf(proto) : this; while (proto) { if (proto.interceptor && Object.prototype.hasOwnProperty.call(proto, 'interceptor')) { return await proto.interceptor.call(this, next, command, this); } proto = Object.getPrototypeOf(proto); if (!(proto instanceof SqbEntityService)) break; } /* Call before[X] hooks */ if (command.crud === 'create') await this._beforeCreate(command); else if (command.crud === 'update' && command.byId) { await this._beforeUpdate(command); } else if (command.crud === 'update' && !command.byId) { await this._beforeUpdateMany(command); } else if (command.crud === 'delete' && command.byId) { await this._beforeDelete(command); } else if (command.crud === 'delete' && !command.byId) { await this._beforeDeleteMany(command); } /* Call command function */ return commandFn(); }; try { const result = await next(); /* Call after[X] hooks */ if (command.crud === 'create') await this._afterCreate(command, result); else if (command.crud === 'update' && command.byId) { await this._afterUpdate(command, result); } else if (command.crud === 'update' && !command.byId) { await this._afterUpdateMany(command, result); } else if (command.crud === 'delete' && command.byId) { await this._afterDelete(command, result); } else if (command.crud === 'delete' && !command.byId) { await this._afterDeleteMany(command, result); } return result; } catch (e) { Error.captureStackTrace(e, this._executeCommand); await this.onError?.(e, command, this); throw e; } } async _beforeCreate( // eslint-disable-next-line @typescript-eslint/no-unused-vars command) { // Do nothing } async _beforeUpdate( // eslint-disable-next-line @typescript-eslint/no-unused-vars command) { // Do nothing } async _beforeUpdateMany( // eslint-disable-next-line @typescript-eslint/no-unused-vars command) { // Do nothing } async _beforeDelete( // eslint-disable-next-line @typescript-eslint/no-unused-vars command) { // Do nothing } async _beforeDeleteMany( // eslint-disable-next-line @typescript-eslint/no-unused-vars command) { // Do nothing } async _afterCreate( // eslint-disable-next-line @typescript-eslint/no-unused-vars command, // eslint-disable-next-line @typescript-eslint/no-unused-vars result) { // Do nothing } async _afterUpdate( // eslint-disable-next-line @typescript-eslint/no-unused-vars command, // eslint-disable-next-line @typescript-eslint/no-unused-vars result) { // Do nothing } async _afterUpdateMany( // eslint-disable-next-line @typescript-eslint/no-unused-vars command, // eslint-disable-next-line @typescript-eslint/no-unused-vars affected) { // Do nothing } async _afterDelete( // eslint-disable-next-line @typescript-eslint/no-unused-vars command, // eslint-disable-next-line @typescript-eslint/no-unused-vars affected) { // Do nothing } async _afterDeleteMany( // eslint-disable-next-line @typescript-eslint/no-unused-vars command, // eslint-disable-next-line @typescript-eslint/no-unused-vars affected) { // Do nothing } }