@opra/sqb
Version:
Opra SQB adapter package
669 lines (668 loc) • 24.1 kB
JavaScript
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
}
}