import { Condition, GroupOperator, Operator, QueryError } from "../query";
import { SelectSelector } from "../query/selectors";
import { RamContext, RawRamQuery } from "./types";
import { Model } from "@decaf-ts/decorator-validation";
import { InternalError } from "@decaf-ts/db-decorators";
import { Statement } from "../query/Statement";
import { Adapter } from "../persistence/Adapter";
import { AdapterFlags } from "../persistence/types";
import { OrderDirection } from "../repository/constants";
import { Constructor, Metadata } from "@decaf-ts/decoration";
/**
* @description RAM-specific query statement builder
* @summary Extends the base Statement class to provide query building functionality for the RAM adapter.
* This class translates high-level query operations into predicates that can filter and sort
* in-memory data structures.
* @template M - The model type being queried
* @template R - The result type returned by the query
* @param {RamAdapter} adapter - The RAM adapter instance to use for executing queries
* @class RamStatement
* @category Ram
* @example
* ```typescript
* // Create a statement for querying User models
* const statement = new RamStatement<User, User>(ramAdapter);
*
* // Build a query to find active users with age > 18
* const results = await statement
* .from(User)
* .where(Condition.and(
* Condition.eq('active', true),
* Condition.gt('age', 18)
* ))
* .orderBy('lastName', 'asc')
* .limit(10)
* .execute();
* ```
*/
export class RamStatement<
M extends Model,
R,
A extends Adapter<M, any, RawRamQuery<any>, RamContext>,
> extends Statement<M, A, R, RawRamQuery<any>> {
constructor(adapter: A, overrides?: Partial<AdapterFlags>) {
super(adapter, overrides);
}
/**
* @description Creates a sort comparator function
* @summary Generates a function that compares two model instances based on the orderBy criteria.
* This method handles different data types (string, number, date) and sort directions (asc, desc).
* @return {function(Model, Model): number} A comparator function for sorting model instances
*/
private getSort() {
const selectors = this.orderBySelectors;
return (el1: Model, el2: Model) => {
if (!selectors || !selectors.length)
throw new InternalError(
"orderBySelectors not set. Should be impossible"
);
for (const [key, direction] of selectors) {
const normalizedDirection = String(direction).toLowerCase();
const directionFactor =
normalizedDirection === OrderDirection.ASC ? 1 : -1;
const comparison = this.compareByKey(el1, el2, key as keyof Model);
if (comparison !== 0) return directionFactor * comparison;
}
return 0;
};
}
private compareByKey(el1: Model, el2: Model, key: keyof Model): number {
const value1 = el1[key];
const value2 = el2[key];
if (value1 === value2) return 0;
if (value1 == null || value2 == null) return value1 == null ? 1 : -1;
const { designType: type } = Metadata.getPropDesignTypes(
el1.constructor as any,
key as string
);
const resolvedType =
(type && type.name && type.name.toLowerCase()) || typeof value1;
switch (resolvedType) {
case "string":
return this.compareStrings(
value1 as unknown as string,
value2 as unknown as string
);
case "number":
return this.compareNumbers(
value1 as unknown as number,
value2 as unknown as number
);
case "bigint":
return this.compareBigInts(
value1 as unknown as bigint,
value2 as unknown as bigint
);
case "boolean":
return this.compareBooleans(
value1 as unknown as boolean,
value2 as unknown as boolean
);
case "date":
case "object":
if (value1 instanceof Date && value2 instanceof Date) {
return this.compareDates(value1 as Date, value2 as Date);
}
break;
default:
break;
}
throw new QueryError(`sorting not supported for type ${resolvedType}`);
}
private compareBooleans(a: boolean, b: boolean): number {
return a === b ? 0 : a ? 1 : -1;
}
private compareNumbers(a: number, b: number): number {
return a - b;
}
private compareBigInts(a: bigint, b: bigint): number {
if (a === b) return 0;
return a > b ? 1 : -1;
}
private compareStrings(a: string, b: string): number {
return a.localeCompare(b);
}
private compareDates(a: Date, b: Date): number {
return a.valueOf() - b.valueOf();
}
/**
* @description Builds a RAM query from the statement
* @summary Converts the statement's selectors and conditions into a RawRamQuery object
* that can be executed by the RAM adapter. This method assembles all query components
* (select, from, where, limit, offset, sort) into the final query structure.
* @return {RawRamQuery<M>} The constructed RAM query object
*/
protected build(): RawRamQuery<any> {
if (this.minSelector)
this.ensureNumberOrDateSelector(this.minSelector, "MIN operation");
if (this.maxSelector)
this.ensureNumberOrDateSelector(this.maxSelector, "MAX operation");
if (this.sumSelector)
this.ensureNumericSelector(this.sumSelector, "SUM operation");
if (this.avgSelector)
this.ensureNumberOrDateSelector(this.avgSelector, "AVG operation");
const result: RawRamQuery<M> = {
select: this.selectSelector,
from: this.fromSelector,
where: this.whereCondition
? this.parseCondition(this.whereCondition).where
: // eslint-disable-next-line @typescript-eslint/no-unused-vars
(el: M) => {
return true;
},
limit: this.limitSelector,
skip: this.offsetSelector,
groupBy: this.groupBySelectors,
};
if (typeof this.countSelector !== "undefined") result.count = this.countSelector;
if (this.countDistinctSelector) result.countDistinct = this.countDistinctSelector;
if (this.minSelector) result.min = this.minSelector;
if (this.maxSelector) result.max = this.maxSelector;
if (this.sumSelector) result.sum = this.sumSelector;
if (this.avgSelector) result.avg = this.avgSelector;
if (this.distinctSelector) result.distinct = this.distinctSelector;
if (this.orderBySelectors?.length) result.sort = this.getSort();
return result as RawRamQuery<any>;
}
/**
* @description Parses a condition into a RAM query predicate
* @summary Converts a Condition object into a predicate function that can be used
* to filter model instances in memory. This method handles both simple conditions
* (equals, greater than, etc.) and complex conditions with logical operators (AND, OR).
* @template M - The model type for the condition
* @param {Condition<M>} condition - The condition to parse
* @return {RawRamQuery<M>} A RAM query object with a where predicate function
* @mermaid
* sequenceDiagram
* participant Caller
* participant RamStatement
* participant SimpleCondition
* participant ComplexCondition
*
* Caller->>RamStatement: parseCondition(condition)
* alt Simple condition (eq, gt, lt, etc.)
* RamStatement->>SimpleCondition: Extract attr1, operator, comparison
* SimpleCondition-->>RamStatement: Return predicate function
* else Logical operator (AND, OR)
* RamStatement->>ComplexCondition: Extract nested conditions
* RamStatement->>RamStatement: parseCondition(leftCondition)
* RamStatement->>RamStatement: parseCondition(rightCondition)
* ComplexCondition-->>RamStatement: Combine predicates with logical operator
* end
* RamStatement-->>Caller: Return query with where predicate
*/
protected parseCondition(condition: Condition<M>): RawRamQuery<any> {
return {
where: (m: Model) => {
const { attr1, operator, comparison } = condition as unknown as {
attr1: string | Condition<M>;
operator: Operator | GroupOperator;
comparison: any;
};
if (
[GroupOperator.AND, GroupOperator.OR, Operator.NOT].indexOf(
operator as GroupOperator
) === -1
) {
switch (operator) {
case Operator.BIGGER:
return m[attr1 as keyof Model] > comparison;
case Operator.BIGGER_EQ:
return m[attr1 as keyof Model] >= comparison;
case Operator.DIFFERENT:
return m[attr1 as keyof Model] !== comparison;
case Operator.EQUAL:
return m[attr1 as keyof Model] === comparison;
case Operator.REGEXP:
if (typeof m[attr1 as keyof Model] !== "string")
throw new QueryError(
`Invalid regexp comparison on a non string attribute: ${m[attr1 as keyof Model]}`
);
return !!(m[attr1 as keyof Model] as unknown as string).match(
new RegExp(comparison, "g")
);
case Operator.STARTS_WITH: {
const attr = attr1 as keyof Model;
const attrName = attr as string;
const attrValue = m[attr] as unknown;
if (typeof attrValue !== "string") {
throw new QueryError(
`Invalid startsWith comparison on a non string attribute "${attrName}"`
);
}
if (typeof comparison !== "string") {
throw new QueryError(
`STARTS_WITH operator requires a string comparison, got ${typeof comparison}`
);
}
return (attrValue as string).startsWith(comparison);
}
case Operator.ENDS_WITH: {
const attr = attr1 as keyof Model;
const attrName = attr as string;
const attrValue = m[attr] as unknown;
if (typeof attrValue !== "string") {
throw new QueryError(
`Invalid endsWith comparison on a non string attribute "${attrName}"`
);
}
if (typeof comparison !== "string") {
throw new QueryError(
`ENDS_WITH operator requires a string comparison, got ${typeof comparison}`
);
}
return (attrValue as string).endsWith(comparison);
}
case Operator.SMALLER:
return m[attr1 as keyof Model] < comparison;
case Operator.SMALLER_EQ:
return m[attr1 as keyof Model] <= comparison;
case Operator.IN:
if (!Array.isArray(comparison))
throw new QueryError(
`IN operator requires an array, got: ${typeof comparison}`
);
return comparison.includes(m[attr1 as keyof Model]);
case Operator.BETWEEN: {
if (!Array.isArray(comparison) || comparison.length !== 2)
throw new QueryError(
`BETWEEN operator requires an array with 2 values [min, max], got: ${JSON.stringify(
comparison
)}`
);
const attr = attr1 as keyof Model;
const attrName = attr as string;
const attrType = this.determineAttributeType(
m.constructor as Constructor<Model>,
attrName,
"BETWEEN"
);
if (!this.isNumericType(attrType) && attrType !== "date") {
throw new QueryError(
`BETWEEN operator requires numeric or date attributes, but "${attrName}" is ${attrType ||
"unknown"}`
);
}
const [min, max] = comparison;
const value = m[attr];
const comparableValue = this.toComparableValue(
value,
attrType,
attrName,
"BETWEEN",
{ allowNull: true }
);
if (comparableValue === null) return false;
const minComparable = this.toComparableValue(
min,
attrType,
attrName,
"BETWEEN min"
)!;
const maxComparable = this.toComparableValue(
max,
attrType,
attrName,
"BETWEEN max"
)!;
return (
comparableValue >= minComparable && comparableValue <= maxComparable
);
}
default:
throw new InternalError(
`Invalid operator for standard comparisons: ${operator}`
);
}
} else if (operator === Operator.NOT) {
if (!(attr1 instanceof Condition)) {
throw new InternalError(
"NOT operator requires a nested condition to negate"
);
}
const nested = this.parseCondition(attr1 as Condition<M>);
return !nested.where(m);
} else {
const op1: RawRamQuery<any> = this.parseCondition(
attr1 as Condition<M>
);
const op2: RawRamQuery<any> = this.parseCondition(
comparison as Condition<M>
);
switch (operator) {
case GroupOperator.AND:
return op1.where(m) && op2.where(m);
case GroupOperator.OR:
return op1.where(m) || op2.where(m);
default:
throw new InternalError(
`Invalid operator for And/Or comparisons: ${operator}`
);
}
}
},
} as RawRamQuery<any>;
}
private ensureNumericSelector(
selector: SelectSelector<M>,
context: string
): void {
this.ensureSelectorType(
selector,
context,
(type) => this.isNumericType(type),
"numeric"
);
}
private ensureNumberOrDateSelector(
selector: SelectSelector<M>,
context: string
): void {
this.ensureSelectorType(
selector,
context,
(type) => this.isNumericType(type) || type === "date",
"numeric or date"
);
}
private ensureSelectorType(
selector: SelectSelector<M>,
context: string,
predicate: (type: string) => boolean,
description: string
) {
if (!this.fromSelector) {
throw new InternalError(
`${context} requires a target model. Call from() before aggregating.`
);
}
const attr = selector as string;
const type = this.determineAttributeType(
this.fromSelector,
attr as keyof Model<false>,
context
);
if (!predicate(type)) {
throw new QueryError(
`${context} requires a ${description} attribute, but "${attr}" is ${type || "unknown"}`
);
}
}
private determineAttributeType(
clazz: Constructor<Model>,
attr: string,
context: string
): string {
const propKey = attr as keyof Model<false>;
const metaType =
Metadata.type(clazz, propKey) ??
Metadata.getPropDesignTypes(clazz, propKey)?.designType;
const resolved = this.normalizeMetaType(metaType);
if (!resolved) {
throw new QueryError(
`${context} could not resolve property type for "${attr}"`
);
}
return resolved;
}
private normalizeMetaType(metaType: any): string | undefined {
if (!metaType) return undefined;
if (typeof metaType === "string") return metaType.toLowerCase();
if (typeof metaType === "function" && metaType.name)
return metaType.name.toLowerCase();
return undefined;
}
private isNumericType(type?: string): boolean {
return type === "number" || type === "bigint";
}
private toComparableValue(
value: any,
attrType: string,
attrName: string,
context: string,
options?: { allowNull?: boolean }
): number | null {
if (value == null) {
if (options?.allowNull) return null;
throw new QueryError(`${context} requires a value for "${attrName}"`);
}
switch (attrType) {
case "date":
if (!(value instanceof Date)) {
throw new QueryError(
`${context} on date attribute "${attrName}" requires Date values`
);
}
return value.getTime();
case "number":
if (typeof value !== "number") {
throw new QueryError(
`${context} on numeric attribute "${attrName}" requires number values`
);
}
return value;
case "bigint":
if (typeof value === "number") return value;
if (typeof value === "bigint") return Number(value);
throw new QueryError(
`${context} on bigint attribute "${attrName}" requires numeric values`
);
default:
throw new QueryError(
`${context} unsupported type "${attrType}" for attribute "${attrName}"`
);
}
}
}
Source