@jakub.knejzlik/ts-query
Version:
TypeScript implementation of SQL builder
553 lines (499 loc) • 16.4 kB
text/typescript
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 };