UNPKG

@jakub.knejzlik/ts-query

Version:

TypeScript implementation of SQL builder

553 lines (499 loc) 16.4 kB
import { Dayjs } from "dayjs"; import { ExpressionBase, ExpressionValue } from "./Expression"; import { ISQLFlavor } from "./Flavor"; import { Q, SelectQuery } from "./Query"; import { ISequelizable, ISerializable } from "./interfaces"; type ConditionValue = ExpressionValue | Dayjs; // Values accepted by IN / NOT IN: a list of values, a subquery, or null. type InValues = ConditionValue[] | SelectQuery | null; // Normalizes the value(s) passed to an IN/NOT IN condition. // - A SelectQuery (passed directly or as the only element of an array) becomes a subquery. // - An empty/null list is rendered fail-closed as `(NULL)` so an empty IN never silently // drops the filter (which would expose all rows). The NULL marker survives serialization. const normalizeInValues = ( values: InValues ): { subquery?: SelectQuery; values: ExpressionBase[] } => { if (values instanceof SelectQuery) { return { subquery: values, values: [] }; } const arr = values ?? []; const subqueryCount = arr.filter((v) => v instanceof SelectQuery).length; if (subqueryCount > 0) { // A subquery is only valid as the sole value: `IN (SELECT ...)`. Mixing a // subquery with scalar values (or multiple subqueries) is invalid SQL — fail // loud rather than emitting malformed SQL / throwing later on deserialize. if (subqueryCount > 1 || arr.length > 1) { throw new Error( "Cond.in/notIn: cannot mix a subquery with other values or use multiple subqueries; pass a single subquery or a list of scalar values" ); } return { subquery: arr[0] as SelectQuery, values: [] }; } if (arr.length === 0) { return { values: [Q.exprValue(Q.raw("NULL"))] }; } return { values: arr.map((v) => Q.exprValue(v)) }; }; export class Condition implements ISequelizable, ISerializable { toSQL(flavor: ISQLFlavor): string { throw new Error("Method not implemented."); } toJSON(): any { throw new Error("Method not implemented."); } serialize(): string { return JSON.stringify(this.toJSON()); } static fromJSON(json: any): Condition { switch (json.type) { case "BinaryCondition": return BinaryCondition.fromJSON(json); case "LogicalCondition": return LogicalCondition.fromJSON(json); case "BetweenCondition": return BetweenCondition.fromJSON(json); case "InCondition": return InCondition.fromJSON(json); case "NotInCondition": return NotInCondition.fromJSON(json); case "NullCondition": return NullCondition.fromJSON(json); case "LikeCondition": return LikeCondition.fromJSON(json); case "ColumnComparisonCondition": return ColumnComparisonCondition.fromJSON(json); case "NotCondition": return NotCondition.fromJSON(json); default: throw new Error( `Unknown condition type: ${json.type} (${JSON.stringify(json)})` ); } } static deserialize(value: Condition | string): Condition | null { if (typeof value === "string") { try { return Condition.fromJSON(JSON.parse(value)); } catch { return null; } } return value; } } type Operator = "=" | "!=" | ">" | "<" | ">=" | "<="; class BinaryCondition extends Condition { key: ExpressionBase; value: ExpressionBase; operator: Operator; constructor(key: ConditionValue, value: ConditionValue, operator: Operator) { super(); this.key = Q.expr(key); this.value = Q.value(value); this.operator = operator; } toSQL(flavor: ISQLFlavor): string { return `${this.key.toSQL(flavor)} ${this.operator} ${this.value.toSQL( flavor )}`; } // serialization toJSON(): any { return { type: "BinaryCondition", key: this.key.serialize(), value: this.value.serialize(), operator: this.operator, }; } static fromJSON(json: any): BinaryCondition { return new BinaryCondition( ExpressionBase.deserialize(json.key), ExpressionBase.deserializeValue(json.value), json.operator ); } } class LogicalCondition extends Condition { conditions: Condition[]; operator: "AND" | "OR"; constructor(conditions: Condition[], operator: "AND" | "OR") { super(); this.conditions = conditions; this.operator = operator; } toSQL(flavor: ISQLFlavor): string { return `(${this.conditions .map((c) => c.toSQL(flavor)) .join(` ${this.operator} `)})`; } // serialization toJSON(): any { return { type: "LogicalCondition", conditions: this.conditions.map((condition) => condition.toJSON()), operator: this.operator, }; } static fromJSON(json: any): LogicalCondition { const conditions = json.conditions.map(Condition.fromJSON); return new LogicalCondition(conditions, json.operator); } } class BetweenCondition extends Condition { key: ExpressionBase; from: ExpressionBase; to: ExpressionBase; constructor(key: ConditionValue, from: ConditionValue, to: ConditionValue) { super(); this.key = Q.expr(key); this.from = Q.expr(from); this.to = Q.expr(to); } toSQL(flavor: ISQLFlavor): string { return `${this.key.toSQL(flavor)} BETWEEN ${this.from.toSQL( flavor )} AND ${this.to.toSQL(flavor)}`; } // serialization toJSON(): any { return { type: "BetweenCondition", key: this.key.serialize(), from: this.from.serialize(), to: this.to.serialize(), }; } static fromJSON(json: any): BetweenCondition { return new BetweenCondition( ExpressionBase.deserialize(json.key), ExpressionBase.deserializeValue(json.from), ExpressionBase.deserializeValue(json.to) ); } } class InCondition extends Condition { key: ExpressionBase; values: ExpressionBase[]; subquery?: SelectQuery; constructor(key: ConditionValue, values: InValues) { super(); this.key = Q.expr(key); const normalized = normalizeInValues(values); this.values = normalized.values; this.subquery = normalized.subquery; } toSQL(flavor: ISQLFlavor): string { const right = this.subquery ? this.subquery.toSQL(flavor) : this.values.map((v) => v.toSQL(flavor)).join(", "); return `${this.key.toSQL(flavor)} IN (${right})`; } // serialization toJSON(): any { return { type: "InCondition", key: this.key.serialize(), ...(this.subquery ? { subquery: this.subquery.toJSON() } : { values: this.values.map((v) => v.serialize()) }), }; } static fromJSON(json: any): InCondition { if (json.subquery) { return new InCondition( ExpressionBase.deserialize(json.key), SelectQuery.fromJSON(json.subquery) ); } return new InCondition( ExpressionBase.deserialize(json.key), json.values.map(ExpressionBase.deserializeValue) ); } } class NotInCondition extends Condition { key: ExpressionBase; values: ExpressionBase[]; subquery?: SelectQuery; constructor(key: ConditionValue, values: InValues) { super(); this.key = Q.expr(key); const normalized = normalizeInValues(values); this.values = normalized.values; this.subquery = normalized.subquery; } toSQL(flavor: ISQLFlavor): string { const right = this.subquery ? this.subquery.toSQL(flavor) : this.values.map((v) => v.toSQL(flavor)).join(", "); return `${this.key.toSQL(flavor)} NOT IN (${right})`; } // serialization toJSON(): any { return { type: "NotInCondition", key: this.key.serialize(), ...(this.subquery ? { subquery: this.subquery.toJSON() } : { values: this.values.map((v) => v.serialize()) }), }; } static fromJSON(json: any): NotInCondition { if (json.subquery) { return new NotInCondition( ExpressionBase.deserialize(json.key), SelectQuery.fromJSON(json.subquery) ); } return new NotInCondition( ExpressionBase.deserialize(json.key), json.values.map(ExpressionBase.deserializeValue) ); } } class NullCondition extends Condition { key: ExpressionBase; isNull: boolean; constructor(key: ConditionValue, isNull: boolean) { super(); this.key = Q.expr(key); this.isNull = isNull; } toSQL(flavor: ISQLFlavor): string { return `${this.key.toSQL(flavor)} IS ${this.isNull ? "" : "NOT "}NULL`; } // serialization toJSON(): any { return { type: "NullCondition", key: this.key.serialize(), isNull: this.isNull, }; } static fromJSON(json: any): NullCondition { return new NullCondition(ExpressionBase.deserialize(json.key), json.isNull); } } class LikeCondition extends Condition { key: ExpressionBase; pattern: string; isLike: boolean; caseInsensitive: boolean; constructor( key: ConditionValue, pattern: string, isLike: boolean, caseInsensitive: boolean = false ) { super(); this.key = Q.expr(key); this.pattern = pattern; this.isLike = isLike; this.caseInsensitive = caseInsensitive; } toSQL(flavor: ISQLFlavor): string { const escapedColumn = this.key.toSQL(flavor); const escapedPattern = flavor.escapeValue(this.pattern); return flavor.escapeLikeCondition( escapedColumn, escapedPattern, this.isLike, this.caseInsensitive ); } // serialization toJSON(): any { return { type: "LikeCondition", key: this.key.serialize(), pattern: this.pattern, isLike: this.isLike, caseInsensitive: this.caseInsensitive, }; } static fromJSON(json: any): LikeCondition { return new LikeCondition( ExpressionBase.deserialize(json.key), json.pattern, json.isLike, json.caseInsensitive ?? false ); } } class ColumnComparisonCondition extends Condition { leftKey: ExpressionBase; rightKey: ExpressionBase; operator: Operator; constructor( leftKey: ConditionValue, rightKey: ConditionValue, operator: Operator ) { super(); this.leftKey = Q.expr(leftKey); this.rightKey = Q.expr(rightKey); this.operator = operator; } toSQL(flavor: ISQLFlavor): string { return `${this.leftKey.toSQL(flavor)} ${ this.operator } ${this.rightKey.toSQL(flavor)}`; } // serialization toJSON(): any { return { type: "ColumnComparisonCondition", leftKey: this.leftKey.serialize(), rightKey: this.rightKey.serialize(), operator: this.operator, }; } static fromJSON(json: any): ColumnComparisonCondition { return new ColumnComparisonCondition( ExpressionBase.deserialize(json.leftKey), ExpressionBase.deserialize(json.rightKey), json.operator ); } } class NotCondition extends Condition { condition: Condition; constructor(condition: Condition) { super(); this.condition = condition; } toSQL(flavor: ISQLFlavor): string { return `NOT (${this.condition.toSQL(flavor)})`; } // serialization toJSON(): any { return { type: "NotCondition", condition: this.condition.toJSON(), }; } static fromJSON(json: any): NotCondition { return new NotCondition(Condition.fromJSON(json.condition)); } } export const Conditions = { fromString: ( column: string, value: string | number, delimiters: string[] = [" "] ): Condition => { const str = `${value}`; if (str.indexOf(">=") === 0) { return Conditions.greaterThanOrEqual(column, str.substring(2)); } if (str.indexOf("<=") === 0) { return Conditions.lessThanOrEqual(column, str.substring(2)); } if (str.indexOf(">") === 0) { return Conditions.greaterThan(column, str.substring(1)); } if (str.indexOf("<") === 0) { return Conditions.lessThan(column, str.substring(1)); } if (str.indexOf("~") === 0) { const searchValue = str.substring(1); const conditions = [Conditions.like(column, `${searchValue}%`)]; for (const delimiter of delimiters) { conditions.push( Conditions.like(column, `%${delimiter}${searchValue}%`) ); } return Conditions.or(conditions); } if (str.indexOf("!~") === 0) { const searchValue = str.substring(2); const conditions = [Conditions.notLike(column, `${searchValue}%`)]; for (const delimiter of delimiters) { conditions.push( Conditions.notLike(column, `%${delimiter}${searchValue}%`) ); } return Conditions.and(conditions); } if (str.indexOf("!") === 0) { return Conditions.notEqual(column, `${str.substring(1)}%`); } return Conditions.equal(column, str); }, equal: (key: ConditionValue, value: ConditionValue) => new BinaryCondition(key, value, "="), notEqual: (key: ConditionValue, value: ConditionValue) => new BinaryCondition(key, value, "!="), greaterThan: (key: ConditionValue, value: ConditionValue) => new BinaryCondition(key, value, ">"), lessThan: (key: ConditionValue, value: ConditionValue) => new BinaryCondition(key, value, "<"), greaterThanOrEqual: (key: ConditionValue, value: ConditionValue) => new BinaryCondition(key, value, ">="), lessThanOrEqual: (key: ConditionValue, value: ConditionValue) => new BinaryCondition(key, value, "<="), between: (key: ConditionValue, values: [ConditionValue, ConditionValue]) => new BetweenCondition(key, Q.exprValue(values[0]), Q.exprValue(values[1])), // Fail-closed: an empty/null list renders `IN (NULL)` (never matches) instead of being // dropped, so an empty IN can never silently expand visibility. Accepts a subquery too. in: (key: ConditionValue, values: InValues) => new InCondition(key, values), notIn: (key: ConditionValue, values: InValues) => new NotInCondition(key, values), and: (conditions: (Condition | null)[] | null) => { const _c = (conditions || []).filter((c) => c !== null); return _c.length > 0 ? new LogicalCondition(_c, "AND") : null; }, or: (conditions: (Condition | null)[] | null) => { const _c = (conditions || []).filter((c) => c !== null); return _c.length > 0 ? new LogicalCondition(_c, "OR") : null; }, isNull: (key: ConditionValue) => new NullCondition(key, true), isNotNull: (key: ConditionValue) => new NullCondition(key, false), // Deprecated: use isNull instead null: (key: ConditionValue) => new NullCondition(key, true), // Deprecated: use isNotNull instead notNull: (key: ConditionValue) => new NullCondition(key, false), like: ( key: ConditionValue, pattern: string, opts?: { caseInsensitive?: boolean } ) => new LikeCondition(key, pattern, true, opts?.caseInsensitive ?? false), notLike: ( key: ConditionValue, pattern: string, opts?: { caseInsensitive?: boolean } ) => new LikeCondition(key, pattern, false, opts?.caseInsensitive ?? false), ilike: (key: ConditionValue, pattern: string) => new LikeCondition(key, pattern, true, true), notIlike: (key: ConditionValue, pattern: string) => new LikeCondition(key, pattern, false, true), columnEqual: (leftKey: ConditionValue, rightKey: ConditionValue) => new ColumnComparisonCondition(leftKey, rightKey, "="), columnNotEqual: (leftKey: ConditionValue, rightKey: ConditionValue) => new ColumnComparisonCondition(leftKey, rightKey, "!="), columnGreaterThan: (leftKey: ConditionValue, rightKey: ConditionValue) => new ColumnComparisonCondition(leftKey, rightKey, ">"), columnLessThan: (leftKey: ConditionValue, rightKey: ConditionValue) => new ColumnComparisonCondition(leftKey, rightKey, "<"), columnGreaterThanOrEqual: (leftKey: ConditionValue, rightKey: ConditionValue) => new ColumnComparisonCondition(leftKey, rightKey, ">="), columnLessThanOrEqual: (leftKey: ConditionValue, rightKey: ConditionValue) => new ColumnComparisonCondition(leftKey, rightKey, "<="), not: (condition: Condition) => new NotCondition(condition), }; export { Conditions as Cond }; // Export condition subclasses for use by query targets export { BinaryCondition, LogicalCondition, BetweenCondition, InCondition, NotInCondition, NullCondition, LikeCondition, ColumnComparisonCondition, NotCondition, }; // Export operator type for targets export type { Operator };