From c0e918529eee13c11b58b77695d8e12583a7a5d0 Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 23 Jun 2026 21:47:54 +0300 Subject: [PATCH] feat: add dialect-specific SQL identifier quoting for PG and MySQL --- src/lib/mysql/helpers/compare-fields.ts | 23 +- .../mysql/helpers/compare-fields.unit.spec.ts | 11 + src/lib/mysql/model/materialized-view.ts | 90 +++-- .../model/materialized-view.unit.spec.ts | 94 +++++ src/lib/mysql/model/table.ts | 179 ++++++--- src/lib/mysql/model/table.unit.spec.ts | 278 ++++++++++++++ src/lib/mysql/model/view.ts | 92 +++-- src/lib/mysql/model/view.unit.spec.ts | 126 +++++++ src/lib/pg/helpers/compare-fields.ts | 23 +- src/lib/pg/helpers/get-fields-to-search.ts | 1 + src/lib/pg/model/materialized-view.ts | 71 ++-- .../pg/model/materialized-view.unit.spec.ts | 191 ++++++++++ src/lib/pg/model/queries.ts | 16 +- src/lib/pg/model/queries.unit.spec.ts | 298 +++++++++++++++ src/lib/pg/model/table.ts | 176 ++++++--- src/lib/pg/model/table.unit.spec.ts | 355 ++++++++++++++++++ src/lib/pg/model/view.ts | 76 ++-- src/lib/pg/model/view.unit.spec.ts | 173 +++++++++ src/shared-helpers/index.ts | 34 ++ 19 files changed, 2082 insertions(+), 225 deletions(-) create mode 100644 src/lib/mysql/model/materialized-view.unit.spec.ts create mode 100644 src/lib/mysql/model/table.unit.spec.ts create mode 100644 src/lib/mysql/model/view.unit.spec.ts create mode 100644 src/lib/pg/model/materialized-view.unit.spec.ts create mode 100644 src/lib/pg/model/queries.unit.spec.ts create mode 100644 src/lib/pg/model/table.unit.spec.ts create mode 100644 src/lib/pg/model/view.unit.spec.ts diff --git a/src/lib/mysql/helpers/compare-fields.ts b/src/lib/mysql/helpers/compare-fields.ts index 7e02cea..ae248be 100644 --- a/src/lib/mysql/helpers/compare-fields.ts +++ b/src/lib/mysql/helpers/compare-fields.ts @@ -1,3 +1,5 @@ +import * as SharedHelpers from "../../../shared-helpers/index.js"; + import * as Types from "../model/types.js"; import { processMappings } from "./process-mappings.js"; @@ -15,6 +17,9 @@ import { processMappings } from "./process-mappings.js"; export const compareFields = ( params: Types.TSearchParams = {}, paramsOr?: Types.TSearchParams[], + options?: { + tableFieldsSet?: Set; + }, ): { queryArray: Types.TField[]; queryOrArray: { query: Types.TField[]; }[]; @@ -23,12 +28,14 @@ export const compareFields = ( const queryArray: Types.TField[] = []; const values: unknown[] = []; + const { tableFieldsSet } = options || {}; + for (const entry of Object.entries(params)) { const key = entry[0]; const value = entry[1]; if (value === null) { - queryArray.push({ key: `${key} IS NULL`, operator: "$withoutParameters" }); + queryArray.push({ key: `${SharedHelpers.quoteMysqlIdent(key, { tableFieldsSet })} IS NULL`, operator: "$withoutParameters" }); } else if (typeof value === "object") { if (Array.isArray(value)) { for (const v of value) { @@ -39,7 +46,7 @@ export const compareFields = ( throw new Error(`Invalid value.key ${k}, Available values: ${Array.from(processMappings.keys())}`); } - processFunction(key, v, queryArray, values); + processFunction(SharedHelpers.quoteMysqlIdent(key, { tableFieldsSet }), v, queryArray, values); } } } else { @@ -50,11 +57,11 @@ export const compareFields = ( throw new Error(`Invalid value.key ${k}, Available values: ${Array.from(processMappings.keys())}`); } - processFunction(key, value, queryArray, values); + processFunction(SharedHelpers.quoteMysqlIdent(key, { tableFieldsSet }), value, queryArray, values); } } } else if (value !== undefined) { - queryArray.push({ key, operator: "=" }); + queryArray.push({ key: SharedHelpers.quoteMysqlIdent(key, { tableFieldsSet }), operator: "=" }); values.push(value); } } @@ -74,7 +81,7 @@ export const compareFields = ( const value = entry[1]; if (value === null) { - queryOrArrayLocal.push({ key: `${key} IS NULL`, operator: "$withoutParameters" }); + queryOrArrayLocal.push({ key: `${SharedHelpers.quoteMysqlIdent(key, { tableFieldsSet })} IS NULL`, operator: "$withoutParameters" }); } else if (typeof value === "object") { if (Array.isArray(value)) { for (const v of value) { @@ -85,7 +92,7 @@ export const compareFields = ( throw new Error(`Invalid value.key ${k}, Available values: ${Array.from(processMappings.keys())}`); } - processFunction(key, v, queryOrArrayLocal, values); + processFunction(SharedHelpers.quoteMysqlIdent(key, { tableFieldsSet }), v, queryOrArrayLocal, values); } } } else { @@ -96,11 +103,11 @@ export const compareFields = ( throw new Error(`Invalid value.key ${k}, Available values: ${Array.from(processMappings.keys())}`); } - processFunction(key, value, queryOrArrayLocal, values); + processFunction(SharedHelpers.quoteMysqlIdent(key, { tableFieldsSet }), value, queryOrArrayLocal, values); } } } else if (value !== undefined) { - queryOrArrayLocal.push({ key, operator: "=" }); + queryOrArrayLocal.push({ key: SharedHelpers.quoteMysqlIdent(key, { tableFieldsSet }), operator: "=" }); values.push(value); } } diff --git a/src/lib/mysql/helpers/compare-fields.unit.spec.ts b/src/lib/mysql/helpers/compare-fields.unit.spec.ts index 83847cd..41d9efd 100644 --- a/src/lib/mysql/helpers/compare-fields.unit.spec.ts +++ b/src/lib/mysql/helpers/compare-fields.unit.spec.ts @@ -99,4 +99,15 @@ describe("compareFields", () => { ]); expect(result.values).toEqual(["active", "John", 30]); }); + + it("should quote identifiers when tableFieldsSet is provided", () => { + const tableFieldsSet = new Set(["typeID", "published"]); + const result = compareFields({ published: true, typeID: 34 }, undefined, { tableFieldsSet }); + + expect(result.queryArray).toEqual([ + { key: "`published`", operator: "=" }, + { key: "`typeID`", operator: "=" }, + ]); + expect(result.values).toEqual([true, 34]); + }); }); diff --git a/src/lib/mysql/model/materialized-view.ts b/src/lib/mysql/model/materialized-view.ts index 2837ea8..2643475 100644 --- a/src/lib/mysql/model/materialized-view.ts +++ b/src/lib/mysql/model/materialized-view.ts @@ -1,12 +1,12 @@ import mysql from "mysql2/promise"; import * as Helpers from "../helpers/index.js"; +import * as SharedHelpers from "../../../shared-helpers/index.js"; import * as SharedTypes from "../../../shared-types/index.js"; import * as Types from "./types.js"; import * as connection from "../connection.js"; import { QueryBuilder } from "../query-builder/index.js"; import queries from "./queries.js"; -import { setLoggerAndExecutor } from "../helpers/index.js"; /** * @experimental @@ -16,6 +16,7 @@ import { setLoggerAndExecutor } from "../helpers/index.js"; export class BaseMaterializedView { #sortingOrders = new Set(["ASC", "DESC"]); #coreFieldsSet; + #coreFieldsCoreSet; #isLoggerEnabled: boolean | undefined; #logger?: SharedTypes.TLogger; #executeSql; @@ -31,6 +32,8 @@ export class BaseMaterializedView { return new BaseMaterializedView( { ...this.#initialArgs.data }, this.#initialArgs.dbCreds ? { ...this.#initialArgs.dbCreds } : undefined, @@ -221,12 +243,16 @@ export class BaseMaterializedView { + const orderResult: { orderBy: string; ordering: SharedTypes.TOrdering; }[] = []; + if (order?.length) { for (const o of order) { + orderResult.push({ orderBy: SharedHelpers.quoteMysqlIdent(o.orderBy, { tableFieldsSet: this.#coreFieldsCoreSet }), ordering: o.ordering }); + if (!this.#coreFieldsSet.has(o.orderBy)) { const allowedFields = Array.from(this.#coreFieldsSet).join(", "); @@ -237,13 +263,19 @@ export class BaseMaterializedView selectedResult.push(SharedHelpers.quoteMysqlIdent(e, { tableFieldsSet: this.#coreFieldsCoreSet }))); + } + + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, pagination, orderResult); return { - query: queries.getByParams(this.name, selectedFields, searchFields, orderByFields, paginationFields), + query: queries.getByParams(this.#nameQuoted, selectedFields, searchFields, orderByFields, paginationFields), values, }; }, @@ -259,11 +291,11 @@ export class BaseMaterializedView { - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); const { searchFields } = this.getFieldsToSearch({ queryArray, queryOrArray }); return { - query: queries.getCountByParams(this.name, searchFields), + query: queries.getCountByParams(this.#nameQuoted, searchFields), values, }; }, @@ -279,16 +311,22 @@ export class BaseMaterializedView { - if (!selected.length) selected.push("*"); + const selectedResult = []; + + if (!selected.length) { + selectedResult.push("*"); + } else { + selected.forEach((e) => selectedResult.push(SharedHelpers.quoteMysqlIdent(e, { tableFieldsSet: this.#coreFieldsCoreSet }))); + } - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, { limit: 1, offset: 0 }); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, { limit: 1, offset: 0 }); return { query: queries.getByParams( - this.name, + this.#nameQuoted, selectedFields, searchFields, orderByFields, @@ -313,7 +351,7 @@ export class BaseMaterializedView { @@ -329,13 +367,19 @@ export class BaseMaterializedView selectedResult.push(SharedHelpers.quoteMysqlIdent(e, { tableFieldsSet: this.#coreFieldsCoreSet }))); + } - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, pagination, order); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, pagination, order); return { - query: queries.getByParams(this.name, selectedFields, searchFields, orderByFields, paginationFields), + query: queries.getByParams(this.#nameQuoted, selectedFields, searchFields, orderByFields, paginationFields), values, }; }, @@ -411,7 +455,7 @@ export class BaseMaterializedView { - const query = `REFRESH MATERIALIZED VIEW ${concurrently ? "CONCURRENTLY" : ""} ${this.name}`; + const query = `REFRESH MATERIALIZED VIEW ${concurrently ? "CONCURRENTLY " : ""}${this.#nameQuoted}`; await this.#executeSql({ query }); } diff --git a/src/lib/mysql/model/materialized-view.unit.spec.ts b/src/lib/mysql/model/materialized-view.unit.spec.ts new file mode 100644 index 0000000..876a8fa --- /dev/null +++ b/src/lib/mysql/model/materialized-view.unit.spec.ts @@ -0,0 +1,94 @@ +import { + describe, + expect, + it, + vi, +} from "vitest"; +import mysql from "mysql2/promise"; + +import * as Types from "./types.js"; +import { BaseMaterializedView } from "./materialized-view.js"; + +const mockClient = {} as mysql.PoolConnection; + +const usersMvSchema = { + coreFields: ["id", "name", "age", "published"], + name: "users_mv", +} satisfies Types.TMaterializedView; + +function createMaterializedView( + schema: Types.TMaterializedView = usersMvSchema, + options?: Types.TMVOptions, +): BaseMaterializedView { + return new BaseMaterializedView(schema, undefined, { client: mockClient, ...options }); +} + +describe("BaseMaterializedView", () => { + describe("constructor", () => { + it("should throw when neither client nor dbCreds are provided", () => { + expect(() => new BaseMaterializedView(usersMvSchema)).toThrow("No client or dbCreds provided"); + }); + + it("should expose schema metadata", () => { + const mv = createMaterializedView(); + + expect(mv.name).toBe("users_mv"); + expect(mv.coreFields).toEqual(["id", "name", "age", "published"]); + }); + }); + + describe("compareQuery.getOneByParams", () => { + it("should build SELECT with LIMIT 1", () => { + const mv = createMaterializedView(); + const result = mv.compareQuery.getOneByParams( + { $and: { published: true } }, + ["id", "name"], + ); + + expect(result.query).toBe( + "SELECT `id`, `name` FROM `users_mv` WHERE (`published` = ?) LIMIT 1 OFFSET 0;", + ); + expect(result.values).toEqual([true]); + }); + }); + + describe("compareQuery.getCountByParams", () => { + it("should build COUNT query", () => { + const mv = createMaterializedView(); + const result = mv.compareQuery.getCountByParams({ + $and: { published: true }, + }); + + expect(result.query).toBe("SELECT COUNT(*) AS count FROM `users_mv` WHERE (`published` = ?);"); + expect(result.values).toEqual([true]); + }); + }); + + describe("refresh", () => { + it("should execute REFRESH MATERIALIZED VIEW", async () => { + const query = vi.fn().mockResolvedValue([[], []]); + const client = { query } as unknown as mysql.PoolConnection; + const mv = createMaterializedView(usersMvSchema, { client }); + + await mv.refresh(); + + expect(query).toHaveBeenCalledWith( + "REFRESH MATERIALIZED VIEW `users_mv`", + undefined, + ); + }); + + it("should execute REFRESH MATERIALIZED VIEW CONCURRENTLY", async () => { + const query = vi.fn().mockResolvedValue([[], []]); + const client = { query } as unknown as mysql.PoolConnection; + const mv = createMaterializedView(usersMvSchema, { client }); + + await mv.refresh(true); + + expect(query).toHaveBeenCalledWith( + "REFRESH MATERIALIZED VIEW CONCURRENTLY `users_mv`", + undefined, + ); + }); + }); +}); diff --git a/src/lib/mysql/model/table.ts b/src/lib/mysql/model/table.ts index 0feb49c..c914e82 100644 --- a/src/lib/mysql/model/table.ts +++ b/src/lib/mysql/model/table.ts @@ -7,7 +7,6 @@ import * as Types from "./types.js"; import * as connection from "../connection.js"; import queries, { generateTimestampQuery } from "./queries.js"; import { QueryBuilder } from "../query-builder/index.js"; -import { setLoggerAndExecutor } from "../helpers/index.js"; /** * Represents a base table with common database operations. @@ -16,6 +15,7 @@ export class BaseTable { #insertOptions; #sortingOrders = new Set(["ASC", "DESC"]); #tableFieldsSet; + #tableFieldsCoreSet; #isLoggerEnabled: boolean | undefined; #logger?: SharedTypes.TLogger; #executeSql; @@ -24,13 +24,18 @@ export class BaseTable { #initialArgs; /** - * The PostgreSQL executor. - * - pg.Pool - * - pg.PoolClient - * - pg.Client + * The MySQL executor. + * - mysql.Pool + * - mysql.PoolConnection + * - mysql.Connection */ #executor: Types.TExecutor; + #createFieldQuoted; + #primaryKeyQuoted; + #tableNameQuoted; + #updateFieldQuoted; + createField; isPKAutoIncremented; primaryKey; @@ -82,13 +87,30 @@ export class BaseTable { } this.createField = data.createField; + this.#createFieldQuoted = this.createField + ? { title: SharedHelpers.quoteMysqlIdent(this.createField.title, { force: true }), type: this.createField.type } + : null; + this.primaryKey = data.primaryKey; + this.#primaryKeyQuoted = this.primaryKey + ? Array.isArray(this.primaryKey) + ? this.primaryKey.map((key) => SharedHelpers.quoteMysqlIdent(key, { force: true })) + : SharedHelpers.quoteMysqlIdent(this.primaryKey, { force: true }) + : null; + this.isPKAutoIncremented = typeof data.isPKAutoIncremented === "boolean" ? data.isPKAutoIncremented : true; this.tableName = data.tableName; + this.#tableNameQuoted = SharedHelpers.quoteMysqlIdent(this.tableName, { force: true }); + this.tableFields = [...data.tableFields]; + this.#tableFieldsCoreSet = new Set(this.tableFields); + this.updateField = data.updateField; + this.#updateFieldQuoted = this.updateField + ? { title: SharedHelpers.quoteMysqlIdent(this.updateField.title, { force: true }), type: this.updateField.type } + : null; this.#tableFieldsSet = new Set([ ...this.tableFields, @@ -97,7 +119,7 @@ export class BaseTable { this.#initialArgs = { data, dbCreds, options }; - const preparedOptions = setLoggerAndExecutor( + const preparedOptions = Helpers.setLoggerAndExecutor( this.#executor, { isLoggerEnabled, logger }, ); @@ -138,12 +160,18 @@ export class BaseTable { * @param logger - The logger to use for the table. */ setLogger(logger: SharedTypes.TLogger) { - const preparedOptions = setLoggerAndExecutor( + const preparedOptions = Helpers.setLoggerAndExecutor( + this.#executor, + { isLoggerEnabled: true, logger }, + ); + + const { executeSqlStream } = Helpers.setStreamExecutor( this.#executor, { isLoggerEnabled: true, logger }, ); this.#executeSql = preparedOptions.executeSql; + this.#executeSqlStream = executeSqlStream; this.#isLoggerEnabled = preparedOptions.isLoggerEnabled; this.#logger = preparedOptions.logger; } @@ -154,12 +182,18 @@ export class BaseTable { * @param executor - The executor to use for the table. */ setExecutor(executor: Types.TExecutor) { - const preparedOptions = setLoggerAndExecutor( + const preparedOptions = Helpers.setLoggerAndExecutor( + executor, + { isLoggerEnabled: this.#isLoggerEnabled, logger: this.#logger }, + ); + + const { executeSqlStream } = Helpers.setStreamExecutor( executor, { isLoggerEnabled: this.#isLoggerEnabled, logger: this.#logger }, ); this.#executeSql = preparedOptions.executeSql; + this.#executeSqlStream = executeSqlStream; this.#isLoggerEnabled = preparedOptions.isLoggerEnabled; this.#logger = preparedOptions.logger; this.#executor = executor; @@ -173,6 +207,10 @@ export class BaseTable { return this.#executeSql; } + get executeSqlStream() { + return this.#executeSqlStream; + } + /** * Sets the client in the current class. * @@ -235,22 +273,22 @@ export class BaseTable { const params = SharedHelpers.clearUndefinedFields(example); - Object.keys(params).forEach((e) => headers.add(e)); + Object.keys(params).forEach((e) => headers.add(SharedHelpers.quoteMysqlIdent(e, { tableFieldsSet: this.#tableFieldsCoreSet }))); - if (this.createField) { - headers.add(this.createField.title); + if (this.#createFieldQuoted) { + headers.add(this.#createFieldQuoted.title); } for (const pR of recordParams) { const params = SharedHelpers.clearUndefinedFields(pR); - const keys = new Set(Object.keys(params)); + const keys = new Set(Object.keys(params).map((key) => SharedHelpers.quoteMysqlIdent(key, { tableFieldsSet: this.#tableFieldsCoreSet }))); const paramsPrepared = [...Object.values(params)]; - if (this.createField) { - if (!keys.has(this.createField.title)) { - keys.add(this.createField.title); + if (this.#createFieldQuoted) { + if (!keys.has(this.#createFieldQuoted.title)) { + keys.add(this.#createFieldQuoted.title); - switch (this.createField.type) { + switch (this.#createFieldQuoted.type) { case "timestamp": paramsPrepared.push(new Date().toISOString()); break; @@ -259,7 +297,7 @@ export class BaseTable { break; default: - throw new Error("Invalid type: " + this.createField.type); + throw new Error("Invalid type: " + this.#createFieldQuoted.type); } } } @@ -285,7 +323,7 @@ export class BaseTable { fields: k, headers: [...headers], onConflict, - tableName: this.tableName, + tableName: this.#tableNameQuoted, }), values: v, }; @@ -300,29 +338,34 @@ export class BaseTable { if (!fields.length) { throw new Error("No one save field arrived"); } return { - query: queries.createOne(this.tableName, fields, this.createField, onConflict), + query: queries.createOne( + this.#tableNameQuoted, + fields.map((field) => SharedHelpers.quoteMysqlIdent(field, { tableFieldsSet: this.#tableFieldsCoreSet })), + this.#createFieldQuoted, + onConflict, + ), values: Object.values(clearedParams), }; }, deleteAll: (): { query: string; } => { - return { query: queries.deleteAll(this.tableName) }; + return { query: queries.deleteAll(this.#tableNameQuoted) }; }, deleteByParams: ( { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, ): { query: string; values: unknown[]; } => { - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#tableFieldsCoreSet }); const { searchFields } = this.getFieldsToSearch({ queryArray, queryOrArray }); return { - query: queries.deleteByParams(this.tableName, searchFields), + query: queries.deleteByParams(this.#tableNameQuoted, searchFields), values, }; }, deleteOneByPk: (primaryKey: T): { query: string; values: unknown[]; } => { - if (!this.primaryKey) { throw new Error("Primary key not specified"); } + if (!this.#primaryKeyQuoted) { throw new Error("Primary key not specified"); } return { - query: queries.deleteByPk(this.tableName, this.primaryKey), + query: queries.deleteByPk(this.#tableNameQuoted, this.#primaryKeyQuoted), values: Array.isArray(primaryKey) ? primaryKey : [primaryKey], }; }, @@ -332,8 +375,12 @@ export class BaseTable { pagination?: SharedTypes.TPagination, order?: { orderBy: string; ordering: SharedTypes.TOrdering; }[], ): { query: string; values: unknown[]; } => { + const orderResult: { orderBy: string; ordering: SharedTypes.TOrdering; }[] = []; + if (order?.length) { for (const o of order) { + orderResult.push({ orderBy: SharedHelpers.quoteMysqlIdent(o.orderBy, { tableFieldsSet: this.#tableFieldsCoreSet }), ordering: o.ordering }); + if (!this.#tableFieldsSet.has(o.orderBy)) { const allowedFields = Array.from(this.#tableFieldsSet).join(", "); @@ -344,43 +391,49 @@ export class BaseTable { } } - if (!selected.length) selected.push("*"); + const selectedResult = []; - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, pagination, order); + if (!selected.length) { + selectedResult.push("*"); + } else { + selected.forEach((e) => selectedResult.push(SharedHelpers.quoteMysqlIdent(e, { tableFieldsSet: this.#tableFieldsCoreSet }))); + } + + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#tableFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, pagination, orderResult); return { - query: queries.getByParams(this.tableName, selectedFields, searchFields, orderByFields, paginationFields), + query: queries.getByParams(this.#tableNameQuoted, selectedFields, searchFields, orderByFields, paginationFields), values, }; }, getCountByParams: ( { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, ): { query: string; values: unknown[]; } => { - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#tableFieldsCoreSet }); const { searchFields } = this.getFieldsToSearch({ queryArray, queryOrArray }); return { - query: queries.getCountByParams(this.tableName, searchFields), + query: queries.getCountByParams(this.#tableNameQuoted, searchFields), values, }; }, getCountByPks: (pks: T[]): { query: string; values: unknown[]; } => { - if (!this.primaryKey) { throw new Error("Primary key not specified"); } + if (!this.#primaryKeyQuoted) { throw new Error("Primary key not specified"); } if (Array.isArray(pks[0])) { - if (!Array.isArray(this.primaryKey)) { throw new Error("invalid primary key type"); } + if (!Array.isArray(this.#primaryKeyQuoted)) { throw new Error("invalid primary key type"); } return { - query: queries.getCountByCompositePks(this.primaryKey as string[], this.tableName, pks.length), + query: queries.getCountByCompositePks(this.#primaryKeyQuoted, this.#tableNameQuoted, pks.length), values: pks.flat(), }; } - if (Array.isArray(this.primaryKey)) { throw new Error("invalid primary key type"); } + if (Array.isArray(this.#primaryKeyQuoted)) { throw new Error("invalid primary key type"); } return { - query: queries.getCountByPks(this.primaryKey as string, this.tableName), + query: queries.getCountByPks(this.#primaryKeyQuoted, this.#tableNameQuoted), values: [pks], }; }, @@ -388,24 +441,24 @@ export class BaseTable { pks: T[], { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, ): { query: string; values: unknown[]; } => { - if (!this.primaryKey) { throw new Error("Primary key not specified"); } + if (!this.#primaryKeyQuoted) { throw new Error("Primary key not specified"); } - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#tableFieldsCoreSet }); const { searchFields } = this.getFieldsToSearch({ queryArray, queryOrArray }); if (Array.isArray(pks[0])) { - if (!Array.isArray(this.primaryKey)) { throw new Error("invalid primary key type"); } + if (!Array.isArray(this.#primaryKeyQuoted)) { throw new Error("invalid primary key type"); } return { - query: queries.getCountByCompositePksAndParams(this.primaryKey, this.tableName, searchFields, pks.length), + query: queries.getCountByCompositePksAndParams(this.#primaryKeyQuoted, this.#tableNameQuoted, searchFields, pks.length), values: [...values, ...pks.flat()], }; } - if (Array.isArray(this.primaryKey)) { throw new Error("invalid primary key type"); } + if (Array.isArray(this.#primaryKeyQuoted)) { throw new Error("invalid primary key type"); } return { - query: queries.getCountByPksAndParams(this.primaryKey, this.tableName, searchFields), + query: queries.getCountByPksAndParams(this.#primaryKeyQuoted, this.#tableNameQuoted, searchFields), values: [...values, pks], }; }, @@ -413,14 +466,20 @@ export class BaseTable { { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, selected = ["*"], ): { query: string; values: unknown[]; } => { - if (!selected.length) selected.push("*"); + const selectedResult = []; + + if (!selected.length) { + selectedResult.push("*"); + } else { + selected.forEach((e) => selectedResult.push(SharedHelpers.quoteMysqlIdent(e, { tableFieldsSet: this.#tableFieldsCoreSet }))); + } - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, { limit: 1, offset: 0 }); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#tableFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, { limit: 1, offset: 0 }); return { query: queries.getByParams( - this.tableName, + this.#tableNameQuoted, selectedFields, searchFields, orderByFields, @@ -430,10 +489,10 @@ export class BaseTable { }; }, getOneByPk: (primaryKey: T): { query: string; values: unknown[]; } => { - if (!this.primaryKey) { throw new Error("Primary key not specified"); } + if (!this.#primaryKeyQuoted) { throw new Error("Primary key not specified"); } return { - query: queries.getOneByPk(this.tableName, this.primaryKey), + query: queries.getOneByPk(this.#tableNameQuoted, this.#primaryKeyQuoted), values: Array.isArray(primaryKey) ? [...primaryKey] : [primaryKey], }; }, @@ -455,13 +514,19 @@ export class BaseTable { } } - if (!selected.length) selected.push("*"); + const selectedResult = []; - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, pagination, order); + if (!selected.length) { + selectedResult.push("*"); + } else { + selected.forEach((e) => selectedResult.push(SharedHelpers.quoteMysqlIdent(e, { tableFieldsSet: this.#tableFieldsCoreSet }))); + } + + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#tableFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, pagination, order); return { - query: queries.getByParams(this.tableName, selectedFields, searchFields, orderByFields, paginationFields), + query: queries.getByParams(this.#tableNameQuoted, selectedFields, searchFields, orderByFields, paginationFields), values, }; }, @@ -469,15 +534,15 @@ export class BaseTable { queryConditions: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, updateFields: SharedTypes.TRawParams = {}, ): { query: string; values: unknown[]; } => { - const { queryArray, queryOrArray, values } = this.compareFields(queryConditions.$and, queryConditions.$or); + const { queryArray, queryOrArray, values } = this.compareFields(queryConditions.$and, queryConditions.$or, { tableFieldsSet: this.#tableFieldsCoreSet }); const { searchFields } = this.getFieldsToSearch({ queryArray, queryOrArray }); const clearedUpdate = SharedHelpers.clearUndefinedFields(updateFields); - const fieldsToUpdate = Object.keys(clearedUpdate); + const fieldsToUpdate = Object.keys(clearedUpdate).map((e) => SharedHelpers.quoteMysqlIdent(e, { tableFieldsSet: this.#tableFieldsCoreSet })); if (!queryArray.length) throw new Error("No one update field arrived"); return { - query: queries.updateByParams(this.tableName, fieldsToUpdate, searchFields, this.updateField), + query: queries.updateByParams(this.#tableNameQuoted, fieldsToUpdate, searchFields, this.#updateFieldQuoted), values: [...Object.values(clearedUpdate), ...values], }; }, @@ -485,15 +550,15 @@ export class BaseTable { primaryKeyValue: T, updateFields: SharedTypes.TRawParams = {}, ): { query: string; values: unknown[]; } => { - if (!this.primaryKey) { throw new Error("Primary key not specified"); } + if (!this.#primaryKeyQuoted) { throw new Error("Primary key not specified"); } const clearedParams = SharedHelpers.clearUndefinedFields(updateFields); - const fields = Object.keys(clearedParams); + const fields = Object.keys(clearedParams).map((e) => SharedHelpers.quoteMysqlIdent(e, { tableFieldsSet: this.#tableFieldsCoreSet })); if (!fields.length) throw new Error("No one update field arrived"); return { - query: queries.updateByPk(this.tableName, fields, this.primaryKey, this.updateField), + query: queries.updateByPk(this.#tableNameQuoted, fields, this.#primaryKeyQuoted, this.#updateFieldQuoted), values: [...Object.values(clearedParams), ...Array.isArray(primaryKeyValue) ? [...primaryKeyValue] : [primaryKeyValue]], }; }, diff --git a/src/lib/mysql/model/table.unit.spec.ts b/src/lib/mysql/model/table.unit.spec.ts new file mode 100644 index 0000000..740860c --- /dev/null +++ b/src/lib/mysql/model/table.unit.spec.ts @@ -0,0 +1,278 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import mysql from "mysql2/promise"; + +import * as Types from "./types.js"; +import { BaseTable } from "./table.js"; + +const mockClient = {} as mysql.PoolConnection; + +const usersSchema = { + createField: null, + primaryKey: "id", + tableFields: ["id", "name", "age", "published"], + tableName: "users", + updateField: { title: "updated_at", type: "timestamp" as const }, +} satisfies Types.TTable; + +const invTypesSchema = { + createField: null, + primaryKey: "typeID", + tableFields: ["typeID", "typeName", "published"], + tableName: "invTypes", + updateField: null, +} satisfies Types.TTable; + +function createTable( + schema: Types.TTable = usersSchema, + options?: Types.TDBOptions, +): BaseTable { + return new BaseTable(schema, undefined, { client: mockClient, ...options }); +} + +describe("BaseTable", () => { + describe("constructor", () => { + it("should throw when neither client nor dbCreds are provided", () => { + expect(() => new BaseTable(usersSchema)).toThrow("No client or dbCreds provided"); + }); + + it("should expose schema metadata", () => { + const table = createTable(); + + expect(table.tableName).toBe("users"); + expect(table.primaryKey).toBe("id"); + expect(table.tableFields).toEqual(["id", "name", "age", "published"]); + expect(table.updateField).toEqual({ title: "updated_at", type: "timestamp" }); + }); + }); + + describe("compareQuery.createOne", () => { + it("should build INSERT query and values", () => { + const table = createTable(); + const result = table.compareQuery.createOne({ age: 30, name: "John" }); + + expect(result.query).toBe("INSERT INTO `users` (`age`,`name`) VALUES (?,?) ;"); + expect(result.values).toEqual([30, "John"]); + }); + + it("should throw when no fields are provided", () => { + const table = createTable(); + + expect(() => table.compareQuery.createOne({})).toThrow("No one save field arrived"); + }); + + it("should apply onConflict from insertOptions", () => { + const table = createTable(usersSchema, { + insertOptions: { onConflict: "ON DUPLICATE KEY UPDATE id=id" }, + }); + const result = table.compareQuery.createOne({ name: "John" }); + + expect(result.query).toBe( + "INSERT INTO `users` (`name`) VALUES (?) ON DUPLICATE KEY UPDATE id=id;", + ); + }); + }); + + describe("compareQuery.createMany", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should build INSERT for multiple rows", () => { + const table = createTable(); + const result = table.compareQuery.createMany([ + { age: 30, name: "John" }, + { age: 25, name: "Jane" }, + ]); + + expect(result.query).toBe( + "INSERT INTO `users` (`age`,`name`) VALUES (?,?),(?,?) ;", + ); + expect(result.values).toEqual([30, "John", 25, "Jane"]); + }); + + it("should throw for empty recordParams", () => { + const table = createTable(); + + expect(() => table.compareQuery.createMany([])).toThrow("Invalid recordParams"); + }); + + it("should append createField when missing in params", () => { + const table = createTable({ + ...usersSchema, + createField: { title: "created_at", type: "timestamp" }, + tableFields: [...usersSchema.tableFields, "created_at"], + }); + const result = table.compareQuery.createMany([{ name: "John" }]); + + expect(result.query).toBe( + "INSERT INTO `users` (`name`,`created_at`) VALUES (?,?) ;", + ); + expect(result.values).toEqual(["John", "2024-01-01T00:00:00.000Z"]); + }); + }); + + describe("compareQuery.deleteAll", () => { + it("should build DELETE query", () => { + const table = createTable(); + + expect(table.compareQuery.deleteAll()).toEqual({ + query: "DELETE FROM `users`;", + }); + }); + }); + + describe("compareQuery.deleteByParams", () => { + it("should build DELETE with search conditions", () => { + const table = createTable(); + const result = table.compareQuery.deleteByParams({ + $and: { age: { $gt: 18 }, published: true }, + }); + + expect(result.query).toBe("DELETE FROM `users` WHERE ((`age` > ?) AND (`published` = ?));"); + expect(result.values).toEqual([18, true]); + }); + }); + + describe("compareQuery.deleteOneByPk", () => { + it("should build DELETE by primary key", () => { + const table = createTable(); + const result = table.compareQuery.deleteOneByPk(42); + + expect(result.query).toBe("DELETE FROM `users` WHERE `id` = ?;"); + expect(result.values).toEqual([42]); + }); + + it("should throw when primary key is not configured", () => { + const table = createTable({ ...usersSchema, primaryKey: null }); + + expect(() => table.compareQuery.deleteOneByPk(1)).toThrow("Primary key not specified"); + }); + }); + + describe("compareQuery.getOneByParams", () => { + it("should build SELECT with LIMIT 1", () => { + const table = createTable(); + const result = table.compareQuery.getOneByParams( + { $and: { published: true } }, + ["id", "name"], + ); + + expect(result.query).toBe( + "SELECT `id`, `name` FROM `users` WHERE (`published` = ?) LIMIT 1 OFFSET 0;", + ); + expect(result.values).toEqual([true]); + }); + + it("should support quoted SQL identifiers from schema", () => { + const table = createTable(invTypesSchema); + const result = table.compareQuery.getOneByParams( + { $and: { typeID: 34 } }, + ["typeID", "typeName"], + ); + + expect(result.query).toBe( + "SELECT `typeID`, `typeName` FROM `invTypes` WHERE (`typeID` = ?) LIMIT 1 OFFSET 0;", + ); + expect(result.values).toEqual([34]); + }); + }); + + describe("compareQuery.getArrByParams", () => { + it("should build SELECT with pagination and order", () => { + const table = createTable(); + const result = table.compareQuery.getArrByParams( + { $and: { published: true } }, + ["id", "name"], + { limit: 10, offset: 5 }, + [{ orderBy: "name", ordering: "ASC" }], + ); + + expect(result.query).toBe( + "SELECT `id`, `name` FROM `users` WHERE (`published` = ?) ORDER BY `name` ASC LIMIT 10 OFFSET 5;", + ); + expect(result.values).toEqual([true]); + }); + + it("should throw for invalid orderBy", () => { + const table = createTable(); + + expect(() => table.compareQuery.getArrByParams( + { $and: {} }, + ["*"], + undefined, + [{ orderBy: "unknown", ordering: "ASC" }], + )).toThrow("Invalid orderBy: unknown"); + }); + }); + + describe("compareQuery.getCountByParams", () => { + it("should build COUNT query", () => { + const table = createTable(); + const result = table.compareQuery.getCountByParams({ + $and: { published: true }, + }); + + expect(result.query).toBe("SELECT COUNT(*) AS count FROM `users` WHERE (`published` = ?);"); + expect(result.values).toEqual([true]); + }); + }); + + describe("compareQuery.getOneByPk", () => { + it("should build SELECT by primary key", () => { + const table = createTable(); + const result = table.compareQuery.getOneByPk(7); + + expect(result.query).toBe("SELECT * FROM `users` WHERE `id` = ? LIMIT 1;"); + expect(result.values).toEqual([7]); + }); + }); + + describe("compareQuery.getCountByPks", () => { + it("should build COUNT for scalar primary keys", () => { + const table = createTable(); + const result = table.compareQuery.getCountByPks([1, 2, 3]); + + expect(result.query).toBe("SELECT COUNT(*) AS count FROM `users` WHERE `id` IN (?);"); + expect(result.values).toEqual([[1, 2, 3]]); + }); + }); + + describe("compareQuery.updateByParams", () => { + it("should build UPDATE with WHERE and SET clauses", () => { + const table = createTable(); + const result = table.compareQuery.updateByParams( + { $and: { id: 1 } }, + { age: 31, name: "John" }, + ); + + expect(result.query).toBe( + "UPDATE `users` SET `age` = ?,`name` = ?, `updated_at` = UTC_TIMESTAMP() WHERE (`id` = ?);", + ); + expect(result.values).toEqual([31, "John", 1]); + }); + }); + + describe("compareQuery.updateOneByPk", () => { + it("should build UPDATE by primary key", () => { + const table = createTable(); + const result = table.compareQuery.updateOneByPk(42, { name: "Jane" }); + + expect(result.query).toBe( + "UPDATE `users` SET `name` = ?, `updated_at` = UTC_TIMESTAMP() WHERE `id` = ?;", + ); + expect(result.values).toEqual(["Jane", 42]); + }); + }); +}); diff --git a/src/lib/mysql/model/view.ts b/src/lib/mysql/model/view.ts index 84e2374..1df3c3e 100644 --- a/src/lib/mysql/model/view.ts +++ b/src/lib/mysql/model/view.ts @@ -1,12 +1,12 @@ import mysql from "mysql2/promise"; import * as Helpers from "../helpers/index.js"; +import * as SharedHelpers from "../../../shared-helpers/index.js"; import * as SharedTypes from "../../../shared-types/index.js"; import * as Types from "./types.js"; import * as connection from "../connection.js"; import { QueryBuilder } from "../query-builder/index.js"; import queries from "./queries.js"; -import { setLoggerAndExecutor } from "../helpers/index.js"; /** * @experimental @@ -16,6 +16,7 @@ import { setLoggerAndExecutor } from "../helpers/index.js"; export class BaseView { #sortingOrders = new Set(["ASC", "DESC"]); #coreFieldsSet; + #coreFieldsCoreSet; #isLoggerEnabled: boolean | undefined; #logger?: SharedTypes.TLogger; #executeSql; @@ -31,6 +32,8 @@ export class BaseView { */ #executor: Types.TExecutor; + #nameQuoted; + /** * The name of the view. */ @@ -42,7 +45,7 @@ export class BaseView { coreFields: readonly string[]; /** - * Creates an instance of `BaseMaterializedView`. + * Creates an instance of `BaseView`. * * @param data - Data for initializing the view. * @param data.coreFields - The core fields of the view. @@ -67,7 +70,10 @@ export class BaseView { } this.name = data.name; - this.coreFields = data.coreFields; + this.#nameQuoted = SharedHelpers.quoteMysqlIdent(this.name, { force: true }); + + this.coreFields = [...data.coreFields]; + this.#coreFieldsCoreSet = new Set(this.coreFields); this.#coreFieldsSet = new Set([ ...this.coreFields, @@ -78,7 +84,7 @@ export class BaseView { const { isLoggerEnabled, logger } = options || {}; - const preparedOptions = setLoggerAndExecutor( + const preparedOptions = Helpers.setLoggerAndExecutor( this.#executor, { isLoggerEnabled, logger }, ); @@ -118,12 +124,18 @@ export class BaseView { * @param logger - The logger to use for the view. */ setLogger(logger: SharedTypes.TLogger) { - const preparedOptions = setLoggerAndExecutor( + const preparedOptions = Helpers.setLoggerAndExecutor( + this.#executor, + { isLoggerEnabled: true, logger }, + ); + + const { executeSqlStream } = Helpers.setStreamExecutor( this.#executor, { isLoggerEnabled: true, logger }, ); this.#executeSql = preparedOptions.executeSql; + this.#executeSqlStream = executeSqlStream; this.#isLoggerEnabled = preparedOptions.isLoggerEnabled; this.#logger = preparedOptions.logger; } @@ -134,12 +146,18 @@ export class BaseView { * @param executor - The executor to use for the view. */ setExecutor(executor: Types.TExecutor) { - const preparedOptions = setLoggerAndExecutor( + const preparedOptions = Helpers.setLoggerAndExecutor( + executor, + { isLoggerEnabled: this.#isLoggerEnabled, logger: this.#logger }, + ); + + const { executeSqlStream } = Helpers.setStreamExecutor( executor, { isLoggerEnabled: this.#isLoggerEnabled, logger: this.#logger }, ); this.#executeSql = preparedOptions.executeSql; + this.#executeSqlStream = executeSqlStream; this.#isLoggerEnabled = preparedOptions.isLoggerEnabled; this.#logger = preparedOptions.logger; this.#executor = executor; @@ -153,6 +171,10 @@ export class BaseView { return this.#executeSql; } + get executeSqlStream() { + return this.#executeSqlStream; + } + /** * Sets the client in the current class. * @@ -183,7 +205,7 @@ export class BaseView { * * @returns A new instance of the base class with the new connection client. */ - setClientInBaseClass(client: Types.TExecutor): BaseView { + setClientInBaseClass(client: Types.TExecutor): BaseView { return new BaseView( { ...this.#initialArgs.data }, this.#initialArgs.dbCreds ? { ...this.#initialArgs.dbCreds } : undefined, @@ -221,12 +243,16 @@ export class BaseView { */ getArrByParams: ( { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, - selected: string[] = ["*"], + selected = ["*"], pagination?: SharedTypes.TPagination, order?: { orderBy: string; ordering: SharedTypes.TOrdering; }[], ): { query: string; values: unknown[]; } => { + const orderResult: { orderBy: string; ordering: SharedTypes.TOrdering; }[] = []; + if (order?.length) { for (const o of order) { + orderResult.push({ orderBy: SharedHelpers.quoteMysqlIdent(o.orderBy, { tableFieldsSet: this.#coreFieldsCoreSet }), ordering: o.ordering }); + if (!this.#coreFieldsSet.has(o.orderBy)) { const allowedFields = Array.from(this.#coreFieldsSet).join(", "); @@ -237,13 +263,19 @@ export class BaseView { } } - if (!selected.length) selected.push("*"); + const selectedResult = []; - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, pagination, order); + if (!selected.length) { + selectedResult.push("*"); + } else { + selected.forEach((e) => selectedResult.push(SharedHelpers.quoteMysqlIdent(e, { tableFieldsSet: this.#coreFieldsCoreSet }))); + } + + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, pagination, orderResult); return { - query: queries.getByParams(this.name, selectedFields, searchFields, orderByFields, paginationFields), + query: queries.getByParams(this.#nameQuoted, selectedFields, searchFields, orderByFields, paginationFields), values, }; }, @@ -259,11 +291,11 @@ export class BaseView { getCountByParams: ( { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, ): { query: string; values: unknown[]; } => { - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); const { searchFields } = this.getFieldsToSearch({ queryArray, queryOrArray }); return { - query: queries.getCountByParams(this.name, searchFields), + query: queries.getCountByParams(this.#nameQuoted, searchFields), values, }; }, @@ -279,16 +311,22 @@ export class BaseView { */ getOneByParams: ( { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, - selected: string[] = ["*"], + selected = ["*"], ): { query: string; values: unknown[]; } => { - if (!selected.length) selected.push("*"); + const selectedResult = []; + + if (!selected.length) { + selectedResult.push("*"); + } else { + selected.forEach((e) => selectedResult.push(SharedHelpers.quoteMysqlIdent(e, { tableFieldsSet: this.#coreFieldsCoreSet }))); + } - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, { limit: 1, offset: 0 }); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, { limit: 1, offset: 0 }); return { query: queries.getByParams( - this.name, + this.#nameQuoted, selectedFields, searchFields, orderByFields, @@ -313,7 +351,7 @@ export class BaseView { */ streamArrByParams: ( { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, - selected: string[] = ["*"], + selected = ["*"], pagination?: SharedTypes.TPagination, order?: { orderBy: string; ordering: SharedTypes.TOrdering; }[], ): { query: string; values: unknown[]; } => { @@ -329,13 +367,19 @@ export class BaseView { } } - if (!selected.length) selected.push("*"); + const selectedResult = []; + + if (!selected.length) { + selectedResult.push("*"); + } else { + selected.forEach((e) => selectedResult.push(SharedHelpers.quoteMysqlIdent(e, { tableFieldsSet: this.#coreFieldsCoreSet }))); + } - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, pagination, order); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, pagination, order); return { - query: queries.getByParams(this.name, selectedFields, searchFields, orderByFields, paginationFields), + query: queries.getByParams(this.#nameQuoted, selectedFields, searchFields, orderByFields, paginationFields), values, }; }, diff --git a/src/lib/mysql/model/view.unit.spec.ts b/src/lib/mysql/model/view.unit.spec.ts new file mode 100644 index 0000000..cda3b72 --- /dev/null +++ b/src/lib/mysql/model/view.unit.spec.ts @@ -0,0 +1,126 @@ +import { + describe, + expect, + it, +} from "vitest"; +import mysql from "mysql2/promise"; + +import * as Types from "./types.js"; +import { BaseView } from "./view.js"; + +const mockClient = {} as mysql.PoolConnection; + +const usersViewSchema = { + coreFields: ["id", "name", "age", "published"], + name: "users_view", +} satisfies Types.TView; + +const invTypesViewSchema = { + coreFields: ["typeID", "typeName", "published"], + name: "invTypesView", +} satisfies Types.TView; + +function createView( + schema: Types.TView = usersViewSchema, + options?: Types.TVOptions, +): BaseView { + return new BaseView(schema, undefined, { client: mockClient, ...options }); +} + +describe("BaseView", () => { + describe("constructor", () => { + it("should throw when neither client nor dbCreds are provided", () => { + expect(() => new BaseView(usersViewSchema)).toThrow("No client or dbCreds provided"); + }); + + it("should expose schema metadata", () => { + const view = createView(); + + expect(view.name).toBe("users_view"); + expect(view.coreFields).toEqual(["id", "name", "age", "published"]); + }); + }); + + describe("compareQuery.getOneByParams", () => { + it("should build SELECT with LIMIT 1", () => { + const view = createView(); + const result = view.compareQuery.getOneByParams( + { $and: { published: true } }, + ["id", "name"], + ); + + expect(result.query).toBe( + "SELECT `id`, `name` FROM `users_view` WHERE (`published` = ?) LIMIT 1 OFFSET 0;", + ); + expect(result.values).toEqual([true]); + }); + + it("should support quoted SQL identifiers from schema", () => { + const view = createView(invTypesViewSchema); + const result = view.compareQuery.getOneByParams( + { $and: { typeID: 34 } }, + ["typeID", "typeName"], + ); + + expect(result.query).toBe( + "SELECT `typeID`, `typeName` FROM `invTypesView` WHERE (`typeID` = ?) LIMIT 1 OFFSET 0;", + ); + expect(result.values).toEqual([34]); + }); + }); + + describe("compareQuery.getArrByParams", () => { + it("should build SELECT with pagination and order", () => { + const view = createView(); + const result = view.compareQuery.getArrByParams( + { $and: { published: true } }, + ["id", "name"], + { limit: 10, offset: 5 }, + [{ orderBy: "name", ordering: "ASC" }], + ); + + expect(result.query).toBe( + "SELECT `id`, `name` FROM `users_view` WHERE (`published` = ?) ORDER BY `name` ASC LIMIT 10 OFFSET 5;", + ); + expect(result.values).toEqual([true]); + }); + + it("should throw for invalid orderBy", () => { + const view = createView(); + + expect(() => view.compareQuery.getArrByParams( + { $and: {} }, + ["*"], + undefined, + [{ orderBy: "unknown", ordering: "ASC" }], + )).toThrow("Invalid orderBy: unknown"); + }); + }); + + describe("compareQuery.getCountByParams", () => { + it("should build COUNT query", () => { + const view = createView(); + const result = view.compareQuery.getCountByParams({ + $and: { published: true }, + }); + + expect(result.query).toBe("SELECT COUNT(*) AS count FROM `users_view` WHERE (`published` = ?);"); + expect(result.values).toEqual([true]); + }); + }); + + describe("compareQuery.streamArrByParams", () => { + it("should build the same query shape as getArrByParams", () => { + const view = createView(); + const result = view.compareQuery.streamArrByParams( + { $and: { published: true } }, + ["id"], + { limit: 5, offset: 0 }, + ); + + expect(result.query).toBe( + "SELECT `id` FROM `users_view` WHERE (`published` = ?) LIMIT 5 OFFSET 0;", + ); + }); + }); +}); diff --git a/src/lib/pg/helpers/compare-fields.ts b/src/lib/pg/helpers/compare-fields.ts index 7e02cea..2f975e9 100644 --- a/src/lib/pg/helpers/compare-fields.ts +++ b/src/lib/pg/helpers/compare-fields.ts @@ -1,3 +1,5 @@ +import * as SharedHelpers from "../../../shared-helpers/index.js"; + import * as Types from "../model/types.js"; import { processMappings } from "./process-mappings.js"; @@ -15,6 +17,9 @@ import { processMappings } from "./process-mappings.js"; export const compareFields = ( params: Types.TSearchParams = {}, paramsOr?: Types.TSearchParams[], + options?: { + tableFieldsSet?: Set; + }, ): { queryArray: Types.TField[]; queryOrArray: { query: Types.TField[]; }[]; @@ -23,12 +28,14 @@ export const compareFields = ( const queryArray: Types.TField[] = []; const values: unknown[] = []; + const { tableFieldsSet } = options || {}; + for (const entry of Object.entries(params)) { const key = entry[0]; const value = entry[1]; if (value === null) { - queryArray.push({ key: `${key} IS NULL`, operator: "$withoutParameters" }); + queryArray.push({ key: `${SharedHelpers.quotePgIdent(key, { tableFieldsSet })} IS NULL`, operator: "$withoutParameters" }); } else if (typeof value === "object") { if (Array.isArray(value)) { for (const v of value) { @@ -39,7 +46,7 @@ export const compareFields = ( throw new Error(`Invalid value.key ${k}, Available values: ${Array.from(processMappings.keys())}`); } - processFunction(key, v, queryArray, values); + processFunction(SharedHelpers.quotePgIdent(key, { tableFieldsSet }), v, queryArray, values); } } } else { @@ -50,11 +57,11 @@ export const compareFields = ( throw new Error(`Invalid value.key ${k}, Available values: ${Array.from(processMappings.keys())}`); } - processFunction(key, value, queryArray, values); + processFunction(SharedHelpers.quotePgIdent(key, { tableFieldsSet }), value, queryArray, values); } } } else if (value !== undefined) { - queryArray.push({ key, operator: "=" }); + queryArray.push({ key: SharedHelpers.quotePgIdent(key, { tableFieldsSet }), operator: "=" }); values.push(value); } } @@ -74,7 +81,7 @@ export const compareFields = ( const value = entry[1]; if (value === null) { - queryOrArrayLocal.push({ key: `${key} IS NULL`, operator: "$withoutParameters" }); + queryOrArrayLocal.push({ key: `${SharedHelpers.quotePgIdent(key, { tableFieldsSet })} IS NULL`, operator: "$withoutParameters" }); } else if (typeof value === "object") { if (Array.isArray(value)) { for (const v of value) { @@ -85,7 +92,7 @@ export const compareFields = ( throw new Error(`Invalid value.key ${k}, Available values: ${Array.from(processMappings.keys())}`); } - processFunction(key, v, queryOrArrayLocal, values); + processFunction(SharedHelpers.quotePgIdent(key, { tableFieldsSet }), v, queryOrArrayLocal, values); } } } else { @@ -96,11 +103,11 @@ export const compareFields = ( throw new Error(`Invalid value.key ${k}, Available values: ${Array.from(processMappings.keys())}`); } - processFunction(key, value, queryOrArrayLocal, values); + processFunction(SharedHelpers.quotePgIdent(key, { tableFieldsSet }), value, queryOrArrayLocal, values); } } } else if (value !== undefined) { - queryOrArrayLocal.push({ key, operator: "=" }); + queryOrArrayLocal.push({ key: SharedHelpers.quotePgIdent(key, { tableFieldsSet }), operator: "=" }); values.push(value); } } diff --git a/src/lib/pg/helpers/get-fields-to-search.ts b/src/lib/pg/helpers/get-fields-to-search.ts index bc87403..acbe068 100644 --- a/src/lib/pg/helpers/get-fields-to-search.ts +++ b/src/lib/pg/helpers/get-fields-to-search.ts @@ -1,4 +1,5 @@ import * as SharedTypes from "../../../shared-types/index.js"; + import * as Types from "../model/types.js"; import { operatorMappings } from "./operator-mappings.js"; diff --git a/src/lib/pg/model/materialized-view.ts b/src/lib/pg/model/materialized-view.ts index a29ba21..9925cb6 100644 --- a/src/lib/pg/model/materialized-view.ts +++ b/src/lib/pg/model/materialized-view.ts @@ -1,6 +1,7 @@ import pg from "pg"; import * as Helpers from "../helpers/index.js"; +import * as SharedHelpers from "../../../shared-helpers/index.js"; import * as SharedTypes from "../../../shared-types/index.js"; import * as Types from "./types.js"; import * as connection from "../connection.js"; @@ -15,6 +16,7 @@ import queries from "./queries.js"; export class BaseMaterializedView { #sortingOrders = new Set(["ASC", "DESC"]); #coreFieldsSet; + #coreFieldsCoreSet; #isLoggerEnabled: boolean | undefined; #logger?: SharedTypes.TLogger; #executeSql; @@ -30,6 +32,8 @@ export class BaseMaterializedView { return new BaseMaterializedView( { ...this.#initialArgs.data }, this.#initialArgs.dbCreds ? { ...this.#initialArgs.dbCreds } : undefined, @@ -236,12 +243,16 @@ export class BaseMaterializedView { + const orderResult: { orderBy: string; ordering: SharedTypes.TOrdering; }[] = []; + if (order?.length) { for (const o of order) { + orderResult.push({ orderBy: SharedHelpers.quotePgIdent(o.orderBy, { tableFieldsSet: this.#coreFieldsCoreSet }), ordering: o.ordering }); + if (!this.#coreFieldsSet.has(o.orderBy)) { const allowedFields = Array.from(this.#coreFieldsSet).join(", "); @@ -252,13 +263,19 @@ export class BaseMaterializedView selectedResult.push(SharedHelpers.quotePgIdent(e, { tableFieldsSet: this.#coreFieldsCoreSet }))); + } - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, pagination, order); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, pagination, orderResult); return { - query: queries.getByParams(this.name, selectedFields, searchFields, orderByFields, paginationFields), + query: queries.getByParams(this.#nameQuoted, selectedFields, searchFields, orderByFields, paginationFields), values, }; }, @@ -274,11 +291,11 @@ export class BaseMaterializedView { - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); const { searchFields } = this.getFieldsToSearch({ queryArray, queryOrArray }); return { - query: queries.getCountByParams(this.name, searchFields), + query: queries.getCountByParams(this.#nameQuoted, searchFields), values, }; }, @@ -294,16 +311,22 @@ export class BaseMaterializedView { - if (!selected.length) selected.push("*"); + const selectedResult = []; + + if (!selected.length) { + selectedResult.push("*"); + } else { + selected.forEach((e) => selectedResult.push(SharedHelpers.quotePgIdent(e, { tableFieldsSet: this.#coreFieldsCoreSet }))); + } - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, { limit: 1, offset: 0 }); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, { limit: 1, offset: 0 }); return { query: queries.getByParams( - this.name, + this.#nameQuoted, selectedFields, searchFields, orderByFields, @@ -328,7 +351,7 @@ export class BaseMaterializedView { @@ -344,13 +367,19 @@ export class BaseMaterializedView selectedResult.push(SharedHelpers.quotePgIdent(e, { tableFieldsSet: this.#coreFieldsCoreSet }))); + } - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, pagination, order); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, pagination, order); return { - query: queries.getByParams(this.name, selectedFields, searchFields, orderByFields, paginationFields), + query: queries.getByParams(this.#nameQuoted, selectedFields, searchFields, orderByFields, paginationFields), values, }; }, @@ -393,7 +422,7 @@ export class BaseMaterializedView { const sql = this.compareQuery.getCountByParams(params); - const { rows } = await this.executeSql<{ count: string; }>(sql); + const { rows } = await this.#executeSql(sql); return Number(rows[0]?.count) || 0; } @@ -426,7 +455,7 @@ export class BaseMaterializedView { - const query = `REFRESH MATERIALIZED VIEW ${concurrently ? "CONCURRENTLY" : ""} ${this.name}`; + const query = `REFRESH MATERIALIZED VIEW ${concurrently ? "CONCURRENTLY " : ""}${this.#nameQuoted}`; await this.#executeSql({ query }); } diff --git a/src/lib/pg/model/materialized-view.unit.spec.ts b/src/lib/pg/model/materialized-view.unit.spec.ts new file mode 100644 index 0000000..ef5f106 --- /dev/null +++ b/src/lib/pg/model/materialized-view.unit.spec.ts @@ -0,0 +1,191 @@ +import { + describe, + expect, + it, + vi, +} from "vitest"; +import pg from "pg"; + +import * as Types from "./types.js"; +import { BaseMaterializedView } from "./materialized-view.js"; + +const mockClient = {} as pg.Client; + +const usersMvSchema = { + coreFields: ["id", "name", "age", "published"], + name: "users_mv", +} satisfies Types.TMaterializedView; + +const invTypesMvSchema = { + coreFields: ["typeID", "typeName", "published"], + name: "invTypesMv", +} satisfies Types.TMaterializedView; + +function createMaterializedView( + schema: Types.TMaterializedView = usersMvSchema, + options?: Types.TMVOptions, +): BaseMaterializedView { + return new BaseMaterializedView(schema, undefined, { client: mockClient, ...options }); +} + +describe("BaseMaterializedView", () => { + describe("constructor", () => { + it("should throw when neither client nor dbCreds are provided", () => { + expect(() => new BaseMaterializedView(usersMvSchema)).toThrow("No client or dbCreds provided"); + }); + + it("should expose schema metadata", () => { + const mv = createMaterializedView(); + + expect(mv.name).toBe("users_mv"); + expect(mv.coreFields).toEqual(["id", "name", "age", "published"]); + }); + + it("should copy coreFields array", () => { + const coreFields = ["id", "name"]; + const mv = createMaterializedView({ ...usersMvSchema, coreFields }); + + expect(mv.coreFields).toEqual(coreFields); + expect(mv.coreFields).not.toBe(coreFields); + }); + }); + + describe("compareQuery.getOneByParams", () => { + it("should build SELECT with LIMIT 1", () => { + const mv = createMaterializedView(); + const result = mv.compareQuery.getOneByParams( + { $and: { published: true } }, + ["id", "name"], + ); + + expect(result.query).toBe( + "SELECT \"id\", \"name\" FROM \"users_mv\" WHERE (\"published\" = $1) LIMIT 1 OFFSET 0;", + ); + expect(result.values).toEqual([true]); + }); + + it("should support quoted SQL identifiers from schema", () => { + const mv = createMaterializedView(invTypesMvSchema); + const result = mv.compareQuery.getOneByParams( + { $and: { typeID: 34 } }, + ["typeID", "typeName"], + ); + + expect(result.query).toBe( + "SELECT \"typeID\", \"typeName\" FROM \"invTypesMv\" WHERE (\"typeID\" = $1) LIMIT 1 OFFSET 0;", + ); + expect(result.values).toEqual([34]); + }); + }); + + describe("compareQuery.getArrByParams", () => { + it("should build SELECT with pagination and order", () => { + const mv = createMaterializedView(); + const result = mv.compareQuery.getArrByParams( + { $and: { published: true } }, + ["id", "name"], + { limit: 10, offset: 5 }, + [{ orderBy: "name", ordering: "ASC" }], + ); + + expect(result.query).toBe( + "SELECT \"id\", \"name\" FROM \"users_mv\" WHERE (\"published\" = $1) ORDER BY \"name\" ASC LIMIT 10 OFFSET 5;", + ); + expect(result.values).toEqual([true]); + }); + + it("should allow additionalSortingFields for orderBy", () => { + const mv = createMaterializedView({ + ...usersMvSchema, + additionalSortingFields: ["computed_rank"], + }); + const result = mv.compareQuery.getArrByParams( + { $and: {} }, + ["id"], + undefined, + [{ orderBy: "computed_rank", ordering: "DESC" }], + ); + + expect(result.query).toBe( + "SELECT \"id\" FROM \"users_mv\" WHERE (1=1) ORDER BY computed_rank DESC;", + ); + }); + + it("should throw for invalid orderBy", () => { + const mv = createMaterializedView(); + + expect(() => mv.compareQuery.getArrByParams( + { $and: {} }, + ["*"], + undefined, + [{ orderBy: "unknown", ordering: "ASC" }], + )).toThrow("Invalid orderBy: unknown"); + }); + + it("should throw for invalid ordering", () => { + const mv = createMaterializedView(); + + expect(() => mv.compareQuery.getArrByParams( + { $and: {} }, + ["*"], + undefined, + [{ orderBy: "name", ordering: "INVALID" as "ASC" }], + )).toThrow("Invalid ordering"); + }); + }); + + describe("compareQuery.getCountByParams", () => { + it("should build COUNT query", () => { + const mv = createMaterializedView(); + const result = mv.compareQuery.getCountByParams({ + $and: { published: true }, + }); + + expect(result.query).toBe("SELECT COUNT(*) AS count FROM \"users_mv\" WHERE (\"published\" = $1);"); + expect(result.values).toEqual([true]); + }); + }); + + describe("compareQuery.streamArrByParams", () => { + it("should build the same query shape as getArrByParams", () => { + const mv = createMaterializedView(); + const result = mv.compareQuery.streamArrByParams( + { $and: { published: true } }, + ["id"], + { limit: 5, offset: 0 }, + ); + + expect(result.query).toBe( + "SELECT \"id\" FROM \"users_mv\" WHERE (\"published\" = $1) LIMIT 5 OFFSET 0;", + ); + }); + }); + + describe("refresh", () => { + it("should execute REFRESH MATERIALIZED VIEW", async () => { + const query = vi.fn().mockResolvedValue({ rows: [] }); + const client = { query } as unknown as pg.Client; + const mv = createMaterializedView(usersMvSchema, { client }); + + await mv.refresh(); + + expect(query).toHaveBeenCalledWith( + "REFRESH MATERIALIZED VIEW \"users_mv\"", + undefined, + ); + }); + + it("should execute REFRESH MATERIALIZED VIEW CONCURRENTLY", async () => { + const query = vi.fn().mockResolvedValue({ rows: [] }); + const client = { query } as unknown as pg.Client; + const mv = createMaterializedView(usersMvSchema, { client }); + + await mv.refresh(true); + + expect(query).toHaveBeenCalledWith( + "REFRESH MATERIALIZED VIEW CONCURRENTLY \"users_mv\"", + undefined, + ); + }); + }); +}); diff --git a/src/lib/pg/model/queries.ts b/src/lib/pg/model/queries.ts index 0f7e41a..cbe2b9e 100644 --- a/src/lib/pg/model/queries.ts +++ b/src/lib/pg/model/queries.ts @@ -54,8 +54,8 @@ export default { createOne( tableName: string, fields: string[], - createField: { title: string; type: "unix_timestamp" | "timestamp"; } | null, - onConflict: string, + createField?: { title: string; type: "unix_timestamp" | "timestamp"; } | null, + onConflict?: string, returning?: string[], ): string { const intoFields = []; @@ -85,7 +85,7 @@ export default { } } - return `INSERT INTO ${tableName} (${intoFields.join(",")}) VALUES (${valuesFields.join(",")}) ${onConflict} RETURNING ${returning?.length ? returning.join(",") : "*"};`; + return `INSERT INTO ${tableName} (${intoFields.join(",")}) VALUES (${valuesFields.join(",")}) ${onConflict || ""} RETURNING ${returning?.length ? returning.join(",") : "*"};`; }, /** @@ -183,7 +183,7 @@ export default { const whereClause = conditions.join(" OR "); - return `SELECT COUNT(*) AS count FROM ${tableName} WHERE ${whereClause};`; + return `SELECT COUNT(*) AS count FROM ${tableName} WHERE (${whereClause});`; }, /** @@ -306,11 +306,11 @@ export default { tableName: string, fields: string[], searchFields: string, - updateField: { title: string; type: "unix_timestamp" | "timestamp"; } | null, - startOrderNumber: number, + updateField?: { title: string; type: "unix_timestamp" | "timestamp"; } | null, + startOrderNumber?: number, returning?: string[], ): string { - let idx = startOrderNumber; + let idx = startOrderNumber || 1; let updateFields = fields.map((e: string) => `${e} = $${idx++}`).join(","); if (updateField) { @@ -349,7 +349,7 @@ export default { tableName: string, fields: string[], primaryKeyField: SharedTypes.TPrimaryKeyField, - updateField: { title: string; type: "unix_timestamp" | "timestamp"; } | null, + updateField?: { title: string; type: "unix_timestamp" | "timestamp"; } | null, returning?: string[], ): string { let idx = 1; diff --git a/src/lib/pg/model/queries.unit.spec.ts b/src/lib/pg/model/queries.unit.spec.ts new file mode 100644 index 0000000..ecd98b3 --- /dev/null +++ b/src/lib/pg/model/queries.unit.spec.ts @@ -0,0 +1,298 @@ +import { + describe, + expect, + it, +} from "vitest"; + +import queries, { generateTimestampQuery } from "./queries.js"; + +describe("generateTimestampQuery", () => { + it("should return NOW() for timestamp", () => { + expect(generateTimestampQuery("timestamp")).toBe("NOW()"); + }); + + it("should return unix timestamp expression for unix_timestamp", () => { + expect(generateTimestampQuery("unix_timestamp")).toBe( + "ROUND((EXTRACT(EPOCH FROM NOW()) * (1000)::NUMERIC))", + ); + }); + + it("should throw for invalid type", () => { + expect(() => generateTimestampQuery("invalid" as "timestamp")).toThrow("Invalid type: invalid"); + }); +}); + +describe("queries.createMany", () => { + it("should build INSERT for a single row", () => { + const query = queries.createMany({ + fields: [["a", "b"]], + headers: ["name", "age"], + onConflict: "", + tableName: "users", + }); + + expect(query).toBe("INSERT INTO users (name,age) VALUES ($1,$2) RETURNING *;"); + }); + + it("should build INSERT for multiple rows with sequential placeholders", () => { + const query = queries.createMany({ + fields: [["a", "b"], ["c", "d"]], + headers: ["name", "age"], + onConflict: "ON CONFLICT DO NOTHING", + returning: ["id", "name"], + tableName: "users", + }); + + expect(query).toBe( + "INSERT INTO users (name,age) VALUES ($1,$2),($3,$4) ON CONFLICT DO NOTHING RETURNING id,name;", + ); + }); + + it("should preserve quoted SQL identifiers in headers", () => { + const query = queries.createMany({ + fields: [["x"]], + headers: ["\"typeID\""], + onConflict: "", + tableName: "invTypes", + }); + + expect(query).toBe("INSERT INTO invTypes (\"typeID\") VALUES ($1) RETURNING *;"); + }); +}); + +describe("queries.createOne", () => { + it("should build INSERT for provided fields", () => { + const query = queries.createOne("\"users\"", ["\"name\"", "\"age\""], undefined, undefined); + + expect(query).toBe("INSERT INTO \"users\" (\"name\",\"age\") VALUES ($1,$2) RETURNING *;"); + }); + + it("should append timestamp createField", () => { + const query = queries.createOne( + "\"users\"", + ["\"name\""], + { title: "\"created_at\"", type: "timestamp" }, + "ON CONFLICT DO NOTHING", + ["\"id\""], + ); + + expect(query).toBe( + "INSERT INTO \"users\" (\"name\",\"created_at\") VALUES ($1,NOW()) ON CONFLICT DO NOTHING RETURNING \"id\";", + ); + }); + + it("should append unix_timestamp createField", () => { + const query = queries.createOne( + "\"users\"", + ["\"name\""], + { title: "\"created_at\"", type: "unix_timestamp" }, + "", + ); + + expect(query).toBe( + "INSERT INTO \"users\" (\"name\",\"created_at\") VALUES ($1,ROUND((EXTRACT(EPOCH FROM NOW()) * (1000)::NUMERIC))) RETURNING *;", + ); + }); + + it("should throw for invalid createField type", () => { + expect(() => queries.createOne( + "\"users\"", + ["\"name\""], + { title: "\"created_at\"", type: "invalid" as "timestamp" }, + "", + )).toThrow("Invalid type: invalid"); + }); +}); + +describe("queries.deleteAll", () => { + it("should build DELETE without WHERE", () => { + expect(queries.deleteAll("\"users\"")).toBe("DELETE FROM \"users\";"); + }); +}); + +describe("queries.deleteByParams", () => { + it("should append searchFields to DELETE", () => { + const query = queries.deleteByParams("\"users\"", " WHERE (\"name\" = $1)"); + + expect(query).toBe("DELETE FROM \"users\" WHERE (\"name\" = $1);"); + }); +}); + +describe("queries.deleteByPk", () => { + it("should build DELETE by single primary key", () => { + expect(queries.deleteByPk("\"users\"", "\"id\"")).toBe( + "DELETE FROM \"users\" WHERE \"id\" = $1 RETURNING \"id\";", + ); + }); + + it("should build DELETE by composite primary key", () => { + expect(queries.deleteByPk("\"users\"", ["\"tenant_id\"", "\"user_id\""])).toBe( + "DELETE FROM \"users\" WHERE \"tenant_id\" = $1 AND \"user_id\" = $2 RETURNING \"tenant_id\", \"user_id\";", + ); + }); +}); + +describe("queries.getByParams", () => { + it("should build SELECT with all clauses", () => { + const query = queries.getByParams( + "\"users\"", + "\"id\", \"name\"", + " WHERE (\"active\" = $1)", + " ORDER BY \"name\" ASC", + " LIMIT 10 OFFSET 0", + ); + + expect(query).toBe( + "SELECT \"id\", \"name\" FROM \"users\" WHERE (\"active\" = $1) ORDER BY \"name\" ASC LIMIT 10 OFFSET 0;", + ); + }); +}); + +describe("queries.getCountByCompositePks", () => { + it("should build COUNT with OR-ed composite PK conditions", () => { + const query = queries.getCountByCompositePks(["\"tenant_id\"", "\"user_id\""], "\"users\"", 2); + + expect(query).toBe( + "SELECT COUNT(*) AS count FROM \"users\" WHERE ((\"tenant_id\" = $1 AND \"user_id\" = $2) OR (\"tenant_id\" = $3 AND \"user_id\" = $4));", + ); + }); +}); + +describe("queries.getCountByCompositePksAndParams", () => { + it("should append composite PK conditions after searchFields", () => { + const query = queries.getCountByCompositePksAndParams( + ["tenant_id", "user_id"], + "users", + " WHERE (active = $1)", + 2, + 1, + ); + + expect(query).toBe( + "SELECT COUNT(*) AS count FROM users WHERE (active = $1) AND ((tenant_id = $3 AND user_id = $4));", + ); + }); +}); + +describe("queries.getCountByParams", () => { + it("should build COUNT with searchFields", () => { + expect(queries.getCountByParams("users", " WHERE (active = $1)")).toBe( + "SELECT COUNT(*) AS count FROM users WHERE (active = $1);", + ); + }); +}); + +describe("queries.getCountByPks", () => { + it("should build COUNT with ANY for primary key list", () => { + expect(queries.getCountByPks("id", "users")).toBe( + "SELECT COUNT(*) AS count FROM users WHERE id = ANY ($1);", + ); + }); +}); + +describe("queries.getCountByPksAndParams", () => { + it("should combine searchFields and ANY primary key filter", () => { + const query = queries.getCountByPksAndParams("id", "users", " WHERE (active = $1)", 2); + + expect(query).toBe( + "SELECT COUNT(*) AS count FROM users WHERE (active = $1) AND id = ANY ($3);", + ); + }); +}); + +describe("queries.getOneByPk", () => { + it("should build SELECT by single primary key", () => { + expect(queries.getOneByPk("users", "id")).toBe( + "SELECT * FROM users WHERE id = $1 LIMIT 1;", + ); + }); + + it("should build SELECT by composite primary key", () => { + expect(queries.getOneByPk("users", ["tenant_id", "user_id"])).toBe( + "SELECT * FROM users WHERE tenant_id = $1 AND user_id = $2 LIMIT 1;", + ); + }); +}); + +describe("queries.updateByParams", () => { + it("should build UPDATE with parameterized SET clause", () => { + const query = queries.updateByParams( + "users", + ["name", "age"], + " WHERE (id = $1)", + undefined, + 2, + ); + + expect(query).toBe("UPDATE users SET name = $2,age = $3 WHERE (id = $1) RETURNING *;"); + }); + + it("should append timestamp updateField", () => { + const query = queries.updateByParams( + "users", + ["name"], + " WHERE (id = $1)", + { title: "updated_at", type: "timestamp" }, + 2, + ["id", "name"], + ); + + expect(query).toBe( + "UPDATE users SET name = $2, updated_at = NOW() WHERE (id = $1) RETURNING id,name;", + ); + }); + + it("should append unix_timestamp updateField", () => { + const query = queries.updateByParams( + "users", + ["name"], + " WHERE (id = $1)", + { title: "updated_at", type: "unix_timestamp" }, + 2, + ); + + expect(query).toBe( + "UPDATE users SET name = $2, updated_at = ROUND((EXTRACT(EPOCH FROM NOW()) * (1000)::NUMERIC)) WHERE (id = $1) RETURNING *;", + ); + }); + + it("should throw for invalid updateField type", () => { + expect(() => queries.updateByParams( + "users", + ["name"], + " WHERE (id = $1)", + { title: "updated_at", type: "invalid" as "timestamp" }, + 2, + )).toThrow("Invalid type: invalid"); + }); +}); + +describe("queries.updateByPk", () => { + it("should build UPDATE by single primary key", () => { + const query = queries.updateByPk("users", ["name"], "id", undefined, ["id", "name"]); + + expect(query).toBe("UPDATE users SET name = $1 WHERE id = $2 RETURNING id,name;"); + }); + + it("should build UPDATE by composite primary key", () => { + const query = queries.updateByPk( + "users", + ["name"], + ["tenant_id", "user_id"], + { title: "updated_at", type: "timestamp" }, + ); + + expect(query).toBe( + "UPDATE users SET name = $1, updated_at = NOW() WHERE tenant_id = $2 AND user_id = $3 RETURNING *;", + ); + }); + + it("should throw for invalid updateField type", () => { + expect(() => queries.updateByPk( + "users", + ["name"], + "id", + { title: "updated_at", type: "invalid" as "timestamp" }, + )).toThrow("Invalid type: invalid"); + }); +}); diff --git a/src/lib/pg/model/table.ts b/src/lib/pg/model/table.ts index 42bcef9..530e9d9 100644 --- a/src/lib/pg/model/table.ts +++ b/src/lib/pg/model/table.ts @@ -15,6 +15,7 @@ export class BaseTable { #insertOptions; #sortingOrders = new Set(["ASC", "DESC"]); #tableFieldsSet; + #tableFieldsCoreSet; #isLoggerEnabled: boolean | undefined; #logger?: SharedTypes.TLogger; #executeSql; @@ -30,6 +31,11 @@ export class BaseTable { */ #executor: Types.TExecutor; + #createFieldQuoted; + #primaryKeyQuoted; + #tableNameQuoted; + #updateFieldQuoted; + createField; primaryKey; tableName; @@ -80,10 +86,27 @@ export class BaseTable { } this.createField = data.createField; + this.#createFieldQuoted = this.createField + ? { title: SharedHelpers.quotePgIdent(this.createField.title, { force: true }), type: this.createField.type } + : undefined; + this.primaryKey = data.primaryKey; + this.#primaryKeyQuoted = this.primaryKey + ? Array.isArray(this.primaryKey) + ? this.primaryKey.map((key) => SharedHelpers.quotePgIdent(key, { force: true })) + : SharedHelpers.quotePgIdent(this.primaryKey, { force: true }) + : undefined; + this.tableName = data.tableName; + this.#tableNameQuoted = SharedHelpers.quotePgIdent(this.tableName, { force: true }); + this.tableFields = [...data.tableFields]; + this.#tableFieldsCoreSet = new Set(this.tableFields); + this.updateField = data.updateField; + this.#updateFieldQuoted = this.updateField + ? { title: SharedHelpers.quotePgIdent(this.updateField.title, { force: true }), type: this.updateField.type } + : undefined; this.#tableFieldsSet = new Set([ ...this.tableFields, @@ -240,22 +263,22 @@ export class BaseTable { const params = SharedHelpers.clearUndefinedFields(example); - Object.keys(params).forEach((e) => headers.add(e)); + Object.keys(params).forEach((e) => headers.add(SharedHelpers.quotePgIdent(e, { tableFieldsSet: this.#tableFieldsCoreSet }))); - if (this.createField) { - headers.add(this.createField.title); + if (this.#createFieldQuoted) { + headers.add(this.#createFieldQuoted.title); } for (const pR of recordParams) { const params = SharedHelpers.clearUndefinedFields(pR); - const keys = new Set(Object.keys(params)); + const keys = new Set(Object.keys(params).map((key) => SharedHelpers.quotePgIdent(key, { tableFieldsSet: this.#tableFieldsCoreSet }))); const paramsPrepared = [...Object.values(params)]; - if (this.createField) { - if (!keys.has(this.createField.title)) { - keys.add(this.createField.title); + if (this.#createFieldQuoted) { + if (!keys.has(this.#createFieldQuoted.title)) { + keys.add(this.#createFieldQuoted.title); - switch (this.createField.type) { + switch (this.#createFieldQuoted.type) { case "timestamp": paramsPrepared.push(new Date().toISOString()); break; @@ -265,7 +288,7 @@ export class BaseTable { break; default: - throw new Error("Invalid type: " + this.createField.type); + throw new Error("Invalid type: " + this.#createFieldQuoted.type); } } } @@ -291,8 +314,8 @@ export class BaseTable { fields: k, headers: [...headers], onConflict, - returning: saveOptions?.returningFields, - tableName: this.tableName, + returning: saveOptions?.returningFields?.map((field) => SharedHelpers.quotePgIdent(field, { tableFieldsSet: this.#tableFieldsCoreSet })), + tableName: this.#tableNameQuoted, }), values: v, }; @@ -308,29 +331,35 @@ export class BaseTable { if (!fields.length) { throw new Error("No one save field arrived"); } return { - query: queries.createOne(this.tableName, fields, this.createField, onConflict, saveOptions?.returningFields), + query: queries.createOne( + this.#tableNameQuoted, + fields.map((field) => SharedHelpers.quotePgIdent(field, { tableFieldsSet: this.#tableFieldsCoreSet })), + this.#createFieldQuoted, + onConflict, + saveOptions?.returningFields?.map((field) => SharedHelpers.quotePgIdent(field, { tableFieldsSet: this.#tableFieldsCoreSet })), + ), values: Object.values(clearedParams), }; }, deleteAll: (): { query: string; } => { - return { query: queries.deleteAll(this.tableName) }; + return { query: queries.deleteAll(this.#tableNameQuoted) }; }, deleteByParams: ( { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, ): { query: string; values: unknown[]; } => { - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#tableFieldsCoreSet }); const { searchFields } = this.getFieldsToSearch({ queryArray, queryOrArray }); return { - query: queries.deleteByParams(this.tableName, searchFields), + query: queries.deleteByParams(this.#tableNameQuoted, searchFields), values, }; }, deleteOneByPk: (primaryKey: T): { query: string; values: unknown[]; } => { - if (!this.primaryKey) { throw new Error("Primary key not specified"); } + if (!this.#primaryKeyQuoted) { throw new Error("Primary key not specified"); } return { - query: queries.deleteByPk(this.tableName, this.primaryKey), + query: queries.deleteByPk(this.#tableNameQuoted, this.#primaryKeyQuoted), values: Array.isArray(primaryKey) ? primaryKey : [primaryKey], }; }, @@ -340,8 +369,12 @@ export class BaseTable { pagination?: SharedTypes.TPagination, order?: { orderBy: string; ordering: SharedTypes.TOrdering; }[], ): { query: string; values: unknown[]; } => { + const orderResult: { orderBy: string; ordering: SharedTypes.TOrdering; }[] = []; + if (order?.length) { for (const o of order) { + orderResult.push({ orderBy: SharedHelpers.quotePgIdent(o.orderBy, { tableFieldsSet: this.#tableFieldsCoreSet }), ordering: o.ordering }); + if (!this.#tableFieldsSet.has(o.orderBy)) { const allowedFields = Array.from(this.#tableFieldsSet).join(", "); @@ -352,43 +385,51 @@ export class BaseTable { } } - if (!selected.length) selected.push("*"); + const selectedResult = []; + + if (!selected.length) { + selectedResult.push("*"); + } else { + selected.forEach((e) => selectedResult.push(SharedHelpers.quotePgIdent(e, { tableFieldsSet: this.#tableFieldsCoreSet }))); + } - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, pagination, order); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#tableFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, pagination, orderResult); return { - query: queries.getByParams(this.tableName, selectedFields, searchFields, orderByFields, paginationFields), + query: queries.getByParams(this.#tableNameQuoted, selectedFields, searchFields, orderByFields, paginationFields), values, }; }, getCountByParams: ( { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, ): { query: string; values: unknown[]; } => { - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#tableFieldsCoreSet }); const { searchFields } = this.getFieldsToSearch({ queryArray, queryOrArray }); return { - query: queries.getCountByParams(this.tableName, searchFields), + query: queries.getCountByParams(this.#tableNameQuoted, searchFields), values, }; }, getCountByPks: (pks: T[]): { query: string; values: unknown[]; } => { - if (!this.primaryKey) { throw new Error("Primary key not specified"); } + if (!this.#primaryKeyQuoted) { throw new Error("Primary key not specified"); } if (Array.isArray(pks[0])) { - if (!Array.isArray(this.primaryKey)) { throw new Error("invalid primary key type"); } + if (!Array.isArray(this.#primaryKeyQuoted)) { throw new Error("invalid primary key type"); } return { - query: queries.getCountByCompositePks(this.primaryKey as string[], this.tableName, pks.length), + query: queries.getCountByCompositePks(this.#primaryKeyQuoted, this.#tableNameQuoted, pks.length), values: pks.flat(), }; } - if (Array.isArray(this.primaryKey)) { throw new Error("invalid primary key type"); } + if (Array.isArray(this.#primaryKeyQuoted)) { + throw new Error("invalid primary key type"); + } return { - query: queries.getCountByPks(this.primaryKey as string, this.tableName), + query: queries.getCountByPks(this.#primaryKeyQuoted, this.#tableNameQuoted), values: [pks], }; }, @@ -396,24 +437,24 @@ export class BaseTable { pks: T[], { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, ): { query: string; values: unknown[]; } => { - if (!this.primaryKey) { throw new Error("Primary key not specified"); } + if (!this.#primaryKeyQuoted) { throw new Error("Primary key not specified"); } - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#tableFieldsCoreSet }); const { orderNumber, searchFields } = this.getFieldsToSearch({ queryArray, queryOrArray }); if (Array.isArray(pks[0])) { - if (!Array.isArray(this.primaryKey)) { throw new Error("invalid primary key type"); } + if (!Array.isArray(this.#primaryKeyQuoted)) { throw new Error("invalid primary key type"); } return { - query: queries.getCountByCompositePksAndParams(this.primaryKey, this.tableName, searchFields, orderNumber, pks.length), + query: queries.getCountByCompositePksAndParams(this.#primaryKeyQuoted, this.#tableNameQuoted, searchFields, orderNumber, pks.length), values: [...values, ...pks.flat()], }; } - if (Array.isArray(this.primaryKey)) { throw new Error("invalid primary key type"); } + if (Array.isArray(this.#primaryKeyQuoted)) { throw new Error("invalid primary key type"); } return { - query: queries.getCountByPksAndParams(this.primaryKey, this.tableName, searchFields, orderNumber), + query: queries.getCountByPksAndParams(this.#primaryKeyQuoted, this.#tableNameQuoted, searchFields, orderNumber), values: [...values, pks], }; }, @@ -421,14 +462,20 @@ export class BaseTable { { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, selected = ["*"], ): { query: string; values: unknown[]; } => { - if (!selected.length) selected.push("*"); + const selectedResult = []; + + if (!selected.length) { + selectedResult.push("*"); + } else { + selected.forEach((e) => selectedResult.push(SharedHelpers.quotePgIdent(e, { tableFieldsSet: this.#tableFieldsCoreSet }))); + } - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, { limit: 1, offset: 0 }); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#tableFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, { limit: 1, offset: 0 }); return { query: queries.getByParams( - this.tableName, + this.#tableNameQuoted, selectedFields, searchFields, orderByFields, @@ -438,10 +485,10 @@ export class BaseTable { }; }, getOneByPk: (primaryKey: T): { query: string; values: unknown[]; } => { - if (!this.primaryKey) { throw new Error("Primary key not specified"); } + if (!this.#primaryKeyQuoted) { throw new Error("Primary key not specified"); } return { - query: queries.getOneByPk(this.tableName, this.primaryKey), + query: queries.getOneByPk(this.#tableNameQuoted, this.#primaryKeyQuoted), values: Array.isArray(primaryKey) ? [...primaryKey] : [primaryKey], }; }, @@ -463,13 +510,19 @@ export class BaseTable { } } - if (!selected.length) selected.push("*"); + const selectedResult = []; - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, pagination, order); + if (!selected.length) { + selectedResult.push("*"); + } else { + selected.forEach((e) => selectedResult.push(SharedHelpers.quotePgIdent(e, { tableFieldsSet: this.#tableFieldsCoreSet }))); + } + + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#tableFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, pagination, order); return { - query: queries.getByParams(this.tableName, selectedFields, searchFields, orderByFields, paginationFields), + query: queries.getByParams(this.#tableNameQuoted, selectedFields, searchFields, orderByFields, paginationFields), values, }; }, @@ -477,15 +530,24 @@ export class BaseTable { queryConditions: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; returningFields?: string[]; }, updateFields: SharedTypes.TRawParams = {}, ): { query: string; values: unknown[]; } => { - const { queryArray, queryOrArray, values } = this.compareFields(queryConditions.$and, queryConditions.$or); + const { queryArray, queryOrArray, values } = this.compareFields(queryConditions.$and, queryConditions.$or, { tableFieldsSet: this.#tableFieldsCoreSet }); const { orderNumber, searchFields } = this.getFieldsToSearch({ queryArray, queryOrArray }); const clearedUpdate = SharedHelpers.clearUndefinedFields(updateFields); - const fieldsToUpdate = Object.keys(clearedUpdate); + const fieldsToUpdate = Object.keys(clearedUpdate).map((e) => SharedHelpers.quotePgIdent(e, { tableFieldsSet: this.#tableFieldsCoreSet })); - if (!queryArray.length) throw new Error("No one update field arrived"); + if (!queryArray.length) { + throw new Error("No one update field arrived"); + } return { - query: queries.updateByParams(this.tableName, fieldsToUpdate, searchFields, this.updateField, orderNumber + 1, queryConditions?.returningFields), + query: queries.updateByParams( + this.#tableNameQuoted, + fieldsToUpdate, + searchFields, + this.#updateFieldQuoted, + orderNumber + 1, + queryConditions?.returningFields?.map((e) => SharedHelpers.quotePgIdent(e, { tableFieldsSet: this.#tableFieldsCoreSet })), + ), values: [...values, ...Object.values(clearedUpdate)], }; }, @@ -494,15 +556,25 @@ export class BaseTable { updateFields: SharedTypes.TRawParams = {}, updateOptions?: { returningFields?: string[]; }, ): { query: string; values: unknown[]; } => { - if (!this.primaryKey) { throw new Error("Primary key not specified"); } + if (!this.#primaryKeyQuoted) { + throw new Error("Primary key not specified"); + } const clearedParams = SharedHelpers.clearUndefinedFields(updateFields); - const fields = Object.keys(clearedParams); + const fields = Object.keys(clearedParams).map((e) => SharedHelpers.quotePgIdent(e, { tableFieldsSet: this.#tableFieldsCoreSet })); - if (!fields.length) throw new Error("No one update field arrived"); + if (!fields.length) { + throw new Error("No one update field arrived"); + } return { - query: queries.updateByPk(this.tableName, fields, this.primaryKey, this.updateField, updateOptions?.returningFields), + query: queries.updateByPk( + this.#tableNameQuoted, + fields, + this.#primaryKeyQuoted, + this.#updateFieldQuoted, + updateOptions?.returningFields?.map((e) => SharedHelpers.quotePgIdent(e, { tableFieldsSet: this.#tableFieldsCoreSet })), + ), values: [...Object.values(clearedParams), ...Array.isArray(primaryKeyValue) ? [...primaryKeyValue] : [primaryKeyValue]], }; }, diff --git a/src/lib/pg/model/table.unit.spec.ts b/src/lib/pg/model/table.unit.spec.ts new file mode 100644 index 0000000..cd48b0c --- /dev/null +++ b/src/lib/pg/model/table.unit.spec.ts @@ -0,0 +1,355 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import pg from "pg"; + +import * as Types from "./types.js"; +import { BaseTable } from "./table.js"; + +const mockClient = {} as pg.Client; + +const usersSchema = { + createField: null, + primaryKey: "id", + tableFields: ["id", "name", "age", "published"], + tableName: "users", + updateField: { title: "updated_at", type: "timestamp" as const }, +} satisfies Types.TTable; + +const invTypesSchema = { + createField: null, + primaryKey: "typeID", + tableFields: ["typeID", "typeName", "published"], + tableName: "invTypes", + updateField: null, +} satisfies Types.TTable; + +function createTable( + schema: Types.TTable = usersSchema, + options?: Types.TDBOptions, +): BaseTable { + return new BaseTable(schema, undefined, { client: mockClient, ...options }); +} + +describe("BaseTable", () => { + describe("constructor", () => { + it("should throw when neither client nor dbCreds are provided", () => { + expect(() => new BaseTable(usersSchema)).toThrow("No client or dbCreds provided"); + }); + + it("should expose schema metadata", () => { + const table = createTable(); + + expect(table.tableName).toBe("users"); + expect(table.primaryKey).toBe("id"); + expect(table.tableFields).toEqual(["id", "name", "age", "published"]); + expect(table.updateField).toEqual({ title: "updated_at", type: "timestamp" }); + }); + }); + + describe("compareQuery.createOne", () => { + it("should build INSERT query and values", () => { + const table = createTable(); + const result = table.compareQuery.createOne({ age: 30, name: "John" }); + + expect(result.query).toBe("INSERT INTO \"users\" (\"age\",\"name\") VALUES ($1,$2) RETURNING *;"); + expect(result.values).toEqual([30, "John"]); + }); + + it("should throw when no fields are provided", () => { + const table = createTable(); + + expect(() => table.compareQuery.createOne({})).toThrow("No one save field arrived"); + }); + + it("should apply onConflict from insertOptions", () => { + const table = createTable(usersSchema, { + insertOptions: { onConflict: "ON CONFLICT DO NOTHING" }, + }); + const result = table.compareQuery.createOne( + { name: "John" }, + { returningFields: ["id"] }, + ); + + expect(result.query).toBe( + "INSERT INTO \"users\" (\"name\") VALUES ($1) ON CONFLICT DO NOTHING RETURNING \"id\";", + ); + }); + }); + + describe("compareQuery.createMany", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should build INSERT for multiple rows", () => { + const table = createTable(); + const result = table.compareQuery.createMany([ + { age: 30, name: "John" }, + { age: 25, name: "Jane" }, + ]); + + expect(result.query).toBe( + "INSERT INTO \"users\" (\"age\",\"name\") VALUES ($1,$2),($3,$4) RETURNING *;", + ); + expect(result.values).toEqual([30, "John", 25, "Jane"]); + }); + + it("should throw for empty recordParams", () => { + const table = createTable(); + + expect(() => table.compareQuery.createMany([])).toThrow("Invalid recordParams"); + }); + + it("should append createField when missing in params", () => { + const table = createTable({ + ...usersSchema, + createField: { title: "created_at", type: "timestamp" }, + tableFields: [...usersSchema.tableFields, "created_at"], + }); + const result = table.compareQuery.createMany([{ name: "John" }]); + + expect(result.query).toBe( + "INSERT INTO \"users\" (\"name\",\"created_at\") VALUES ($1,$2) RETURNING *;", + ); + expect(result.values).toEqual(["John", "2024-01-01T00:00:00.000Z"]); + }); + }); + + describe("compareQuery.deleteAll", () => { + it("should build DELETE query", () => { + const table = createTable(); + + expect(table.compareQuery.deleteAll()).toEqual({ + query: "DELETE FROM \"users\";", + }); + }); + }); + + describe("compareQuery.deleteByParams", () => { + it("should build DELETE with search conditions", () => { + const table = createTable(); + const result = table.compareQuery.deleteByParams({ + $and: { age: { $gt: 18 }, published: true }, + }); + + expect(result.query).toBe("DELETE FROM \"users\" WHERE (\"age\" > $1 AND \"published\" = $2);"); + expect(result.values).toEqual([18, true]); + }); + }); + + describe("compareQuery.deleteOneByPk", () => { + it("should build DELETE by primary key", () => { + const table = createTable(); + const result = table.compareQuery.deleteOneByPk(42); + + expect(result.query).toBe("DELETE FROM \"users\" WHERE \"id\" = $1 RETURNING \"id\";"); + expect(result.values).toEqual([42]); + }); + + it("should throw when primary key is not configured", () => { + const table = createTable({ ...usersSchema, primaryKey: null }); + + expect(() => table.compareQuery.deleteOneByPk(1)).toThrow("Primary key not specified"); + }); + }); + + describe("compareQuery.getOneByParams", () => { + it("should build SELECT with LIMIT 1", () => { + const table = createTable(); + const result = table.compareQuery.getOneByParams( + { $and: { published: true } }, + ["id", "name"], + ); + + expect(result.query).toBe( + "SELECT \"id\", \"name\" FROM \"users\" WHERE (\"published\" = $1) LIMIT 1 OFFSET 0;", + ); + expect(result.values).toEqual([true]); + }); + + it("should support quoted SQL identifiers from schema", () => { + const table = createTable(invTypesSchema); + const result = table.compareQuery.getOneByParams( + { $and: { typeID: 34 } }, + ["typeID", "typeName"], + ); + + expect(result.query).toBe( + "SELECT \"typeID\", \"typeName\" FROM \"invTypes\" WHERE (\"typeID\" = $1) LIMIT 1 OFFSET 0;", + ); + expect(result.values).toEqual([34]); + }); + }); + + describe("compareQuery.getArrByParams", () => { + it("should build SELECT with pagination and order", () => { + const table = createTable(); + const result = table.compareQuery.getArrByParams( + { $and: { published: true } }, + ["id", "name"], + { limit: 10, offset: 5 }, + [{ orderBy: "name", ordering: "ASC" }], + ); + + expect(result.query).toBe( + "SELECT \"id\", \"name\" FROM \"users\" WHERE (\"published\" = $1) ORDER BY \"name\" ASC LIMIT 10 OFFSET 5;", + ); + expect(result.values).toEqual([true]); + }); + + it("should throw for invalid orderBy", () => { + const table = createTable(); + + expect(() => table.compareQuery.getArrByParams( + { $and: {} }, + ["*"], + undefined, + [{ orderBy: "unknown", ordering: "ASC" }], + )).toThrow("Invalid orderBy: unknown"); + }); + + it("should throw for invalid ordering", () => { + const table = createTable(); + + expect(() => table.compareQuery.getArrByParams( + { $and: {} }, + ["*"], + undefined, + [{ orderBy: "name", ordering: "INVALID" as "ASC" }], + )).toThrow("Invalid ordering"); + }); + }); + + describe("compareQuery.getCountByParams", () => { + it("should build COUNT query", () => { + const table = createTable(); + const result = table.compareQuery.getCountByParams({ + $and: { published: true }, + }); + + expect(result.query).toBe("SELECT COUNT(*) AS count FROM \"users\" WHERE (\"published\" = $1);"); + expect(result.values).toEqual([true]); + }); + }); + + describe("compareQuery.getOneByPk", () => { + it("should build SELECT by primary key", () => { + const table = createTable(); + const result = table.compareQuery.getOneByPk(7); + + expect(result.query).toBe("SELECT * FROM \"users\" WHERE \"id\" = $1 LIMIT 1;"); + expect(result.values).toEqual([7]); + }); + }); + + describe("compareQuery.getCountByPks", () => { + it("should build COUNT for scalar primary keys", () => { + const table = createTable(); + const result = table.compareQuery.getCountByPks([1, 2, 3]); + + expect(result.query).toBe("SELECT COUNT(*) AS count FROM \"users\" WHERE \"id\" = ANY ($1);"); + expect(result.values).toEqual([[1, 2, 3]]); + }); + + it("should build COUNT for composite primary keys", () => { + const table = createTable({ + ...usersSchema, + primaryKey: ["tenant_id", "user_id"], + tableFields: ["tenant_id", "user_id", "name"], + }); + const result = table.compareQuery.getCountByPks([[1, 10], [2, 20]]); + + expect(result.query).toBe( + "SELECT COUNT(*) AS count FROM \"users\" WHERE ((\"tenant_id\" = $1 AND \"user_id\" = $2) OR (\"tenant_id\" = $3 AND \"user_id\" = $4));", + ); + expect(result.values).toEqual([1, 10, 2, 20]); + }); + }); + + describe("compareQuery.getCountByPksAndParams", () => { + it("should combine search params and primary key filter", () => { + const table = createTable(); + const result = table.compareQuery.getCountByPksAndParams( + [1, 2, 3], + { $and: { published: true } }, + ); + + expect(result.query).toBe( + "SELECT COUNT(*) AS count FROM \"users\" WHERE (\"published\" = $1) AND \"id\" = ANY ($2);", + ); + expect(result.values).toEqual([true, [1, 2, 3]]); + }); + }); + + describe("compareQuery.streamArrByParams", () => { + it("should build the same query shape as getArrByParams", () => { + const table = createTable(); + const result = table.compareQuery.streamArrByParams( + { $and: { published: true } }, + ["id"], + { limit: 5, offset: 0 }, + ); + + expect(result.query).toBe( + "SELECT \"id\" FROM \"users\" WHERE (\"published\" = $1) LIMIT 5 OFFSET 0;", + ); + }); + }); + + describe("compareQuery.updateByParams", () => { + it("should build UPDATE with WHERE and SET clauses", () => { + const table = createTable(); + const result = table.compareQuery.updateByParams( + { $and: { id: 1 }, returningFields: ["id", "name"] }, + { age: 31, name: "John" }, + ); + + expect(result.query).toBe( + "UPDATE \"users\" SET \"age\" = $2,\"name\" = $3, \"updated_at\" = NOW() WHERE (\"id\" = $1) RETURNING \"id\",\"name\";", + ); + expect(result.values).toEqual([1, 31, "John"]); + }); + + it("should throw when search params are empty", () => { + const table = createTable(); + + expect(() => table.compareQuery.updateByParams( + { $and: {} }, + { name: "John" }, + )).toThrow("No one update field arrived"); + }); + }); + + describe("compareQuery.updateOneByPk", () => { + it("should build UPDATE by primary key", () => { + const table = createTable(); + const result = table.compareQuery.updateOneByPk( + 42, + { name: "Jane" }, + { returningFields: ["id", "name"] }, + ); + + expect(result.query).toBe( + "UPDATE \"users\" SET \"name\" = $1, \"updated_at\" = NOW() WHERE \"id\" = $2 RETURNING \"id\",\"name\";", + ); + expect(result.values).toEqual(["Jane", 42]); + }); + + it("should throw when update fields are empty", () => { + const table = createTable(); + + expect(() => table.compareQuery.updateOneByPk(1, {})).toThrow("No one update field arrived"); + }); + }); +}); diff --git a/src/lib/pg/model/view.ts b/src/lib/pg/model/view.ts index 7019ea6..f11b542 100644 --- a/src/lib/pg/model/view.ts +++ b/src/lib/pg/model/view.ts @@ -1,12 +1,12 @@ import pg from "pg"; import * as Helpers from "../helpers/index.js"; +import * as SharedHelpers from "../../../shared-helpers/index.js"; import * as SharedTypes from "../../../shared-types/index.js"; import * as Types from "./types.js"; import * as connection from "../connection.js"; import { QueryBuilder } from "../query-builder/index.js"; import queries from "./queries.js"; -import { setLoggerAndExecutor } from "../helpers/index.js"; /** * @experimental @@ -16,6 +16,7 @@ import { setLoggerAndExecutor } from "../helpers/index.js"; export class BaseView { #sortingOrders = new Set(["ASC", "DESC"]); #coreFieldsSet; + #coreFieldsCoreSet; #isLoggerEnabled: boolean | undefined; #logger?: SharedTypes.TLogger; #executeSql; @@ -31,6 +32,8 @@ export class BaseView { */ #executor: Types.TExecutor; + #nameQuoted; + /** * The name of the view. */ @@ -42,7 +45,7 @@ export class BaseView { coreFields: readonly string[]; /** - * Creates an instance of `BaseMaterializedView`. + * Creates an instance of `BaseView`. * * @param data - Data for initializing the view. * @param data.coreFields - The core fields of the view. @@ -67,7 +70,10 @@ export class BaseView { } this.name = data.name; - this.coreFields = data.coreFields; + this.#nameQuoted = SharedHelpers.quotePgIdent(this.name, { force: true }); + + this.coreFields = [...data.coreFields]; + this.#coreFieldsCoreSet = new Set(this.coreFields); this.#coreFieldsSet = new Set([ ...this.coreFields, @@ -78,7 +84,7 @@ export class BaseView { const { isLoggerEnabled, logger } = options || {}; - const preparedOptions = setLoggerAndExecutor( + const preparedOptions = Helpers.setLoggerAndExecutor( this.#executor, { isLoggerEnabled, logger }, ); @@ -118,7 +124,7 @@ export class BaseView { * @param logger - The logger to use for the view. */ setLogger(logger: SharedTypes.TLogger) { - const preparedOptions = setLoggerAndExecutor( + const preparedOptions = Helpers.setLoggerAndExecutor( this.#executor, { isLoggerEnabled: true, logger }, ); @@ -140,7 +146,7 @@ export class BaseView { * @param executor - The executor to use for the view. */ setExecutor(executor: Types.TExecutor) { - const preparedOptions = setLoggerAndExecutor( + const preparedOptions = Helpers.setLoggerAndExecutor( executor, { isLoggerEnabled: this.#isLoggerEnabled, logger: this.#logger }, ); @@ -199,7 +205,7 @@ export class BaseView { * * @returns A new instance of the base class with the new connection client. */ - setClientInBaseClass(client: Types.TExecutor): BaseView { + setClientInBaseClass(client: Types.TExecutor): BaseView { return new BaseView( { ...this.#initialArgs.data }, this.#initialArgs.dbCreds ? { ...this.#initialArgs.dbCreds } : undefined, @@ -237,12 +243,16 @@ export class BaseView { */ getArrByParams: ( { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, - selected: string[] = ["*"], + selected = ["*"], pagination?: SharedTypes.TPagination, order?: { orderBy: string; ordering: SharedTypes.TOrdering; }[], ): { query: string; values: unknown[]; } => { + const orderResult: { orderBy: string; ordering: SharedTypes.TOrdering; }[] = []; + if (order?.length) { for (const o of order) { + orderResult.push({ orderBy: SharedHelpers.quotePgIdent(o.orderBy, { tableFieldsSet: this.#coreFieldsCoreSet }), ordering: o.ordering }); + if (!this.#coreFieldsSet.has(o.orderBy)) { const allowedFields = Array.from(this.#coreFieldsSet).join(", "); @@ -253,13 +263,19 @@ export class BaseView { } } - if (!selected.length) selected.push("*"); + const selectedResult = []; + + if (!selected.length) { + selectedResult.push("*"); + } else { + selected.forEach((e) => selectedResult.push(SharedHelpers.quotePgIdent(e, { tableFieldsSet: this.#coreFieldsCoreSet }))); + } - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, pagination, order); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, pagination, orderResult); return { - query: queries.getByParams(this.name, selectedFields, searchFields, orderByFields, paginationFields), + query: queries.getByParams(this.#nameQuoted, selectedFields, searchFields, orderByFields, paginationFields), values, }; }, @@ -275,11 +291,11 @@ export class BaseView { getCountByParams: ( { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, ): { query: string; values: unknown[]; } => { - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); const { searchFields } = this.getFieldsToSearch({ queryArray, queryOrArray }); return { - query: queries.getCountByParams(this.name, searchFields), + query: queries.getCountByParams(this.#nameQuoted, searchFields), values, }; }, @@ -295,16 +311,22 @@ export class BaseView { */ getOneByParams: ( { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, - selected: string[] = ["*"], + selected = ["*"], ): { query: string; values: unknown[]; } => { - if (!selected.length) selected.push("*"); + const selectedResult = []; + + if (!selected.length) { + selectedResult.push("*"); + } else { + selected.forEach((e) => selectedResult.push(SharedHelpers.quotePgIdent(e, { tableFieldsSet: this.#coreFieldsCoreSet }))); + } - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, { limit: 1, offset: 0 }); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, { limit: 1, offset: 0 }); return { query: queries.getByParams( - this.name, + this.#nameQuoted, selectedFields, searchFields, orderByFields, @@ -329,7 +351,7 @@ export class BaseView { */ streamArrByParams: ( { $and = {}, $or }: { $and: Types.TSearchParams; $or?: Types.TSearchParams[]; }, - selected: string[] = ["*"], + selected = ["*"], pagination?: SharedTypes.TPagination, order?: { orderBy: string; ordering: SharedTypes.TOrdering; }[], ): { query: string; values: unknown[]; } => { @@ -345,13 +367,19 @@ export class BaseView { } } - if (!selected.length) selected.push("*"); + const selectedResult = []; + + if (!selected.length) { + selectedResult.push("*"); + } else { + selected.forEach((e) => selectedResult.push(SharedHelpers.quotePgIdent(e, { tableFieldsSet: this.#coreFieldsCoreSet }))); + } - const { queryArray, queryOrArray, values } = this.compareFields($and, $or); - const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selected, pagination, order); + const { queryArray, queryOrArray, values } = this.compareFields($and, $or, { tableFieldsSet: this.#coreFieldsCoreSet }); + const { orderByFields, paginationFields, searchFields, selectedFields } = this.getFieldsToSearch({ queryArray, queryOrArray }, selectedResult, pagination, order); return { - query: queries.getByParams(this.name, selectedFields, searchFields, orderByFields, paginationFields), + query: queries.getByParams(this.#nameQuoted, selectedFields, searchFields, orderByFields, paginationFields), values, }; }, diff --git a/src/lib/pg/model/view.unit.spec.ts b/src/lib/pg/model/view.unit.spec.ts new file mode 100644 index 0000000..171cab9 --- /dev/null +++ b/src/lib/pg/model/view.unit.spec.ts @@ -0,0 +1,173 @@ +import { + describe, + expect, + it, +} from "vitest"; +import pg from "pg"; + +import * as Types from "./types.js"; +import { BaseView } from "./view.js"; + +const mockClient = {} as pg.Client; + +const usersViewSchema = { + coreFields: ["id", "name", "age", "published"], + name: "users_view", +} satisfies Types.TView; + +const invTypesViewSchema = { + coreFields: ["typeID", "typeName", "published"], + name: "invTypesView", +} satisfies Types.TView; + +function createView( + schema: Types.TView = usersViewSchema, + options?: Types.TVOptions, +): BaseView { + return new BaseView(schema, undefined, { client: mockClient, ...options }); +} + +describe("BaseView", () => { + describe("constructor", () => { + it("should throw when neither client nor dbCreds are provided", () => { + expect(() => new BaseView(usersViewSchema)).toThrow("No client or dbCreds provided"); + }); + + it("should expose schema metadata", () => { + const view = createView(); + + expect(view.name).toBe("users_view"); + expect(view.coreFields).toEqual(["id", "name", "age", "published"]); + }); + + it("should copy coreFields array", () => { + const coreFields = ["id", "name"]; + const view = createView({ ...usersViewSchema, coreFields }); + + expect(view.coreFields).toEqual(coreFields); + expect(view.coreFields).not.toBe(coreFields); + }); + }); + + describe("compareQuery.getOneByParams", () => { + it("should build SELECT with LIMIT 1", () => { + const view = createView(); + const result = view.compareQuery.getOneByParams( + { $and: { published: true } }, + ["id", "name"], + ); + + expect(result.query).toBe( + "SELECT \"id\", \"name\" FROM \"users_view\" WHERE (\"published\" = $1) LIMIT 1 OFFSET 0;", + ); + expect(result.values).toEqual([true]); + }); + + it("should support quoted SQL identifiers from schema", () => { + const view = createView(invTypesViewSchema); + const result = view.compareQuery.getOneByParams( + { $and: { typeID: 34 } }, + ["typeID", "typeName"], + ); + + expect(result.query).toBe( + "SELECT \"typeID\", \"typeName\" FROM \"invTypesView\" WHERE (\"typeID\" = $1) LIMIT 1 OFFSET 0;", + ); + expect(result.values).toEqual([34]); + }); + }); + + describe("compareQuery.getArrByParams", () => { + it("should build SELECT with pagination and order", () => { + const view = createView(); + const result = view.compareQuery.getArrByParams( + { $and: { published: true } }, + ["id", "name"], + { limit: 10, offset: 5 }, + [{ orderBy: "name", ordering: "ASC" }], + ); + + expect(result.query).toBe( + "SELECT \"id\", \"name\" FROM \"users_view\" WHERE (\"published\" = $1) ORDER BY \"name\" ASC LIMIT 10 OFFSET 5;", + ); + expect(result.values).toEqual([true]); + }); + + it("should allow additionalSortingFields for orderBy", () => { + const view = createView({ + ...usersViewSchema, + additionalSortingFields: ["computed_rank"], + }); + const result = view.compareQuery.getArrByParams( + { $and: {} }, + ["id"], + undefined, + [{ orderBy: "computed_rank", ordering: "DESC" }], + ); + + expect(result.query).toBe( + "SELECT \"id\" FROM \"users_view\" WHERE (1=1) ORDER BY computed_rank DESC;", + ); + }); + + it("should throw for invalid orderBy", () => { + const view = createView(); + + expect(() => view.compareQuery.getArrByParams( + { $and: {} }, + ["*"], + undefined, + [{ orderBy: "unknown", ordering: "ASC" }], + )).toThrow("Invalid orderBy: unknown"); + }); + + it("should throw for invalid ordering", () => { + const view = createView(); + + expect(() => view.compareQuery.getArrByParams( + { $and: {} }, + ["*"], + undefined, + [{ orderBy: "name", ordering: "INVALID" as "ASC" }], + )).toThrow("Invalid ordering"); + }); + }); + + describe("compareQuery.getCountByParams", () => { + it("should build COUNT query", () => { + const view = createView(); + const result = view.compareQuery.getCountByParams({ + $and: { published: true }, + }); + + expect(result.query).toBe("SELECT COUNT(*) AS count FROM \"users_view\" WHERE (\"published\" = $1);"); + expect(result.values).toEqual([true]); + }); + }); + + describe("compareQuery.streamArrByParams", () => { + it("should build the same query shape as getArrByParams", () => { + const view = createView(); + const result = view.compareQuery.streamArrByParams( + { $and: { published: true } }, + ["id"], + { limit: 5, offset: 0 }, + ); + + expect(result.query).toBe( + "SELECT \"id\" FROM \"users_view\" WHERE (\"published\" = $1) LIMIT 5 OFFSET 0;", + ); + }); + + it("should throw for invalid orderBy", () => { + const view = createView(); + + expect(() => view.compareQuery.streamArrByParams( + { $and: {} }, + ["*"], + undefined, + [{ orderBy: "unknown", ordering: "ASC" }], + )).toThrow("Invalid orderBy: unknown"); + }); + }); +}); diff --git a/src/shared-helpers/index.ts b/src/shared-helpers/index.ts index 16b1906..3246fea 100644 --- a/src/shared-helpers/index.ts +++ b/src/shared-helpers/index.ts @@ -26,3 +26,37 @@ export function isHasFields( return fields.every((field) => objKeys.includes(field)); } + +export function quoteMysqlIdent(bare: string, options?: { + force?: boolean; + tableFieldsSet?: Set; +}): string { + const { force, tableFieldsSet } = options || {}; + + if (force) { + return `\`${bare}\``; + } + + if (!tableFieldsSet?.has(bare)) { + return bare; + } + + return `\`${bare}\``; +} + +export function quotePgIdent(bare: string, options?: { + force?: boolean; + tableFieldsSet?: Set; +}): string { + const { force, tableFieldsSet } = options || {}; + + if (force) { + return `"${bare}"`; + } + + if (!tableFieldsSet?.has(bare)) { + return bare; + } + + return `"${bare}"`; +}