diff --git a/src/Condition.ts b/src/Condition.ts index 9456907..269b23d 100644 --- a/src/Condition.ts +++ b/src/Condition.ts @@ -1,15 +1,25 @@ import { Dayjs } from "dayjs"; -import { Expression, ExpressionValue, ValueExpression } from "./Expression"; +import { + Expression, + ExpressionBase, + ExpressionValue, + ValueExpression, +} from "./Expression"; import { ISQLFlavor } from "./Flavor"; import { Q } from "./Query"; +import { ISequelizable, ISerializable } from "./interfaces"; -export interface Condition { - toSQL(flavor: ISQLFlavor): string; - toJSON(): any; -} - -export namespace Condition { - export function fromJSON(json: any): Condition { +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); @@ -28,17 +38,27 @@ export namespace Condition { case "ColumnComparisonCondition": return ColumnComparisonCondition.fromJSON(json); default: - throw new Error(`Unknown condition type: ${json.type}`); + throw new Error( + `Unknown condition type: ${json.type} (${JSON.stringify(json)})` + ); } } + static deserialize(value: Condition): Condition { + return value; + // console.log("??", value); + // if (typeof value === "string") { + // return this.fromJSON(JSON.parse(value)); + // } + // return this.fromJSON(value); + } } export type ConditionValue = string | number | boolean | null | Dayjs; type Operator = "=" | "!=" | ">" | "<" | ">=" | "<="; -class BinaryCondition implements Condition { - key: Expression; - value: Expression; +class BinaryCondition extends Condition { + key: ExpressionBase; + value: ExpressionBase; operator: Operator; constructor( @@ -46,8 +66,9 @@ class BinaryCondition implements Condition { value: ExpressionValue, operator: Operator ) { + super(); this.key = Q.expr(key); - this.value = Q.exprValue(value); + this.value = Q.value(value); this.operator = operator; } @@ -69,18 +90,19 @@ class BinaryCondition implements Condition { static fromJSON(json: any): BinaryCondition { return new BinaryCondition( - Expression.deserialize(json.key), - Expression.deserializeValue(json.value), + ExpressionBase.deserialize(json.key), + ExpressionBase.deserializeValue(json.value), json.operator ); } } -class LogicalCondition implements Condition { +class LogicalCondition extends Condition { conditions: Condition[]; operator: "AND" | "OR"; constructor(conditions: Condition[], operator: "AND" | "OR") { + super(); this.conditions = conditions; this.operator = operator; } @@ -106,16 +128,17 @@ class LogicalCondition implements Condition { } } -class BetweenCondition implements Condition { - key: Expression; - from: Expression; - to: Expression; +class BetweenCondition extends Condition { + key: ExpressionBase; + from: ExpressionBase; + to: ExpressionBase; constructor( key: ExpressionValue, from: ExpressionValue, to: ExpressionValue ) { + super(); this.key = Q.expr(key); this.from = Q.expr(from); this.to = Q.expr(to); @@ -139,18 +162,19 @@ class BetweenCondition implements Condition { static fromJSON(json: any): BetweenCondition { return new BetweenCondition( - Expression.deserialize(json.key), - Expression.deserializeValue(json.from), - Expression.deserializeValue(json.to) + ExpressionBase.deserialize(json.key), + ExpressionBase.deserializeValue(json.from), + ExpressionBase.deserializeValue(json.to) ); } } -class InCondition implements Condition { - key: Expression; - values: Expression[]; +class InCondition extends Condition { + key: ExpressionBase; + values: ExpressionBase[]; constructor(key: ExpressionValue, values: ExpressionValue[]) { + super(); this.key = Q.expr(key); this.values = values.map((v) => Q.exprValue(v)); } @@ -172,17 +196,18 @@ class InCondition implements Condition { static fromJSON(json: any): InCondition { return new InCondition( - Expression.deserialize(json.key), - json.values.map(Expression.deserializeValue) + ExpressionBase.deserialize(json.key), + json.values.map(ExpressionBase.deserializeValue) ); } } -class NotInCondition implements Condition { - key: Expression; - values: Expression[]; +class NotInCondition extends Condition { + key: ExpressionBase; + values: ExpressionBase[]; constructor(key: ExpressionValue, values: ExpressionValue[]) { + super(); this.key = Q.expr(key); this.values = values.map((v) => Q.expr(v)); } @@ -204,17 +229,18 @@ class NotInCondition implements Condition { static fromJSON(json: any): NotInCondition { return new NotInCondition( - Expression.deserialize(json.key), - json.values.map(Expression.deserializeValue) + ExpressionBase.deserialize(json.key), + json.values.map(ExpressionBase.deserializeValue) ); } } -class NullCondition implements Condition { - key: Expression; +class NullCondition extends Condition { + key: ExpressionBase; isNull: boolean; constructor(key: ExpressionValue, isNull: boolean) { + super(); this.key = Q.expr(key); this.isNull = isNull; } @@ -233,16 +259,17 @@ class NullCondition implements Condition { } static fromJSON(json: any): NullCondition { - return new NullCondition(Expression.deserialize(json.key), json.isNull); + return new NullCondition(ExpressionBase.deserialize(json.key), json.isNull); } } -class LikeCondition implements Condition { - key: Expression; +class LikeCondition extends Condition { + key: ExpressionBase; pattern: string; isLike: boolean; constructor(key: ExpressionValue, pattern: string, isLike: boolean) { + super(); this.key = Q.expr(key); this.pattern = pattern; this.isLike = isLike; @@ -266,16 +293,16 @@ class LikeCondition implements Condition { static fromJSON(json: any): LikeCondition { return new LikeCondition( - Expression.deserialize(json.key), + ExpressionBase.deserialize(json.key), json.pattern, json.isLike ); } } -class ColumnComparisonCondition implements Condition { - leftKey: Expression; - rightKey: Expression; +class ColumnComparisonCondition extends Condition { + leftKey: ExpressionBase; + rightKey: ExpressionBase; operator: Operator; constructor( @@ -283,6 +310,7 @@ class ColumnComparisonCondition implements Condition { rightKey: ExpressionValue, operator: Operator ) { + super(); this.leftKey = Q.expr(leftKey); this.rightKey = Q.expr(rightKey); this.operator = operator; @@ -306,8 +334,8 @@ class ColumnComparisonCondition implements Condition { static fromJSON(json: any): ColumnComparisonCondition { return new ColumnComparisonCondition( - Expression.deserialize(json.leftKey), - Expression.deserialize(json.rightKey), + ExpressionBase.deserialize(json.leftKey), + ExpressionBase.deserialize(json.rightKey), json.operator ); } diff --git a/src/Expression.test.ts b/src/Expression.test.ts index 992b5b6..4a1aefe 100644 --- a/src/Expression.test.ts +++ b/src/Expression.test.ts @@ -1,4 +1,4 @@ -import { Expression } from "./Expression"; +import { Expression, ExpressionBase } from "./Expression"; import { Q } from "./Query"; const flavor = Q.flavors.mysql; @@ -32,32 +32,32 @@ describe("Expression", () => { describe("Expression serialization", () => { it("should serialize/deserialize expressions", () => { const a = Q.expr("foo").serialize(); - expect(Expression.deserialize(a).toSQL(flavor)).toEqual( + expect(ExpressionBase.deserialize(a).toSQL(flavor)).toEqual( Q.expr("foo").toSQL(flavor) ); expect(Q.expr(Expression.escapeColumn("foo")).toSQL(flavor)).toEqual( - Expression.deserialize(Expression.escapeColumn("foo")).toSQL(flavor) + ExpressionBase.deserialize(Expression.escapeColumn("foo")).toSQL(flavor) ); expect(Q.expr(Expression.escapeString("blah")).toSQL(flavor)).toEqual( - Expression.deserialize(Expression.escapeString("blah")).toSQL(flavor) + ExpressionBase.deserialize(Expression.escapeString("blah")).toSQL(flavor) ); }); it("should serialize/deserialize expressions multiple times", () => { const expr = Q.expr("foo"); expect( - Expression.deserialize( - Expression.deserialize(expr.serialize()).serialize() + ExpressionBase.deserialize( + ExpressionBase.deserialize(expr.serialize()).serialize() ).toSQL(flavor) ).toEqual(expr.toSQL(flavor)); const expr2 = Q.exprValue(123); expect( - Expression.deserialize( - Expression.deserializeValue( - Expression.deserializeValue(expr2.serialize()).serialize() + ExpressionBase.deserialize( + ExpressionBase.deserializeValue( + ExpressionBase.deserializeValue(expr2.serialize()).serialize() ).serialize() ).toSQL(flavor) ).toEqual(expr2.toSQL(flavor)); diff --git a/src/Expression.ts b/src/Expression.ts index 01eb3bd..5c94258 100644 --- a/src/Expression.ts +++ b/src/Expression.ts @@ -1,11 +1,51 @@ +import { Condition, ConditionValue } from "./Condition"; import { ISQLFlavor } from "./Flavor"; import { ISequelizable, ISerializable } from "./interfaces"; export type ExpressionRawValue = string | number; -export type ExpressionValue = Expression | ExpressionRawValue; +export type ExpressionValue = + | ExpressionBase + | ExpressionRawValue + | FunctionExpression + | OperationExpression + | Condition; -export class Expression implements ISerializable, ISequelizable { - constructor(protected value: ExpressionValue) {} +export class ExpressionBase implements ISerializable, ISequelizable { + static deserialize(value: ExpressionValue): ExpressionBase { + if (typeof value === "string" && ValueExpression.isValueString(value)) { + return ValueExpression.deserialize(value); + } + if (typeof value === "string" && FunctionExpression.isValidString(value)) { + return FunctionExpression.deserialize(value); + } + if (typeof value === "string" && OperationExpression.isValidString(value)) { + return OperationExpression.deserialize(value); + } + if (typeof value === "string" || typeof value === "number") { + return new Expression(value); + } + if (value instanceof Condition) { + return Condition.deserialize(value); + } + return value; + } + static deserializeValue(value: ExpressionValue): ValueExpression { + if (value instanceof ValueExpression) { + return value; + } + return ValueExpression.deserialize(value); + } + toSQL(flavor: ISQLFlavor): string { + throw new Error("Method not implemented."); + } + serialize(): string { + throw new Error("Method not implemented."); + } +} +export class Expression extends ExpressionBase { + constructor(public value: T) { + super(); + } toSQL(flavor: ISQLFlavor): string { if (this.value instanceof Expression) { @@ -37,21 +77,7 @@ export class Expression implements ISerializable, ISequelizable { serialize(): string { return `${this.value}`; } - static deserialize(value: ExpressionValue): Expression { - if (typeof value === "string" && value.startsWith("!!!")) { - return ValueExpression.deserialize(value); - } - if (typeof value === "string" || typeof value === "number") { - return new Expression(value); - } - return value; - } - static deserializeValue(value: ExpressionValue): ValueExpression { - if (value instanceof ValueExpression) { - return value; - } - return ValueExpression.deserialize(value); - } + static escapeColumn(column: ExpressionRawValue): string { return `#${column}#`; } @@ -62,11 +88,20 @@ export class Expression implements ISerializable, ISequelizable { if (typeof column === "string" || typeof column === "number") { return this.escapeColumn(column); } - return `${column.value}`; + if (column instanceof FunctionExpression) { + throw new Error("FunctionExpression cannot be used as a value"); + } + if (column instanceof Expression) { + return `${column.value}`; + } + throw new Error(`Invalid expression value: ${column}`); } } export class ValueExpression extends Expression { + static isValueString(str: string): boolean { + return str.startsWith("!!!"); + } toSQL(flavor: ISQLFlavor): string { if (typeof this.value === "number" || typeof this.value === "string") { return flavor.escapeValue(this.value); @@ -84,3 +119,66 @@ export class ValueExpression extends Expression { return new ValueExpression(value); } } + +export class FunctionExpression extends Expression { + constructor(public name: string, ...args: ExpressionValue[]) { + super(args); + } + toSQL(flavor: ISQLFlavor): string { + return flavor.escapeFunction(this); + } + static isValidString(str: string): boolean { + return str.startsWith("FN(") && str.endsWith(")"); + } + static deserialize(value: ExpressionValue): FunctionExpression { + if (typeof value === "string") { + const content = value.substring(3, value.length - 1); + const [name, ...args] = JSON.parse(content); + return new FunctionExpression(name, ...args.map(Expression.deserialize)); + } + throw new Error(`Invalid function expression: '${value}'`); + } + serialize(): string { + return ( + `FN(` + + JSON.stringify([ + this.name, + ...this.value.map((v) => Expression.deserialize(v).serialize()), + ]) + + `)` + ); + } +} + +export class OperationExpression extends Expression { + constructor(public operation, ...args: ExpressionValue[]) { + super(args); + } + toSQL(flavor: ISQLFlavor): string { + return flavor.escapeOperation(this); + } + static isValidString(str: string): boolean { + return str.startsWith("OP(") && str.endsWith(")"); + } + static deserialize(value: ExpressionValue): OperationExpression { + if (typeof value === "string") { + const content = value.substring(3, value.length - 1); + const [operation, ...args] = JSON.parse(content); + return new OperationExpression( + operation, + ...args.map(Expression.deserialize) + ); + } + throw new Error(`Invalid function expression: '${value}'`); + } + serialize(): string { + return ( + `OP(` + + JSON.stringify([ + this.operation, + ...this.value.map((v) => Expression.deserialize(v).serialize()), + ]) + + `)` + ); + } +} diff --git a/src/Flavor.ts b/src/Flavor.ts index a8a9452..63dbf96 100644 --- a/src/Flavor.ts +++ b/src/Flavor.ts @@ -1,8 +1,11 @@ import { ConditionValue } from "./Condition"; +import { FunctionExpression, OperationExpression } from "./Expression"; export interface ISQLFlavor { escapeColumn(name: string, legacy?: boolean): string; escapeTable(table: string): string; escapeValue(value: ConditionValue): string; escapeLimitAndOffset(limit?: number, offset?: number): string; + escapeFunction(fn: FunctionExpression): string; + escapeOperation(fn: OperationExpression): string; } diff --git a/src/Function.test.ts b/src/Function.test.ts index f73d853..e157987 100644 --- a/src/Function.test.ts +++ b/src/Function.test.ts @@ -40,5 +40,35 @@ describe("Expression", () => { expect( Fn.if(Cond.equal("foo_blah", 123), "aa", Q.expr(-123)).toSQL(flavor) ).toEqual("IF(`foo_blah` = 123,`aa`,-123)"); + expect( + Fn.dateRangeSumField({ + dateColumn: "tax_date", + valueColumn: "amount", + start: "2020-01-01", + end: "2020-01-31", + }).toSQL(flavor) + ).toEqual( + 'SUM(IF(`tax_date` BETWEEN "2020-01-01" AND "2020-01-31",`amount`,0))' + ); + }); + it("should support serialization for functions", () => { + const serialized = + '{"type":"SelectQuery","tables":[],"unionQueries":[],"joins":[],"fields":[{"name":"SUM(YEAR(#foo#))"}],"where":[],"having":[],"orderBy":[],"groupBy":[]}'; + + const fn = Q.select().addField(Fn.sum(Fn.year("foo"))); + const fn2 = Q.deserialize(serialized); + // console.log("serialized test:", JSON.stringify(fn.serialize())); + + expect(fn2.toSQL(flavor)).toEqual(fn.toSQL(flavor)); + }); + it("should support serialization for operations", () => { + const serialized = + '{"type":"SelectQuery","tables":[],"unionQueries":[],"joins":[],"fields":[{"name":"((#foo# / #blah#) + #xyz#)"}],"where":[],"having":[],"orderBy":[],"groupBy":[]}'; + + const fn = Q.select().addField(Fn.add(Fn.divide("foo", "blah"), "xyz")); + const fn2 = Q.deserialize(serialized); + // console.log("serialized test:", JSON.stringify(fn.serialize())); + + expect(fn2.toSQL(flavor)).toEqual(fn.toSQL(flavor)); }); }); diff --git a/src/Function.ts b/src/Function.ts index ae68f02..41f5e30 100644 --- a/src/Function.ts +++ b/src/Function.ts @@ -1,54 +1,49 @@ import dayjs, { Dayjs } from "dayjs"; -import { Condition } from "./Condition"; -import { ISQLFlavor } from "./Flavor"; -import { MySQLFlavor } from "./flavors/mysql"; -import { Expression, ExpressionValue } from "./Expression"; +import { Cond, Condition } from "./Condition"; +import { + Expression, + ExpressionValue, + FunctionExpression, + OperationExpression, +} from "./Expression"; import { Q } from "./Query"; +import { MySQLFlavor } from "./flavors/mysql"; const formatDayjs = (dayjs: Dayjs) => dayjs.format("YYYY-MM-DD"); -const defaultFlavor = new MySQLFlavor(); export const Function = { add: (...columns: ExpressionValue[]) => { - return Q.expr( - `(${columns.map((v) => Expression.escapeExpressionValue(v)).join(" + ")})` - ); + return new OperationExpression("+", ...columns); }, subtract: (...columns: ExpressionValue[]) => { - return Q.expr( - `(${columns.map((v) => Expression.escapeExpressionValue(v)).join(" - ")})` - ); + return new OperationExpression("-", ...columns); }, divide: (...columns: ExpressionValue[]) => { - return Q.expr( - `(${columns.map((v) => Expression.escapeExpressionValue(v)).join(" / ")})` - ); + return new OperationExpression("/", ...columns); }, multiply: (...columns: ExpressionValue[]) => { - return Q.expr( - `(${columns.map((v) => Expression.escapeExpressionValue(v)).join(" * ")})` - ); + return new OperationExpression("*", ...columns); }, sum: (column: ExpressionValue) => { - return Q.expr(`SUM(${Expression.escapeExpressionValue(column)})`); + return new FunctionExpression("SUM", column); }, year: (column: ExpressionValue) => { - return Q.expr(`YEAR(${Expression.escapeExpressionValue(column)})`); + return new FunctionExpression("YEAR", column); }, month: (column: ExpressionValue) => { - return Q.expr(`MONTH(${Expression.escapeExpressionValue(column)})`); + return new FunctionExpression("MONTH", column); }, min: (column: ExpressionValue) => { - return Q.expr(`MIN(${Expression.escapeExpressionValue(column)})`); + return new FunctionExpression("MIN", column); }, max: (column: ExpressionValue) => { - return Q.expr(`MAX(${Expression.escapeExpressionValue(column)})`); + return new FunctionExpression("MAX", column); }, avg: (column: ExpressionValue) => { - return Q.expr(`AVG(${Expression.escapeExpressionValue(column)})`); + return new FunctionExpression("AVG", column); }, abs: (column: ExpressionValue) => { - return Q.expr(`ABS(${Expression.escapeExpressionValue(column)})`); + return new FunctionExpression("ABS", column); }, dateDiff: ( interval: "year" | "month" | "day", @@ -94,23 +89,14 @@ export const Function = { ); }, concat: (...values: ExpressionValue[]) => { - return Q.expr( - `CONCAT(${values - .map((x) => Expression.escapeExpressionValue(x)) - .join(",")})` - ); + return new FunctionExpression("CONCAT", ...values); }, if: ( condition: Condition, trueValue: ExpressionValue, - falseValue: ExpressionValue, - flavor: ISQLFlavor = defaultFlavor + falseValue: ExpressionValue ) => { - return Q.expr( - `IF(${condition.toSQL(flavor)},${Expression.escapeExpressionValue( - trueValue - )},${Expression.escapeExpressionValue(falseValue)})` - ); + return new FunctionExpression("IF", condition, trueValue, falseValue); }, dateRangeSumField: ({ dateColumn, @@ -122,12 +108,20 @@ export const Function = { valueColumn: string; start: Dayjs | string; end: Dayjs | string; - }) => - Q.expr( - `SUM(IF(${dateColumn} BETWEEN '${formatDayjs( - dayjs(start) - )}' AND '${formatDayjs(dayjs(end))}',${valueColumn},0))` - ), + }) => { + return new FunctionExpression( + "SUM", + new FunctionExpression( + "IF", + Cond.between(dateColumn, [ + formatDayjs(dayjs(start)), + formatDayjs(dayjs(end)), + ]), + valueColumn, + Q.value(0) + ) + ); + }, priceCurrentAndPreviousDiffField: ({ thisYearColumn, diff --git a/src/Query.ts b/src/Query.ts index 8ce2b0b..e44feb1 100644 --- a/src/Query.ts +++ b/src/Query.ts @@ -1,7 +1,12 @@ import { Condition } from "./Condition"; import { CreateTableAsSelect } from "./CreateTableAsSelect"; import { CreateViewAsSelect } from "./CreateViewAsSelect"; -import { Expression, ExpressionValue } from "./Expression"; +import { + Expression, + ExpressionBase, + ExpressionRawValue, + ExpressionValue, +} from "./Expression"; import { ISQLFlavor } from "./Flavor"; import { AWSTimestreamFlavor } from "./flavors/aws-timestream"; import { MySQLFlavor } from "./flavors/mysql"; @@ -208,7 +213,7 @@ interface SelectField { } interface Order { - field: Expression; + field: ExpressionBase; direction: "ASC" | "DESC"; } @@ -277,7 +282,7 @@ export class SelectQuery extends SelectBaseQuery implements ISerializable { protected _limit?: number; protected _offset?: number; protected _orderBy: Order[] = []; - protected _groupBy: Expression[] = []; + protected _groupBy: ExpressionBase[] = []; protected _unionQueries: { query: SelectQuery; type: UnionType }[] = []; public clone(): this { @@ -338,7 +343,10 @@ export class SelectQuery extends SelectBaseQuery implements ISerializable { } orderBy(field: ExpressionValue, direction: "ASC" | "DESC" = "ASC"): this { const clone = this.clone(); - clone._orderBy.push({ field: Expression.deserialize(field), direction }); + clone._orderBy.push({ + field: Expression.deserialize(field), + direction, + }); return clone; } removeOrderBy(): this { @@ -346,7 +354,7 @@ export class SelectQuery extends SelectBaseQuery implements ISerializable { clone._orderBy = []; return clone; } - public getGroupBy(): Expression[] { + public getGroupBy(): ExpressionBase[] { return this._groupBy; } groupBy(...field: ExpressionValue[]): this { @@ -515,8 +523,10 @@ export const Query = { new CreateViewAsSelect(table, select, true), deserialize, flavors, - expr: Expression.deserialize, - exprValue: Expression.deserializeValue, + expr: (val: ExpressionValue) => ExpressionBase.deserialize(val), + exprValue: (val: ExpressionValue) => ExpressionBase.deserializeValue(val), + value: (val: ExpressionValue) => ExpressionBase.deserializeValue(val), + column: (col: ExpressionRawValue) => Expression.escapeColumn(col), S: (literals: string | readonly string[]) => { return Fn.string(`${literals}`); }, diff --git a/src/flavors/mysql.ts b/src/flavors/mysql.ts index a0e2f98..ac2cbc8 100644 --- a/src/flavors/mysql.ts +++ b/src/flavors/mysql.ts @@ -1,7 +1,11 @@ import { isDayjs } from "dayjs"; import { ConditionValue } from "../Condition"; +import { + Expression, + FunctionExpression, + OperationExpression, +} from "../Expression"; import { ISQLFlavor } from "../Flavor"; -import { SelectQuery } from "../Query"; export class MySQLFlavor implements ISQLFlavor { protected columnQuotes = "`"; @@ -60,4 +64,19 @@ export class MySQLFlavor implements ISQLFlavor { } return str; } + escapeFunction(fn: FunctionExpression): string { + const args = fn.value + .map((x) => Expression.deserialize(x).toSQL(this)) + .join(","); + return `${fn.name}(${args})`; + } + escapeOperation(fn: OperationExpression): string { + return ( + "(" + + fn.value + .map((x) => Expression.deserialize(x).toSQL(this)) + .join(` ${fn.operation} `) + + ")" + ); + } } diff --git a/src/index.ts b/src/index.ts index c5e407e..8e9872c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,11 @@ export { DeleteMutation, InsertMutation, UpdateMutation } from "./Mutation"; export { AWSTimestreamFlavor } from "./flavors/aws-timestream"; export { MySQLFlavor } from "./flavors/mysql"; -export { Cond, Condition, type ConditionValue } from "./Condition"; +export { + Cond, + ICondition as Condition, + type ConditionValue, +} from "./Condition"; export { Fn, Function } from "./Function"; export { Q, Query, SelectQuery } from "./Query"; export {