alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
589 lines (523 loc) • 16.7 kB
text/typescript
import {
$inject,
Alepha,
AlephaError,
createPagination,
type TObject,
} from "alepha";
import {
and,
arrayContained,
arrayContains,
arrayOverlaps,
between,
eq,
exists,
gt,
gte,
ilike,
inArray,
isNotNull,
isNull,
isSQLWrapper,
like,
lt,
lte,
ne,
not,
notBetween,
notExists,
notIlike,
notInArray,
notLike,
or,
type SQL,
type SQLWrapper,
sql,
} from "drizzle-orm";
import type { PgColumn } from "drizzle-orm/pg-core";
import type { FilterOperators } from "../interfaces/FilterOperators.ts";
import type {
PgQueryWhere,
PgQueryWhereOrSQL,
} from "../interfaces/PgQueryWhere.ts";
export class QueryManager {
protected readonly alepha = $inject(Alepha);
/**
* Convert a query object to a SQL query.
*/
public toSQL(
query: PgQueryWhereOrSQL<TObject>,
options: {
schema: TObject;
col: (key: string) => PgColumn;
joins?: PgJoin[];
dialect: "postgresql" | "sqlite";
},
): SQL | undefined {
const { schema, col, joins } = options;
const conditions: SQL[] = [];
if (isSQLWrapper(query)) {
conditions.push(query as SQL);
} else {
const keys = Object.keys(query) as Array<
keyof PgQueryWhere<TObject> & string
>;
for (const key of keys) {
const operator = query[key] as SQL;
// Handle joins - check if this key matches a join at the current level
if (
typeof query[key] === "object" &&
query[key] != null &&
!Array.isArray(query[key]) &&
joins?.length
) {
// Find the join that matches this key (at the current level, without parent filtering)
const matchingJoins = joins.filter((j) => j.key === key);
if (matchingJoins.length > 0) {
// Use the first matching join (they should all have the same schema)
const join = matchingJoins[0];
// Build the full path to this join
const joinPath = join.parent ? `${join.parent}.${key}` : key;
// Find child joins: those whose parent starts with this join's path
const childJoins = joins.filter((j) => {
if (!j.parent) return false;
// Child's parent should be exactly our path, or start with our path + "."
return (
j.parent === joinPath || j.parent.startsWith(`${joinPath}.`)
);
});
// For recursion, we need to restructure child joins
// Remove the current path prefix from parent keys
const recursiveJoins = childJoins.map((j) => {
const newParent =
j.parent === joinPath
? undefined
: j.parent!.substring(joinPath.length + 1);
return {
...j,
parent: newParent,
};
});
const sql = this.toSQL(query[key], {
schema: join.schema,
col: join.col,
joins: recursiveJoins.length > 0 ? recursiveJoins : undefined,
dialect: options.dialect,
});
if (sql) {
conditions.push(sql);
}
continue;
}
}
if (Array.isArray(operator)) {
const operations: SQL[] = operator
.map((it) => {
if (isSQLWrapper(it)) {
return it as SQL;
}
return this.toSQL(it as PgQueryWhere<TObject>, {
schema,
col,
joins, // Pass joins through recursively
dialect: options.dialect,
});
})
.filter((it) => it != null);
// Combine with the sibling conditions instead of returning early —
// an early return here silently DROPPED every other key of the
// where (e.g. `{ userId: {...}, or: [...] }` matched ALL users).
if (key === "and") {
const combined = and(...operations);
if (combined) {
conditions.push(combined);
}
continue;
}
if (key === "or") {
const combined = or(...operations);
if (combined) {
conditions.push(combined);
}
continue;
}
}
if (key === "not") {
const where = this.toSQL(operator as PgQueryWhereOrSQL<TObject>, {
schema,
col,
joins, // Pass joins through recursively
dialect: options.dialect,
});
if (where) {
conditions.push(not(where));
}
continue;
}
if (key === "exists") {
conditions.push(exists(operator as SQLWrapper));
continue;
}
if (key === "notExists") {
conditions.push(notExists(operator as SQLWrapper));
continue;
}
if (operator != null) {
const column = col(key);
const sql = this.mapOperatorToSql(
operator,
column,
schema,
key,
options.dialect,
);
if (sql) {
conditions.push(sql);
}
}
}
}
if (conditions.length === 1) {
return conditions[0];
}
return and(...conditions);
}
/**
* Check if an object has any filter operator properties.
*/
protected hasFilterOperatorProperties(obj: any): boolean {
if (!obj || typeof obj !== "object") return false;
const filterOperatorKeys = [
"eq",
"ne",
"gt",
"gte",
"lt",
"lte",
"inArray",
"notInArray",
"isNull",
"isNotNull",
"like",
"notLike",
"ilike",
"notIlike",
"contains",
"startsWith",
"endsWith",
"between",
"notBetween",
"arrayContains",
"arrayContained",
"arrayOverlaps",
];
return filterOperatorKeys.some((key) => key in obj);
}
/**
* Map a filter operator to a SQL query.
*/
public mapOperatorToSql(
operator: FilterOperators<any> | any,
column: PgColumn,
columnSchema?: TObject,
columnName?: string,
dialect: "postgresql" | "sqlite" = "postgresql",
): SQL | undefined {
// Helper function to encode a value for the specific column
const encodeValue = (value: any): any => {
if (value == null) {
return value;
}
// If we have schema information, encode the value properly
if (columnSchema && columnName) {
try {
const fieldSchema = columnSchema.properties[columnName];
if (fieldSchema) {
// Encode the value using the drizzle codec
// This converts application values (like Dayjs) to database values (like ISO strings)
return this.alepha.codec.encode(fieldSchema, value, {
encoder: "drizzle",
});
}
} catch (error) {
// If encoding fails, fall back to the original value
// This ensures backward compatibility
}
}
return value;
};
// Helper function to encode array values
const encodeArray = (values: any[]): any[] => {
return values.map((v) => encodeValue(v));
};
// If operator is not an object, OR it's an object but doesn't have any filter operator properties,
// treat it as a direct value (e.g., string, number, Date, Dayjs, etc.)
if (
typeof operator !== "object" ||
operator == null ||
!this.hasFilterOperatorProperties(operator)
) {
return eq(column, encodeValue(operator));
}
const conditions: SQL[] = [];
if (operator?.eq != null) {
conditions.push(eq(column, encodeValue(operator.eq)));
}
if (operator?.ne != null) {
conditions.push(ne(column, encodeValue(operator.ne)));
}
if (operator?.gt != null) {
conditions.push(gt(column, encodeValue(operator.gt)));
}
if (operator?.gte != null) {
conditions.push(gte(column, encodeValue(operator.gte)));
}
if (operator?.lt != null) {
conditions.push(lt(column, encodeValue(operator.lt)));
}
if (operator?.lte != null) {
conditions.push(lte(column, encodeValue(operator.lte)));
}
if (operator?.inArray != null) {
if (!Array.isArray(operator.inArray) || operator.inArray.length === 0) {
throw new AlephaError("inArray operator requires at least one value");
}
conditions.push(inArray(column, encodeArray(operator.inArray)));
}
if (operator?.notInArray != null) {
if (
!Array.isArray(operator.notInArray) ||
operator.notInArray.length === 0
) {
throw new AlephaError(
"notInArray operator requires at least one value",
);
}
conditions.push(notInArray(column, encodeArray(operator.notInArray)));
}
if (operator?.isNull != null) {
conditions.push(isNull(column));
}
if (operator?.isNotNull != null) {
conditions.push(isNotNull(column));
}
if (operator?.like != null) {
conditions.push(like(column, encodeValue(operator.like)));
}
if (operator?.notLike != null) {
conditions.push(notLike(column, encodeValue(operator.notLike)));
}
if (operator?.ilike != null) {
if (dialect === "sqlite") {
// SQLite doesn't have ilike, use LOWER() for case-insensitive matching
conditions.push(
sql`LOWER(${column}) LIKE LOWER(${encodeValue(operator.ilike)})`,
);
} else {
conditions.push(ilike(column, encodeValue(operator.ilike)));
}
}
if (operator?.notIlike != null) {
if (dialect === "sqlite") {
// SQLite doesn't have ilike, use LOWER() for case-insensitive matching
conditions.push(
sql`LOWER(${column}) NOT LIKE LOWER(${encodeValue(operator.notIlike)})`,
);
} else {
conditions.push(notIlike(column, encodeValue(operator.notIlike)));
}
}
if (operator?.contains != null) {
// Escape LIKE special characters to prevent wildcard injection
const escapedValue = String(operator.contains)
.replace(/\\/g, "\\\\") // Escape backslash first
.replace(/%/g, "\\%") // Escape %
.replace(/_/g, "\\_"); // Escape _
if (dialect === "sqlite") {
// SQLite doesn't have ilike, use LOWER() for case-insensitive matching
// ESCAPE '\\' is required for SQLite to recognize backslash as escape character
conditions.push(
sql`LOWER(${column}) LIKE LOWER(${encodeValue(`%${escapedValue}%`)}) ESCAPE '\\'`,
);
} else {
conditions.push(ilike(column, encodeValue(`%${escapedValue}%`)));
}
}
if (operator?.startsWith != null) {
// Escape LIKE special characters to prevent wildcard injection
const escapedValue = String(operator.startsWith)
.replace(/\\/g, "\\\\") // Escape backslash first
.replace(/%/g, "\\%") // Escape %
.replace(/_/g, "\\_"); // Escape _
if (dialect === "sqlite") {
// SQLite doesn't have ilike, use LOWER() for case-insensitive matching
conditions.push(
sql`LOWER(${column}) LIKE LOWER(${encodeValue(`${escapedValue}%`)}) ESCAPE '\\'`,
);
} else {
conditions.push(ilike(column, encodeValue(`${escapedValue}%`)));
}
}
if (operator?.endsWith != null) {
// Escape LIKE special characters to prevent wildcard injection
const escapedValue = String(operator.endsWith)
.replace(/\\/g, "\\\\") // Escape backslash first
.replace(/%/g, "\\%") // Escape %
.replace(/_/g, "\\_"); // Escape _
if (dialect === "sqlite") {
// SQLite doesn't have ilike, use LOWER() for case-insensitive matching
conditions.push(
sql`LOWER(${column}) LIKE LOWER(${encodeValue(`%${escapedValue}`)}) ESCAPE '\\'`,
);
} else {
conditions.push(ilike(column, encodeValue(`%${escapedValue}`)));
}
}
if (operator?.between != null) {
if (!Array.isArray(operator.between) || operator.between.length !== 2) {
throw new AlephaError(
"between operator requires exactly 2 values [min, max]",
);
}
conditions.push(
between(
column,
encodeValue(operator.between[0]),
encodeValue(operator.between[1]),
),
);
}
if (operator?.notBetween != null) {
if (
!Array.isArray(operator.notBetween) ||
operator.notBetween.length !== 2
) {
throw new AlephaError(
"notBetween operator requires exactly 2 values [min, max]",
);
}
conditions.push(
notBetween(
column,
encodeValue(operator.notBetween[0]),
encodeValue(operator.notBetween[1]),
),
);
}
if (operator?.arrayContains != null) {
conditions.push(
arrayContains(column, encodeValue(operator.arrayContains)),
);
}
if (operator?.arrayContained != null) {
conditions.push(
arrayContained(column, encodeValue(operator.arrayContained)),
);
}
if (operator?.arrayOverlaps != null) {
conditions.push(
arrayOverlaps(column, encodeValue(operator.arrayOverlaps)),
);
}
if (conditions.length === 0) {
return undefined;
}
if (conditions.length === 1) {
return conditions[0];
}
return and(...conditions);
}
/**
* Parse pagination sort string to orderBy format.
* Format: "firstName,-lastName" -> [{ column: "firstName", direction: "asc" }, { column: "lastName", direction: "desc" }]
* - Columns separated by comma
* - Prefix with '-' for DESC direction
*
* @param sort Pagination sort string
* @returns OrderBy array or single object
*/
public parsePaginationSort(
sort: string,
):
| Array<{ column: string; direction: "asc" | "desc" }>
| { column: string; direction: "asc" | "desc" } {
const fields = sort.split(",").map((field) => field.trim());
const orderByClauses = fields.map((field) => {
if (field.startsWith("-")) {
return {
column: field.substring(1),
direction: "desc" as const,
};
}
return {
column: field,
direction: "asc" as const,
};
});
// Return single object if only one field, array if multiple
return orderByClauses.length === 1 ? orderByClauses[0] : orderByClauses;
}
/**
* Normalize orderBy parameter to array format.
* Supports 3 modes:
* 1. String: "name" -> [{ column: "name", direction: "asc" }]
* 2. Object: { column: "name", direction: "desc" } -> [{ column: "name", direction: "desc" }]
* 3. Array: [{ column: "name" }, { column: "age", direction: "desc" }] -> normalized array
*
* @param orderBy The orderBy parameter
* @returns Normalized array of order by clauses
*/
public normalizeOrderBy(
orderBy: any,
): Array<{ column: string; direction: "asc" | "desc" }> {
// Mode 1: String -> single column, ASC by default
if (typeof orderBy === "string") {
return [{ column: orderBy, direction: "asc" }];
}
// Mode 2: Single object -> convert to array
if (!Array.isArray(orderBy) && typeof orderBy === "object") {
return [
{
column: orderBy.column,
direction: orderBy.direction ?? "asc",
},
];
}
// Mode 3: Array -> normalize each item with default direction
if (Array.isArray(orderBy)) {
return orderBy.map((item) => ({
column: item.column,
direction: item.direction ?? "asc",
}));
}
return [];
}
/**
* Create a pagination object.
*
* @deprecated Use `createPagination` from alepha instead.
* This method now delegates to the framework-level helper.
*
* @param entities The entities to paginate.
* @param limit The limit of the pagination.
* @param offset The offset of the pagination.
* @param sort Optional sort metadata to include in response.
*/
public createPagination<T>(
entities: T[],
limit = 10,
offset = 0,
sort?: Array<{ column: string; direction: "asc" | "desc" }>,
) {
return createPagination(entities, limit, offset, sort);
}
}
// ---------------------------------------------------------------------------------------------------------------------
export interface PgJoin {
table: string;
schema: TObject;
key: string;
col: (key: string) => PgColumn;
parent?: string;
}