@elsikora/nestjs-crud-automator
Version:
A library for automating the creation of CRUD operations in NestJS.
383 lines (380 loc) • 15.7 kB
JavaScript
import { UNSAFE_OBJECT_PROPERTY_NAMES_CONSTANT } from '../../constant/safe-object-property-names.constant.js';
import { ErrorException } from '../error/exception.utility.js';
import { InstanceChecker, Equal, And, In } from 'typeorm';
/**
* Merges two WHERE expressions by building a Cartesian product of OR branches.
* @template E - Entity type
* @param {TApiAuthorizationScopeWhere<E>} baseWhere - Existing filter.
* @param {TApiAuthorizationScopeWhere<E>} scopedWhere - Additional scope filter.
* @returns {TApiAuthorizationScopeWhere<E>} Combined filter.
*/
function AuthorizationScopeMergeWhere(baseWhere, scopedWhere) {
validateWhereOperand(baseWhere);
validateWhereOperand(scopedWhere);
if (!baseWhere) {
return scopedWhere ? normalizeWhereOperand(scopedWhere) : scopedWhere;
}
if (!scopedWhere) {
return baseWhere;
}
const baseVariants = Array.isArray(baseWhere) ? baseWhere : [baseWhere];
const scopedVariants = Array.isArray(scopedWhere) ? scopedWhere : [scopedWhere];
const mergedVariants = [];
for (const baseVariant of baseVariants) {
for (const scopedVariant of scopedVariants) {
mergedVariants.push(mergeRecordValues(baseVariant, scopedVariant));
}
}
const mergedWhere = mergedVariants.length === 1 ? mergedVariants[0] : mergedVariants;
if (!mergedWhere) {
throw ErrorException("Authorization scope WHERE merge produced no branches");
}
return normalizeWhereOperand(mergedWhere);
}
/**
* Compares two where-clause leaf values for semantic equality.
* @param {unknown} left - Existing value.
* @param {unknown} right - Scoped value.
* @returns {boolean} True when both values represent the same condition.
*/
function areValuesEquivalent(left, right) {
if (left instanceof Date || right instanceof Date) {
return left instanceof Date && right instanceof Date && left.getTime() === right.getTime();
}
if (isFindOperator(left) && isFindOperator(right)) {
return left === right;
}
if (Array.isArray(left) && Array.isArray(right)) {
if (left.length !== right.length) {
return false;
}
return left.every((value, index) => areValuesEquivalent(value, right[index]));
}
if (isRecord(left) && isRecord(right)) {
const leftKeys = Object.keys(left).toSorted((a, b) => a.localeCompare(b));
const rightKeys = Object.keys(right).toSorted((a, b) => a.localeCompare(b));
if (!areValuesEquivalent(leftKeys, rightKeys)) {
return false;
}
return leftKeys.every((key) => areValuesEquivalent(left[key], right[key]));
}
return left === right;
}
/**
* Builds a TypeORM conjunction without approximating database operator semantics.
* @param {unknown} baseValue - Existing scalar or operator.
* @param {unknown} scopedValue - Scoped scalar or operator.
* @returns {FindOperator<unknown>} TypeORM `And(...)` operator.
*/
function createConjunctionOperator(baseValue, scopedValue) {
const baseOperator = isFindOperator(baseValue) ? baseValue : Equal(baseValue);
const scopedOperator = isFindOperator(scopedValue) ? scopedValue : Equal(scopedValue);
return And(baseOperator, scopedOperator);
}
/**
* Builds a match-nothing FindOperator branch for impossible conflicts.
* @returns {FindOperator<unknown>} TypeORM `In([])` operator.
*/
function createMatchNothingOperator() {
return In([]);
}
/**
* Defines an own enumerable WHERE property without invoking prototype setters.
* @param {Record<string, unknown>} target - WHERE branch receiving the property.
* @param {string} key - Entity property name.
* @param {unknown} value - WHERE condition value.
* @returns {void}
*/
function defineWhereProperty(target, key, value) {
Object.defineProperty(target, key, {
// eslint-disable-next-line @elsikora/typescript/naming-convention
configurable: true,
// eslint-disable-next-line @elsikora/typescript/naming-convention
enumerable: true,
value,
// eslint-disable-next-line @elsikora/typescript/naming-convention
writable: true,
});
}
/**
* Reads a dense array through own data descriptors without invoking accessors.
* @param {Array<unknown>} value - Candidate array.
* @returns {Array<unknown> | undefined} Stable item values, or undefined for sparse/extended/accessor arrays.
*/
function getDenseArrayDataValues(value) {
const ownKeys = Reflect.ownKeys(value);
const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
if (ownKeys.length !== value.length + 1 || !lengthDescriptor || lengthDescriptor.enumerable || !("value" in lengthDescriptor) || lengthDescriptor.value !== value.length) {
return undefined;
}
const values = [];
for (let index = 0; index < value.length; index++) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (!descriptor?.enumerable || !("value" in descriptor)) {
return undefined;
}
values.push(descriptor.value);
}
return values;
}
/**
* Reads enumerable string-keyed data properties without invoking accessors.
* @param {Record<string, unknown>} value - Candidate WHERE record.
* @returns {Array<[string, unknown]> | undefined} Stable entries, or undefined for hidden, symbol, or accessor properties.
*/
function getWhereRecordDataEntries(value) {
const entries = [];
for (const key of Reflect.ownKeys(value)) {
if (typeof key !== "string") {
return undefined;
}
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor?.enumerable || !("value" in descriptor)) {
return undefined;
}
entries.push([key, descriptor.value]);
}
return entries;
}
/**
* Detects whether every declared WHERE property contains a condition TypeORM will not discard.
* @param {Record<string, unknown>} branch - Plain WHERE branch.
* @returns {boolean} True when the branch is non-empty and every condition is effective.
*/
function hasOnlyEffectiveWherePredicates(branch) {
const entries = getWhereRecordDataEntries(branch);
if (!entries?.length) {
return false;
}
return entries.every(([, value]) => {
if (value === undefined || value === null) {
return false;
}
if (Array.isArray(value)) {
const arrayValues = getDenseArrayDataValues(value);
if (!arrayValues) {
return false;
}
if (arrayValues.length === 0) {
return true;
}
const objectBranches = arrayValues.map((branchValue) => isWhereBranchObject(branchValue));
if (objectBranches.every(Boolean)) {
return arrayValues.every((branchValue) => hasOnlyEffectiveWherePredicates(branchValue));
}
if (objectBranches.some(Boolean)) {
return false;
}
return arrayValues.every((scalarValue) => scalarValue !== undefined && scalarValue !== null && !Array.isArray(scalarValue) && (isAtomicWhereValue(scalarValue) || ["bigint", "boolean", "number", "string"].includes(typeof scalarValue)));
}
if (isWhereBranchObject(value)) {
return hasOnlyEffectiveWherePredicates(value);
}
return isAtomicWhereValue(value) || ["bigint", "boolean", "number", "string"].includes(typeof value);
});
}
/**
* Detects object-shaped values that the normalizer treats as scalar predicates.
* @param {unknown} value - Candidate WHERE value.
* @returns {boolean} True for supported atomic object values.
*/
function isAtomicWhereValue(value) {
return value instanceof Date || isFindOperator(value) || ArrayBuffer.isView(value);
}
/**
* Detects TypeORM FindOperator-like values without depending on private typings.
* @param {unknown} value - Candidate value.
* @returns {boolean} True when the value looks like a FindOperator.
*/
function isFindOperator(value) {
return InstanceChecker.isFindOperator(value);
}
/**
* Detects plain record objects that can be merged recursively.
* @param {unknown} value - Candidate value.
* @returns {boolean} True when the value is a mergeable record.
*/
function isRecord(value) {
if (!value || typeof value !== "object" || Array.isArray(value) || value instanceof Date || isFindOperator(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === null || prototype === Object.prototype;
}
/**
* Detects object values that TypeORM may interpret as nested relation criteria.
* @param {unknown} value - Candidate nested WHERE value.
* @returns {boolean} True when the object must be inspected recursively.
*/
function isWhereBranchObject(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value) && !isAtomicWhereValue(value));
}
/**
* Recursively merges nested record values using logical AND semantics.
* @param {Record<string, unknown>} baseValue - Existing branch.
* @param {Record<string, unknown>} scopedValue - Scoped branch.
* @returns {Record<string, unknown>} Merged branch.
*/
function mergeRecordValues(baseValue, scopedValue) {
const mergedValue = { ...baseValue };
const keys = new Set([...Object.keys(baseValue), ...Object.keys(scopedValue)]);
for (const key of keys) {
const currentBaseValue = baseValue[key];
const currentScopedValue = scopedValue[key];
if (currentBaseValue === undefined) {
defineWhereProperty(mergedValue, key, currentScopedValue);
continue;
}
if (currentScopedValue === undefined) {
defineWhereProperty(mergedValue, key, currentBaseValue);
continue;
}
defineWhereProperty(mergedValue, key, mergeWhereValue(currentBaseValue, currentScopedValue));
}
return mergedValue;
}
/**
* Merges two where-clause leaf values without allowing overwrite semantics.
* @param {unknown} baseValue - Existing value.
* @param {unknown} scopedValue - Scoped value.
* @returns {unknown} Narrowed value or a match-nothing operator on conflict.
*/
function mergeWhereValue(baseValue, scopedValue) {
if (isRecord(baseValue) && isRecord(scopedValue)) {
return mergeRecordValues(baseValue, scopedValue);
}
if (areValuesEquivalent(baseValue, scopedValue)) {
return baseValue;
}
if (isFindOperator(baseValue) || isFindOperator(scopedValue)) {
return createConjunctionOperator(baseValue, scopedValue);
}
return createMatchNothingOperator();
}
/**
* Normalizes a validated WHERE operand before TypeORM receives it.
* @template E - Entity type
* @param {TApiAuthorizationScopeWhere<E>} value - Validated operand.
* @returns {TApiAuthorizationScopeWhere<E>} Operand with scalar leaves represented by exact Equal operators.
*/
function normalizeWhereOperand(value) {
if (Array.isArray(value)) {
return value.map((branch) => normalizeWhereRecord(branch));
}
return normalizeWhereRecord(value);
}
/**
* Normalizes every value in a WHERE branch without invoking prototype setters.
* @param {Record<string, unknown>} value - Validated WHERE branch.
* @returns {Record<string, unknown>} Normalized branch.
*/
function normalizeWhereRecord(value) {
const normalized = {};
const entries = getWhereRecordDataEntries(value);
if (!entries) {
throw ErrorException("Authorization scope WHERE object must contain enumerable data properties only");
}
for (const [key, nestedValue] of entries) {
defineWhereProperty(normalized, key, normalizeWhereValue(nestedValue));
}
return normalized;
}
/**
* Converts scalar leaves to exact TypeORM equality while retaining relation structure.
* @param {unknown} value - Validated WHERE value.
* @returns {unknown} Normalized TypeORM condition.
*/
function normalizeWhereValue(value) {
if (isFindOperator(value)) {
return value;
}
if (Array.isArray(value)) {
const arrayValues = getDenseArrayDataValues(value);
if (!arrayValues) {
throw ErrorException("Authorization scope WHERE arrays must contain dense enumerable data items only");
}
if (arrayValues.length === 0) {
return Equal(arrayValues);
}
const objectBranches = arrayValues.map((branchValue) => isWhereBranchObject(branchValue));
return objectBranches.every(Boolean) ? arrayValues.map((branchValue) => normalizeWhereRecord(branchValue)) : Equal(arrayValues);
}
return isWhereBranchObject(value) ? normalizeWhereRecord(value) : Equal(value);
}
/**
* Rejects array-form WHERE expressions that TypeORM would interpret as unconstrained.
* @param {unknown} value - Candidate array-form WHERE expression.
* @returns {void}
*/
function validateWhereArray(value) {
if (!Array.isArray(value)) {
return;
}
const branches = getDenseArrayDataValues(value);
if (!branches) {
throw ErrorException("Authorization scope WHERE array branches must be non-empty plain objects");
}
if (branches.length === 0) {
throw ErrorException("Authorization scope WHERE cannot be an empty array");
}
if (branches.some((branch) => !isRecord(branch) || !hasOnlyEffectiveWherePredicates(branch))) {
throw ErrorException("Authorization scope WHERE array branches must be non-empty plain objects");
}
for (const branch of branches) {
validateWhereRecordKeys(branch);
}
}
/**
* Rejects malformed or non-empty but ineffective scalar WHERE expressions.
* @param {unknown} value - Candidate WHERE expression.
* @returns {void}
*/
function validateWhereOperand(value) {
if (value === undefined) {
return;
}
if (Array.isArray(value)) {
validateWhereArray(value);
return;
}
if (!isRecord(value)) {
throw ErrorException("Authorization scope WHERE must be undefined, a plain object, or an array of plain objects");
}
const entries = getWhereRecordDataEntries(value);
if (!entries || (entries.length > 0 && !hasOnlyEffectiveWherePredicates(value))) {
throw ErrorException("Authorization scope WHERE object must contain an effective predicate");
}
validateWhereRecordKeys(value);
}
/**
* Rejects prototype-sensitive entity paths recursively.
* @param {Record<string, unknown>} value - WHERE branch to inspect.
* @returns {void}
*/
function validateWhereRecordKeys(value) {
const entries = getWhereRecordDataEntries(value);
if (!entries) {
throw ErrorException("Authorization scope WHERE object must contain enumerable data properties only");
}
for (const [key, nestedValue] of entries) {
if (UNSAFE_OBJECT_PROPERTY_NAMES_CONSTANT.has(key)) {
throw ErrorException(`Authorization scope WHERE property "${key}" is not a safe property name`);
}
if (Array.isArray(nestedValue)) {
const branches = getDenseArrayDataValues(nestedValue);
if (!branches) {
throw ErrorException("Authorization scope WHERE arrays must contain dense enumerable data items only");
}
for (const branch of branches) {
if (isWhereBranchObject(branch)) {
validateWhereRecordKeys(branch);
}
}
}
else if (isWhereBranchObject(nestedValue)) {
validateWhereRecordKeys(nestedValue);
}
}
}
export { AuthorizationScopeMergeWhere };
//# sourceMappingURL=scope-merge-where.utility.js.map