From 610e461e1dbedc9c1978fc41112a8ccb47100d5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:42:27 +0000 Subject: [PATCH 1/4] Initial plan From bf09aaf32d053acb56163729ab0ad65e09b9a477 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:51:39 +0000 Subject: [PATCH 2/4] Initial plan Co-authored-by: kbarbounakis <9191768+kbarbounakis@users.noreply.github.com> --- package-lock.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/package-lock.json b/package-lock.json index f51f878..e77071b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3069,6 +3069,25 @@ "add-peers": "bin/add-peers" } }, + "node_modules/@themost/query": { + "version": "2.14.17", + "resolved": "https://registry.npmjs.org/@themost/query/-/query-2.14.17.tgz", + "integrity": "sha512-YVFGVHIExAoDIuIGIBhMuLtrSEA6EOQFH8JGaGbVv7IzuYbLsgAoKdErBmMjVMZuFmXpWt26AfJ/8/43U3tZiA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@themost/events": "^1.5.0", + "@themost/json": "^1.1.0", + "async": "^3.2.3", + "esprima": "^4.0.0", + "lodash": "^4.17.21", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.4.0" + } + }, "node_modules/@themost/sqlite": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/@themost/sqlite/-/sqlite-2.11.0.tgz", From f1440364d6291d6edf91432b6ef8750e579516e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:59:47 +0000 Subject: [PATCH 3/4] Add independent SQL dialect formatter classes (SQLite, MySQL, MSSQL, PostgreSQL) Co-authored-by: kbarbounakis <9191768+kbarbounakis@users.noreply.github.com> --- spec/SqlDialects.spec.js | 539 +++++++++++++++++++++++++++++++ spec/test/TestMemoryFormatter.js | 24 +- src/dialects/index.js | 5 + src/dialects/mssql.js | 361 +++++++++++++++++++++ src/dialects/mysql.js | 358 ++++++++++++++++++++ src/dialects/pg.js | 349 ++++++++++++++++++++ src/dialects/sqlite.js | 516 +++++++++++++++++++++++++++++ src/index.js | 3 +- 8 files changed, 2133 insertions(+), 22 deletions(-) create mode 100644 spec/SqlDialects.spec.js create mode 100644 src/dialects/index.js create mode 100644 src/dialects/mssql.js create mode 100644 src/dialects/mysql.js create mode 100644 src/dialects/pg.js create mode 100644 src/dialects/sqlite.js diff --git a/spec/SqlDialects.spec.js b/spec/SqlDialects.spec.js new file mode 100644 index 0000000..1a8d0ea --- /dev/null +++ b/spec/SqlDialects.spec.js @@ -0,0 +1,539 @@ +import { QueryExpression, QueryEntity, QueryField } from '../src/index'; +import { SqliteDialect } from '../src/dialects/sqlite'; +import { MySQLDialect } from '../src/dialects/mysql'; +import { MSSQLDialect } from '../src/dialects/mssql'; +import { PostgreSQLDialect } from '../src/dialects/pg'; + +describe('SqlDialects', () => { + + describe('SqliteDialect', () => { + it('should format a SELECT statement with LIMIT/OFFSET', () => { + const Products = new QueryEntity('ProductData'); + const query = new QueryExpression().select('id', 'name').from(Products).take(10).skip(20); + const formatter = new SqliteDialect(); + const sql = formatter.formatLimitSelect(query); + expect(sql).toContain('FROM `ProductData`'); + expect(sql).toContain('LIMIT 20, 10'); + }); + + it('should escape identifiers with backticks', () => { + const formatter = new SqliteDialect(); + expect(formatter.escapeName('name')).toBe('`name`'); + }); + + it('should escape boolean as 1/0', () => { + const formatter = new SqliteDialect(); + expect(formatter.escape(true)).toBe('1'); + expect(formatter.escape(false)).toBe('0'); + }); + + it('should format $toString', () => { + const formatter = new SqliteDialect(); + const result = formatter.$toString({ $name: 'price' }); + expect(result).toBe('CAST(`price` AS TEXT)'); + }); + + it('should format $toInt', () => { + const formatter = new SqliteDialect(); + const result = formatter.$toInt({ $name: 'price' }); + expect(result).toBe('CAST(`price` AS INT)'); + }); + + it('should format $toLong', () => { + const formatter = new SqliteDialect(); + const result = formatter.$toLong({ $name: 'id' }); + expect(result).toBe('CAST(`id` AS BIGINT)'); + }); + + it('should format $toDouble', () => { + const formatter = new SqliteDialect(); + const result = formatter.$toDouble({ $name: 'price' }); + expect(result).toBe('CAST(`price` as DECIMAL(19,8))'); + }); + + it('should format $toDecimal', () => { + const formatter = new SqliteDialect(); + const result = formatter.$toDecimal({ $name: 'price' }, 10, 2); + expect(result).toBe('CAST(`price` as DECIMAL(10,2))'); + }); + + it('should format $toBoolean', () => { + const formatter = new SqliteDialect(); + const result = formatter.$toBoolean({ $name: 'active' }); + expect(result).toBe('CAST(`active` AS INTEGER)'); + }); + + it('should format $concat', () => { + const formatter = new SqliteDialect(); + const result = formatter.$concat({ $name: 'firstName' }, ' ', { $name: 'lastName' }); + expect(result).toContain('||'); + }); + + it('should format $length', () => { + const formatter = new SqliteDialect(); + const result = formatter.$length({ $name: 'name' }); + expect(result).toBe('LENGTH(`name`)'); + }); + + it('should format $substring', () => { + const formatter = new SqliteDialect(); + const result = formatter.$substring({ $name: 'name' }, 0, 5); + expect(result).toBe('SUBSTR(`name`,0 + 1,5)'); + }); + + it('should format $indexOf', () => { + const formatter = new SqliteDialect(); + const result = formatter.$indexOf({ $name: 'name' }, 'John'); + expect(result).toBe('(INSTR(`name`,\'John\')-1)'); + }); + + it('should format $startsWith', () => { + const formatter = new SqliteDialect(); + const result = formatter.$startsWith({ $name: 'name' }, 'John'); + expect(result).toBe('LIKE(\'John%\',`name`)'); + }); + + it('should format $endsWith', () => { + const formatter = new SqliteDialect(); + const result = formatter.$endsWith({ $name: 'name' }, 'son'); + expect(result).toBe('LIKE(\'%son\',`name`)'); + }); + + it('should format $contains', () => { + const formatter = new SqliteDialect(); + const result = formatter.$contains({ $name: 'name' }, 'ohn'); + expect(result).toBe('LIKE(\'%ohn%\',`name`)'); + }); + + it('should format $year', () => { + const formatter = new SqliteDialect(); + const result = formatter.$year({ $name: 'createdAt' }); + expect(result).toBe('CAST(strftime(\'%Y\', `createdAt`) AS INTEGER)'); + }); + + it('should format $month', () => { + const formatter = new SqliteDialect(); + const result = formatter.$month({ $name: 'createdAt' }); + expect(result).toBe('CAST(strftime(\'%m\', `createdAt`) AS INTEGER)'); + }); + + it('should format $day', () => { + const formatter = new SqliteDialect(); + const result = formatter.$day({ $name: 'createdAt' }); + expect(result).toBe('CAST(strftime(\'%d\', `createdAt`) AS INTEGER)'); + }); + + it('should format $ifNull', () => { + const formatter = new SqliteDialect(); + const result = formatter.$ifNull({ $name: 'description' }, ''); + expect(result).toBe('IFNULL(`description`, \'\')'); + }); + + it('should format $ceiling', () => { + const formatter = new SqliteDialect(); + const result = formatter.$ceiling({ $name: 'price' }); + expect(result).toBe('CEIL(`price`)'); + }); + + it('should format $uuid', () => { + const formatter = new SqliteDialect(); + expect(formatter.$uuid()).toBe('uuid4()'); + }); + + it('should format a SELECT statement', () => { + const Products = new QueryEntity('ProductData'); + const query = new QueryExpression() + .select('id', 'name', 'price') + .from(Products); + const formatter = new SqliteDialect(); + const sql = formatter.formatSelect(query); + expect(sql).toContain('SELECT'); + expect(sql).toContain('`ProductData`'); + }); + + it('should be exported from main index', () => { + const { SqliteDialect: dialect } = require('../src/index'); + expect(dialect).toBeDefined(); + const instance = new dialect(); + expect(instance).toBeInstanceOf(SqliteDialect); + }); + }); + + describe('MySQLDialect', () => { + it('should format a SELECT statement with LIMIT/OFFSET', () => { + const Products = new QueryEntity('ProductData'); + const query = new QueryExpression().select('id', 'name').from(Products).take(10).skip(20); + const formatter = new MySQLDialect(); + const sql = formatter.formatLimitSelect(query); + expect(sql).toContain('FROM `ProductData`'); + expect(sql).toContain('LIMIT 10 OFFSET 20'); + }); + + it('should escape identifiers with backticks', () => { + const formatter = new MySQLDialect(); + expect(formatter.escapeName('name')).toBe('`name`'); + }); + + it('should escape boolean as 1/0', () => { + const formatter = new MySQLDialect(); + expect(formatter.escape(true)).toBe('1'); + expect(formatter.escape(false)).toBe('0'); + }); + + it('should format $toString', () => { + const formatter = new MySQLDialect(); + const result = formatter.$toString({ $name: 'price' }); + expect(result).toBe('CAST(`price` AS CHAR)'); + }); + + it('should format $toInt', () => { + const formatter = new MySQLDialect(); + const result = formatter.$toInt({ $name: 'price' }); + expect(result).toBe('CAST(`price` AS SIGNED INT)'); + }); + + it('should format $toLong', () => { + const formatter = new MySQLDialect(); + const result = formatter.$toLong({ $name: 'id' }); + expect(result).toBe('CAST(`id` AS SIGNED)'); + }); + + it('should format $toDecimal', () => { + const formatter = new MySQLDialect(); + const result = formatter.$toDecimal({ $name: 'price' }, 10, 2); + expect(result).toBe('CAST(`price` AS DECIMAL(10,2))'); + }); + + it('should format $toBoolean', () => { + const formatter = new MySQLDialect(); + const result = formatter.$toBoolean({ $name: 'active' }); + expect(result).toBe('CAST(`active` AS UNSIGNED)'); + }); + + it('should format $concat', () => { + const formatter = new MySQLDialect(); + const result = formatter.$concat({ $name: 'firstName' }, ' ', { $name: 'lastName' }); + expect(result).toContain('CONCAT('); + }); + + it('should format $indexOf', () => { + const formatter = new MySQLDialect(); + const result = formatter.$indexOf({ $name: 'name' }, 'John'); + expect(result).toBe('(INSTR(`name`,\'John\')-1)'); + }); + + it('should format $year', () => { + const formatter = new MySQLDialect(); + const result = formatter.$year({ $name: 'createdAt' }); + expect(result).toBe('YEAR(`createdAt`)'); + }); + + it('should format $month', () => { + const formatter = new MySQLDialect(); + const result = formatter.$month({ $name: 'createdAt' }); + expect(result).toBe('MONTH(`createdAt`)'); + }); + + it('should format $day', () => { + const formatter = new MySQLDialect(); + const result = formatter.$day({ $name: 'createdAt' }); + expect(result).toBe('DAY(`createdAt`)'); + }); + + it('should format $ifNull', () => { + const formatter = new MySQLDialect(); + const result = formatter.$ifNull({ $name: 'description' }, ''); + expect(result).toBe('IFNULL(`description`, \'\')'); + }); + + it('should format $uuid', () => { + const formatter = new MySQLDialect(); + expect(formatter.$uuid()).toBe('UUID()'); + }); + + it('should be exported from main index', () => { + const { MySQLDialect: dialect } = require('../src/index'); + expect(dialect).toBeDefined(); + const instance = new dialect(); + expect(instance).toBeInstanceOf(MySQLDialect); + }); + }); + + describe('MSSQLDialect', () => { + it('should format a SELECT statement with OFFSET/FETCH', () => { + const Products = new QueryEntity('ProductData'); + const query = new QueryExpression().select('id', 'name').from(Products).take(10).skip(20); + const formatter = new MSSQLDialect(); + const sql = formatter.formatLimitSelect(query); + expect(sql).toContain('OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY'); + }); + + it('should format a SELECT statement with FETCH NEXT (no skip)', () => { + const Products = new QueryEntity('ProductData'); + const query = new QueryExpression().select('id', 'name').from(Products).take(10); + const formatter = new MSSQLDialect(); + const sql = formatter.formatLimitSelect(query); + expect(sql).toContain('OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY'); + }); + + it('should escape identifiers with brackets', () => { + const formatter = new MSSQLDialect(); + expect(formatter.escapeName('name')).toBe('[name]'); + }); + + it('should escape boolean as 1/0', () => { + const formatter = new MSSQLDialect(); + expect(formatter.escape(true)).toBe('1'); + expect(formatter.escape(false)).toBe('0'); + }); + + it('should format $toString', () => { + const formatter = new MSSQLDialect(); + const result = formatter.$toString({ $name: 'price' }); + expect(result).toBe('CAST([price] AS NVARCHAR(MAX))'); + }); + + it('should format $toInt', () => { + const formatter = new MSSQLDialect(); + const result = formatter.$toInt({ $name: 'price' }); + expect(result).toBe('CAST([price] AS INT)'); + }); + + it('should format $toLong', () => { + const formatter = new MSSQLDialect(); + const result = formatter.$toLong({ $name: 'id' }); + expect(result).toBe('CAST([id] AS BIGINT)'); + }); + + it('should format $toDecimal', () => { + const formatter = new MSSQLDialect(); + const result = formatter.$toDecimal({ $name: 'price' }, 10, 2); + expect(result).toBe('CAST([price] AS DECIMAL(10,2))'); + }); + + it('should format $toBoolean', () => { + const formatter = new MSSQLDialect(); + const result = formatter.$toBoolean({ $name: 'active' }); + expect(result).toBe('CAST([active] AS BIT)'); + }); + + it('should format $concat', () => { + const formatter = new MSSQLDialect(); + const result = formatter.$concat({ $name: 'firstName' }, ' ', { $name: 'lastName' }); + expect(result).toContain('CONCAT('); + }); + + it('should format $indexOf', () => { + const formatter = new MSSQLDialect(); + const result = formatter.$indexOf({ $name: 'name' }, 'John'); + expect(result).toBe('(CHARINDEX(\'John\',[name])-1)'); + }); + + it('should format $length', () => { + const formatter = new MSSQLDialect(); + const result = formatter.$length({ $name: 'name' }); + expect(result).toBe('LEN([name])'); + }); + + it('should format $year', () => { + const formatter = new MSSQLDialect(); + const result = formatter.$year({ $name: 'createdAt' }); + expect(result).toBe('YEAR([createdAt])'); + }); + + it('should format $month', () => { + const formatter = new MSSQLDialect(); + const result = formatter.$month({ $name: 'createdAt' }); + expect(result).toBe('MONTH([createdAt])'); + }); + + it('should format $day', () => { + const formatter = new MSSQLDialect(); + const result = formatter.$day({ $name: 'createdAt' }); + expect(result).toBe('DAY([createdAt])'); + }); + + it('should format $hour', () => { + const formatter = new MSSQLDialect(); + const result = formatter.$hour({ $name: 'createdAt' }); + expect(result).toBe('DATEPART(HOUR, [createdAt])'); + }); + + it('should format $ifNull', () => { + const formatter = new MSSQLDialect(); + const result = formatter.$ifNull({ $name: 'description' }, ''); + expect(result).toBe('ISNULL([description], \'\')'); + }); + + it('should format $uuid', () => { + const formatter = new MSSQLDialect(); + expect(formatter.$uuid()).toBe('NEWID()'); + }); + + it('should be exported from main index', () => { + const { MSSQLDialect: dialect } = require('../src/index'); + expect(dialect).toBeDefined(); + const instance = new dialect(); + expect(instance).toBeInstanceOf(MSSQLDialect); + }); + }); + + describe('PostgreSQLDialect', () => { + it('should format a SELECT statement with LIMIT/OFFSET', () => { + const Products = new QueryEntity('ProductData'); + const query = new QueryExpression().select('id', 'name').from(Products).take(10).skip(20); + const formatter = new PostgreSQLDialect(); + const sql = formatter.formatLimitSelect(query); + expect(sql).toContain('FROM "ProductData"'); + expect(sql).toContain('LIMIT 10 OFFSET 20'); + }); + + it('should escape identifiers with double quotes', () => { + const formatter = new PostgreSQLDialect(); + expect(formatter.escapeName('name')).toBe('"name"'); + }); + + it('should escape boolean as TRUE/FALSE', () => { + const formatter = new PostgreSQLDialect(); + expect(formatter.escape(true)).toBe('TRUE'); + expect(formatter.escape(false)).toBe('FALSE'); + }); + + it('should format $toString', () => { + const formatter = new PostgreSQLDialect(); + const result = formatter.$toString({ $name: 'price' }); + expect(result).toBe('CAST("price" AS TEXT)'); + }); + + it('should format $toInt', () => { + const formatter = new PostgreSQLDialect(); + const result = formatter.$toInt({ $name: 'price' }); + expect(result).toBe('CAST("price" AS INTEGER)'); + }); + + it('should format $toLong', () => { + const formatter = new PostgreSQLDialect(); + const result = formatter.$toLong({ $name: 'id' }); + expect(result).toBe('CAST("id" AS BIGINT)'); + }); + + it('should format $toDouble', () => { + const formatter = new PostgreSQLDialect(); + const result = formatter.$toDouble({ $name: 'price' }); + expect(result).toBe('CAST("price" AS FLOAT)'); + }); + + it('should format $toDecimal', () => { + const formatter = new PostgreSQLDialect(); + const result = formatter.$toDecimal({ $name: 'price' }, 10, 2); + expect(result).toBe('CAST("price" AS DECIMAL(10,2))'); + }); + + it('should format $toBoolean', () => { + const formatter = new PostgreSQLDialect(); + const result = formatter.$toBoolean({ $name: 'active' }); + expect(result).toBe('CAST("active" AS BOOLEAN)'); + }); + + it('should format $concat', () => { + const formatter = new PostgreSQLDialect(); + const result = formatter.$concat({ $name: 'firstName' }, ' ', { $name: 'lastName' }); + expect(result).toContain('CONCAT('); + }); + + it('should format $indexOf', () => { + const formatter = new PostgreSQLDialect(); + const result = formatter.$indexOf({ $name: 'name' }, 'John'); + expect(result).toBe('(STRPOS("name",\'John\')-1)'); + }); + + it('should format $year', () => { + const formatter = new PostgreSQLDialect(); + const result = formatter.$year({ $name: 'createdAt' }); + expect(result).toBe('EXTRACT(YEAR FROM "createdAt")'); + }); + + it('should format $month', () => { + const formatter = new PostgreSQLDialect(); + const result = formatter.$month({ $name: 'createdAt' }); + expect(result).toBe('EXTRACT(MONTH FROM "createdAt")'); + }); + + it('should format $day', () => { + const formatter = new PostgreSQLDialect(); + const result = formatter.$day({ $name: 'createdAt' }); + expect(result).toBe('EXTRACT(DAY FROM "createdAt")'); + }); + + it('should format $ifNull', () => { + const formatter = new PostgreSQLDialect(); + const result = formatter.$ifNull({ $name: 'description' }, ''); + expect(result).toBe('COALESCE("description", \'\')'); + }); + + it('should format $regex', () => { + const formatter = new PostgreSQLDialect(); + const result = formatter.$regex({ $name: 'name' }, 'John'); + expect(result).toContain('~'); + }); + + it('should format $uuid', () => { + const formatter = new PostgreSQLDialect(); + expect(formatter.$uuid()).toBe('gen_random_uuid()'); + }); + + it('should format $toDate', () => { + const formatter = new PostgreSQLDialect(); + expect(formatter.$toDate({ $name: 'createdAt' }, 'date')).toBe('CAST("createdAt" AS DATE)'); + expect(formatter.$toDate({ $name: 'createdAt' }, 'datetime')).toBe('CAST("createdAt" AS TIMESTAMP)'); + expect(formatter.$toDate({ $name: 'createdAt' }, 'timestamp')).toBe('CAST("createdAt" AS TIMESTAMPTZ)'); + }); + + it('should be exported from main index', () => { + const { PostgreSQLDialect: dialect } = require('../src/index'); + expect(dialect).toBeDefined(); + const instance = new dialect(); + expect(instance).toBeInstanceOf(PostgreSQLDialect); + }); + }); + + describe('Dialect common features', () => { + it('should all dialects extend SqlFormatter', () => { + const { SqlFormatter } = require('../src/index'); + expect(new SqliteDialect()).toBeInstanceOf(SqlFormatter); + expect(new MySQLDialect()).toBeInstanceOf(SqlFormatter); + expect(new MSSQLDialect()).toBeInstanceOf(SqlFormatter); + expect(new PostgreSQLDialect()).toBeInstanceOf(SqlFormatter); + }); + + it('should all dialects format a basic SELECT', () => { + const Products = new QueryEntity('ProductData'); + const query = new QueryExpression().select('id', 'name').from(Products); + const dialects = [ + new SqliteDialect(), + new MySQLDialect(), + new MSSQLDialect(), + new PostgreSQLDialect() + ]; + for (const formatter of dialects) { + const sql = formatter.formatSelect(query); + expect(sql).toContain('SELECT'); + expect(sql).toContain('ProductData'); + } + }); + + it('should all dialects use $cast correctly', () => { + const dialects = [ + new SqliteDialect(), + new MySQLDialect(), + new MSSQLDialect(), + new PostgreSQLDialect() + ]; + for (const formatter of dialects) { + const result = formatter.$cast({ $name: 'price' }, 'string'); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + } + }); + }); +}); diff --git a/spec/test/TestMemoryFormatter.js b/spec/test/TestMemoryFormatter.js index 39d7048..32dd7e5 100644 --- a/spec/test/TestMemoryFormatter.js +++ b/spec/test/TestMemoryFormatter.js @@ -1,29 +1,11 @@ -import { SqliteFormatter } from '@themost/sqlite'; - -Object.assign(SqliteFormatter.prototype, { - $toString(arg) { - return `CAST(${this.escape(arg)} AS TEXT)`; - }, - $toDouble(arg) { - return `CAST(${this.escape(arg)} AS NUMERIC)`; - }, - $toDecimal(arg) { - return `CAST(${this.escape(arg)} AS NUMERIC)`; - }, - $toInt(arg) { - return `CAST(${this.escape(arg)} AS INTEGER)`; - }, - $toLong(arg) { - return `CAST(${this.escape(arg)} AS INTEGER)`; - } -}); +import { SqliteDialect } from '../../src/dialects/sqlite'; // noinspection JSUnusedGlobalSymbols /** - * @augments {SqlFormatter} + * @augments {SqliteDialect} */ -class MemoryFormatter extends SqliteFormatter { +class MemoryFormatter extends SqliteDialect { /** * @constructor */ diff --git a/src/dialects/index.js b/src/dialects/index.js new file mode 100644 index 0000000..605fe60 --- /dev/null +++ b/src/dialects/index.js @@ -0,0 +1,5 @@ +// MOST Web Framework Codename Zero Gravity Copyright (c) 2017-2022, THEMOST LP All rights reserved +export { SqliteDialect } from './sqlite'; +export { MySQLDialect } from './mysql'; +export { MSSQLDialect } from './mssql'; +export { PostgreSQLDialect } from './pg'; diff --git a/src/dialects/mssql.js b/src/dialects/mssql.js new file mode 100644 index 0000000..7beea6a --- /dev/null +++ b/src/dialects/mssql.js @@ -0,0 +1,361 @@ +// MOST Web Framework Codename Zero Gravity Copyright (c) 2017-2022, THEMOST LP All rights reserved +import { sprintf } from 'sprintf-js'; +import { SqlFormatter } from '../formatter'; + +function zeroPad(number, length) { + number = number || 0; + let res = number.toString(); + while (res.length < length) { + res = '0' + res; + } + return res; +} + +/** + * Represents the Microsoft SQL Server SQL dialect formatter. + * @class MSSQLDialect + * @augments {SqlFormatter} + */ +class MSSQLDialect extends SqlFormatter { + + static get NAME_FORMAT() { + return '[$1]'; + } + + /** + * @constructor + */ + constructor() { + super(); + this.settings = { + nameFormat: MSSQLDialect.NAME_FORMAT, + forceAlias: true + }; + } + + /** + * Escapes an object or a value and returns the equivalent sql value. + * @param {*} value - A value that is going to be escaped for SQL statements + * @param {boolean=} unquoted - An optional value that indicates whether the resulted string will be quoted or not. + * @returns {string} - The equivalent SQL string value + */ + escape(value, unquoted) { + if (typeof value === 'boolean') { + return value ? '1' : '0'; + } + if (value instanceof Date) { + return this.escapeDate(value); + } + let res = super.escape.bind(this)(value, unquoted); + if (typeof value === 'string') { + // escape single quotes + res = res.replace(/\\'/g, '\'\''); + } + return res; + } + + /** + * @param {Date|*} val + * @returns {string} + */ + escapeDate(val) { + const year = val.getFullYear(); + const month = zeroPad(val.getMonth() + 1, 2); + const day = zeroPad(val.getDate(), 2); + const hour = zeroPad(val.getHours(), 2); + const minute = zeroPad(val.getMinutes(), 2); + const second = zeroPad(val.getSeconds(), 2); + const millisecond = zeroPad(val.getMilliseconds(), 3); + return '\'' + year + '-' + month + '-' + day + 'T' + hour + ':' + minute + ':' + second + '.' + millisecond + '\''; + } + + /** + * Formats a SELECT statement with OFFSET/FETCH. + * MSSQL uses OFFSET...FETCH NEXT...ROWS ONLY syntax for pagination. + * An ORDER BY clause is required for OFFSET/FETCH to work properly. + * @param {import('../query').QueryExpression} obj + * @returns {string} + */ + formatLimitSelect(obj) { + let sql = this.formatSelect(obj); + if (obj.$take) { + const skip = obj.$skip || 0; + sql = sql.concat(' OFFSET ', skip.toString(), ' ROWS FETCH NEXT ', obj.$take.toString(), ' ROWS ONLY'); + } + return sql; + } + + /** + * Implements indexOf(str,substr) expression formatter. + * @param {string} p0 The source string + * @param {string} p1 The string to search for + * @returns {string} + */ + $indexof(p0, p1) { + return sprintf('(CHARINDEX(%s,%s)-1)', this.escape(p1), this.escape(p0)); + } + + /** + * Implements indexOf(str,substr) expression formatter. + * @param {string} p0 The source string + * @param {string} p1 The string to search for + * @returns {string} + */ + $indexOf(p0, p1) { + return sprintf('(CHARINDEX(%s,%s)-1)', this.escape(p1), this.escape(p0)); + } + + /** + * Implements contains(a,b) expression formatter. + * @param {*} p0 The source string + * @param {*} p1 The string to search for + * @returns {string} + */ + $text(p0, p1) { + return sprintf('(CHARINDEX(%s,%s)-1)>=0', this.escape(p1), this.escape(p0)); + } + + /** + * Implements simple regular expression formatter. + * MSSQL does not support REGEXP natively; uses LIKE as fallback. + * @param {*} p0 The source string or field + * @param {*} p1 The string to search for + * @returns {string} + */ + $regex(p0, p1) { + //escape expression + let s1 = this.escape(p1, true); + //implement starts with equivalent for LIKE T-SQL + if (/^\^/.test(s1)) { + s1 = s1.replace(/^\^/, ''); + } else { + s1 = '%' + s1; + } + //implement ends with equivalent for LIKE T-SQL + if (/\$$/.test(s1)) { + s1 = s1.replace(/\$$/, ''); + } else { + s1 += '%'; + } + return sprintf('(%s LIKE \'%s\')', this.escape(p0), s1); + } + + /** + * Implements concat(a,b) expression formatter. + * @param {...*} arg + * @returns {string} + */ + // eslint-disable-next-line no-unused-vars + $concat(arg) { + const args = Array.from(arguments); + if (args.length < 2) { + throw new Error('Concat method expects two or more arguments'); + } + return sprintf('CONCAT(%s)', args.map((a) => this.escape(a)).join(', ')); + } + + /** + * Implements substring(str,pos) expression formatter. + * @param {String} p0 The source string + * @param {Number} pos The starting position + * @param {Number=} length The length of the resulted string + * @returns {string} + */ + $substring(p0, pos, length) { + if (length) { + return sprintf('SUBSTRING(%s,%s + 1,%s)', this.escape(p0), this.escape(pos), this.escape(length)); + } else { + return sprintf('SUBSTRING(%s,%s + 1,LEN(%s))', this.escape(p0), this.escape(pos), this.escape(p0)); + } + } + + /** + * Implements substring(str,pos) expression formatter. + * @param {String} p0 The source string + * @param {Number} pos The starting position + * @param {Number=} length The length of the resulted string + * @returns {string} + */ + $substr(p0, pos, length) { + return this.$substring(p0, pos, length); + } + + /** + * Implements length(a) expression formatter. + * @param {*} p0 + * @returns {string} + */ + $length(p0) { + return sprintf('LEN(%s)', this.escape(p0)); + } + + $ceiling(p0) { + return sprintf('CEILING(%s)', this.escape(p0)); + } + + $startswith(p0, p1) { + return this.$startsWith(p0, p1); + } + + $startsWith(p0, p1) { + if (p0 == null || p1 == null) + return ''; + return sprintf('(%s LIKE \'%s\')', this.escape(p0), this.escape(p1, true) + '%'); + } + + $endswith(p0, p1) { + return this.$endsWith(p0, p1); + } + + $endsWith(p0, p1) { + if (p0 == null || p1 == null) + return ''; + return sprintf('(%s LIKE \'%s\')', this.escape(p0), '%' + this.escape(p1, true)); + } + + $contains(p0, p1) { + if (p0 == null || p1 == null) + return ''; + return sprintf('(%s LIKE \'%s\')', this.escape(p0), '%' + this.escape(p1, true) + '%'); + } + + $day(p0) { + return sprintf('DAY(%s)', this.escape(p0)); + } + + $dayOfMonth(p0) { + return sprintf('DAY(%s)', this.escape(p0)); + } + + $month(p0) { + return sprintf('MONTH(%s)', this.escape(p0)); + } + + $year(p0) { + return sprintf('YEAR(%s)', this.escape(p0)); + } + + $hour(p0) { + return sprintf('DATEPART(HOUR, %s)', this.escape(p0)); + } + + $hours(p0) { + return this.$hour(p0); + } + + $minute(p0) { + return sprintf('DATEPART(MINUTE, %s)', this.escape(p0)); + } + + $minutes(p0) { + return this.$minute(p0); + } + + $second(p0) { + return sprintf('DATEPART(SECOND, %s)', this.escape(p0)); + } + + $seconds(p0) { + return this.$second(p0); + } + + $date(p0) { + return sprintf('CAST(%s AS DATE)', this.escape(p0)); + } + + /** + * @param {*} p0 + * @param {*} p1 + * @returns {string} + */ + $ifnull(p0, p1) { + return sprintf('ISNULL(%s, %s)', this.escape(p0), this.escape(p1)); + } + + /** + * @param {*} p0 + * @param {*} p1 + * @returns {string} + */ + $ifNull(p0, p1) { + return sprintf('ISNULL(%s, %s)', this.escape(p0), this.escape(p1)); + } + + $toString(expr) { + return sprintf('CAST(%s AS NVARCHAR(MAX))', this.escape(expr)); + } + + $toInt(expr) { + return sprintf('CAST(%s AS INT)', this.escape(expr)); + } + + $toDouble(expr) { + return this.$toDecimal(expr, 19, 8); + } + + /** + * @param {*} expr + * @param {number=} precision + * @param {number=} scale + * @returns {string} + */ + $toDecimal(expr, precision, scale) { + const p = typeof precision === 'number' ? Math.floor(precision) : 19; + const s = typeof scale === 'number' ? Math.floor(scale) : 8; + return sprintf('CAST(%s AS DECIMAL(%s,%s))', this.escape(expr), p, s); + } + + $toLong(expr) { + return sprintf('CAST(%s AS BIGINT)', this.escape(expr)); + } + + $toBoolean(expr) { + return sprintf('CAST(%s AS BIT)', this.escape(expr)); + } + + /** + * @param {*} expr + * @param {('date'|'datetime'|'timestamp')} type + * @returns {string} + */ + $toDate(expr, type) { + switch (type) { + case 'date': + return sprintf('CAST(%s AS DATE)', this.escape(expr)); + case 'datetime': + case 'timestamp': + return sprintf('CAST(%s AS DATETIME)', this.escape(expr)); + default: + return sprintf('CAST(%s AS DATE)', this.escape(expr)); + } + } + + $toGuid(expr) { + return sprintf('CONVERT(UNIQUEIDENTIFIER, HASHBYTES(\'MD5\', %s))', this.escape(expr)); + } + + $uuid() { + return 'NEWID()'; + } + + /** + * @param {('date'|'datetime'|'timestamp')} type + * @returns {string} + */ + $getDate(type) { + switch (type) { + case 'date': + return 'CAST(GETUTCDATE() AS DATE)'; + case 'datetime': + return 'GETUTCDATE()'; + case 'timestamp': + return 'GETUTCDATE()'; + default: + return 'GETUTCDATE()'; + } + } +} + +export { + MSSQLDialect +}; diff --git a/src/dialects/mysql.js b/src/dialects/mysql.js new file mode 100644 index 0000000..538811a --- /dev/null +++ b/src/dialects/mysql.js @@ -0,0 +1,358 @@ +// MOST Web Framework Codename Zero Gravity Copyright (c) 2017-2022, THEMOST LP All rights reserved +import { sprintf } from 'sprintf-js'; +import { SqlFormatter } from '../formatter'; + +const REGEXP_SINGLE_QUOTE = /\\'/g; +const SINGLE_QUOTE_ESCAPE = '\\\''; +const REGEXP_DOUBLE_QUOTE = /\\"/g; +const DOUBLE_QUOTE_ESCAPE = '\\"'; +const REGEXP_SLASH = /\\\\/g; +const SLASH_ESCAPE = '\\\\'; + +function zeroPad(number, length) { + number = number || 0; + let res = number.toString(); + while (res.length < length) { + res = '0' + res; + } + return res; +} + +/** + * Represents the MySQL SQL dialect formatter. + * @class MySQLDialect + * @augments {SqlFormatter} + */ +class MySQLDialect extends SqlFormatter { + + static get NAME_FORMAT() { + return '`$1`'; + } + + /** + * @constructor + */ + constructor() { + super(); + this.settings = { + nameFormat: MySQLDialect.NAME_FORMAT, + forceAlias: true + }; + } + + /** + * Escapes an object or a value and returns the equivalent sql value. + * @param {*} value - A value that is going to be escaped for SQL statements + * @param {boolean=} unquoted - An optional value that indicates whether the resulted string will be quoted or not. + * @returns {string} - The equivalent SQL string value + */ + escape(value, unquoted) { + if (typeof value === 'boolean') { + return value ? '1' : '0'; + } + if (value instanceof Date) { + return this.escapeDate(value); + } + let res = super.escape.bind(this)(value, unquoted); + if (typeof value === 'string') { + if (REGEXP_SINGLE_QUOTE.test(res)) + res = res.replace(/\\'/g, SINGLE_QUOTE_ESCAPE); + if (REGEXP_DOUBLE_QUOTE.test(res)) + res = res.replace(/\\"/g, DOUBLE_QUOTE_ESCAPE); + if (REGEXP_SLASH.test(res)) + res = res.replace(/\\\\/g, SLASH_ESCAPE); + } + return res; + } + + /** + * @param {Date|*} val + * @returns {string} + */ + escapeDate(val) { + const year = val.getFullYear(); + const month = zeroPad(val.getMonth() + 1, 2); + const day = zeroPad(val.getDate(), 2); + const hour = zeroPad(val.getHours(), 2); + const minute = zeroPad(val.getMinutes(), 2); + const second = zeroPad(val.getSeconds(), 2); + const millisecond = zeroPad(val.getMilliseconds(), 3); + return '\'' + year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second + '.' + millisecond + '\''; + } + + /** + * Formats a SELECT statement with LIMIT/OFFSET. + * @param {import('../query').QueryExpression} obj + * @returns {string} + */ + formatLimitSelect(obj) { + let sql = this.formatSelect(obj); + if (obj.$take) { + if (obj.$skip) { + sql = sql.concat(' LIMIT ', obj.$take.toString(), ' OFFSET ', obj.$skip.toString()); + } else { + sql = sql.concat(' LIMIT ', obj.$take.toString()); + } + } + return sql; + } + + /** + * Implements indexOf(str,substr) expression formatter. + * @param {string} p0 The source string + * @param {string} p1 The string to search for + * @returns {string} + */ + $indexof(p0, p1) { + return sprintf('(INSTR(%s,%s)-1)', this.escape(p0), this.escape(p1)); + } + + /** + * Implements indexOf(str,substr) expression formatter. + * @param {string} p0 The source string + * @param {string} p1 The string to search for + * @returns {string} + */ + $indexOf(p0, p1) { + return sprintf('(INSTR(%s,%s)-1)', this.escape(p0), this.escape(p1)); + } + + /** + * Implements contains(a,b) expression formatter. + * @param {*} p0 The source string + * @param {*} p1 The string to search for + * @returns {string} + */ + $text(p0, p1) { + return sprintf('(INSTR(%s,%s)-1)>=0', this.escape(p0), this.escape(p1)); + } + + /** + * Implements simple regular expression formatter. + * @param {*} p0 The source string or field + * @param {*} p1 The regular expression pattern + * @returns {string} + */ + $regex(p0, p1) { + return sprintf('(%s REGEXP %s)', this.escape(p0), this.escape(p1, true)); + } + + /** + * Implements concat(a,b) expression formatter. + * @param {...*} arg + * @returns {string} + */ + // eslint-disable-next-line no-unused-vars + $concat(arg) { + const args = Array.from(arguments); + if (args.length < 2) { + throw new Error('Concat method expects two or more arguments'); + } + return sprintf('CONCAT(%s)', args.map((a) => this.escape(a)).join(', ')); + } + + /** + * Implements substring(str,pos) expression formatter. + * @param {String} p0 The source string + * @param {Number} pos The starting position + * @param {Number=} length The length of the resulted string + * @returns {string} + */ + $substring(p0, pos, length) { + if (length) { + return sprintf('SUBSTRING(%s,%s + 1,%s)', this.escape(p0), this.escape(pos), this.escape(length)); + } else { + return sprintf('SUBSTRING(%s,%s + 1)', this.escape(p0), this.escape(pos)); + } + } + + /** + * Implements substring(str,pos) expression formatter. + * @param {String} p0 The source string + * @param {Number} pos The starting position + * @param {Number=} length The length of the resulted string + * @returns {string} + */ + $substr(p0, pos, length) { + return this.$substring(p0, pos, length); + } + + /** + * Implements length(a) expression formatter. + * @param {*} p0 + * @returns {string} + */ + $length(p0) { + return sprintf('LENGTH(%s)', this.escape(p0)); + } + + $ceiling(p0) { + return sprintf('CEILING(%s)', this.escape(p0)); + } + + $startswith(p0, p1) { + return this.$startsWith(p0, p1); + } + + $startsWith(p0, p1) { + if (p0 == null || p1 == null) + return ''; + return sprintf('(%s LIKE %s)', this.escape(p0), this.escape(this.escape(p1, true) + '%', true)); + } + + $endswith(p0, p1) { + return this.$endsWith(p0, p1); + } + + $endsWith(p0, p1) { + if (p0 == null || p1 == null) + return ''; + return sprintf('(%s LIKE %s)', this.escape(p0), this.escape('%' + this.escape(p1, true), true)); + } + + $contains(p0, p1) { + if (p0 == null || p1 == null) + return ''; + return sprintf('(%s LIKE %s)', this.escape(p0), this.escape('%' + this.escape(p1, true) + '%', true)); + } + + $day(p0) { + return sprintf('DAY(%s)', this.escape(p0)); + } + + $dayOfMonth(p0) { + return sprintf('DAY(%s)', this.escape(p0)); + } + + $month(p0) { + return sprintf('MONTH(%s)', this.escape(p0)); + } + + $year(p0) { + return sprintf('YEAR(%s)', this.escape(p0)); + } + + $hour(p0) { + return sprintf('HOUR(%s)', this.escape(p0)); + } + + $hours(p0) { + return this.$hour(p0); + } + + $minute(p0) { + return sprintf('MINUTE(%s)', this.escape(p0)); + } + + $minutes(p0) { + return this.$minute(p0); + } + + $second(p0) { + return sprintf('SECOND(%s)', this.escape(p0)); + } + + $seconds(p0) { + return this.$second(p0); + } + + $date(p0) { + return sprintf('DATE(%s)', this.escape(p0)); + } + + /** + * @param {*} p0 + * @param {*} p1 + * @returns {string} + */ + $ifnull(p0, p1) { + return sprintf('IFNULL(%s, %s)', this.escape(p0), this.escape(p1)); + } + + /** + * @param {*} p0 + * @param {*} p1 + * @returns {string} + */ + $ifNull(p0, p1) { + return sprintf('IFNULL(%s, %s)', this.escape(p0), this.escape(p1)); + } + + $toString(expr) { + return sprintf('CAST(%s AS CHAR)', this.escape(expr)); + } + + $toInt(expr) { + return sprintf('CAST(%s AS SIGNED INT)', this.escape(expr)); + } + + $toDouble(expr) { + return this.$toDecimal(expr, 19, 8); + } + + /** + * @param {*} expr + * @param {number=} precision + * @param {number=} scale + * @returns {string} + */ + $toDecimal(expr, precision, scale) { + const p = typeof precision === 'number' ? Math.floor(precision) : 19; + const s = typeof scale === 'number' ? Math.floor(scale) : 8; + return sprintf('CAST(%s AS DECIMAL(%s,%s))', this.escape(expr), p, s); + } + + $toLong(expr) { + return sprintf('CAST(%s AS SIGNED)', this.escape(expr)); + } + + $toBoolean(expr) { + return sprintf('CAST(%s AS UNSIGNED)', this.escape(expr)); + } + + /** + * @param {*} expr + * @param {('date'|'datetime'|'timestamp')} type + * @returns {string} + */ + $toDate(expr, type) { + switch (type) { + case 'date': + return sprintf('DATE(%s)', this.escape(expr)); + case 'datetime': + case 'timestamp': + return sprintf('CAST(%s AS DATETIME)', this.escape(expr)); + default: + return sprintf('DATE(%s)', this.escape(expr)); + } + } + + $toGuid(expr) { + return sprintf('MD5(%s)', this.escape(expr)); + } + + $uuid() { + return 'UUID()'; + } + + /** + * @param {('date'|'datetime'|'timestamp')} type + * @returns {string} + */ + $getDate(type) { + switch (type) { + case 'date': + return 'CURDATE()'; + case 'datetime': + return 'NOW()'; + case 'timestamp': + return 'NOW()'; + default: + return 'NOW()'; + } + } +} + +export { + MySQLDialect +}; diff --git a/src/dialects/pg.js b/src/dialects/pg.js new file mode 100644 index 0000000..5873fcf --- /dev/null +++ b/src/dialects/pg.js @@ -0,0 +1,349 @@ +// MOST Web Framework Codename Zero Gravity Copyright (c) 2017-2022, THEMOST LP All rights reserved +import { sprintf } from 'sprintf-js'; +import { SqlFormatter } from '../formatter'; + +function zeroPad(number, length) { + number = number || 0; + let res = number.toString(); + while (res.length < length) { + res = '0' + res; + } + return res; +} + +/** + * Represents the PostgreSQL SQL dialect formatter. + * @class PostgreSQLDialect + * @augments {SqlFormatter} + */ +class PostgreSQLDialect extends SqlFormatter { + + static get NAME_FORMAT() { + return '"$1"'; + } + + /** + * @constructor + */ + constructor() { + super(); + this.settings = { + nameFormat: PostgreSQLDialect.NAME_FORMAT, + forceAlias: true + }; + } + + /** + * Escapes an object or a value and returns the equivalent sql value. + * @param {*} value - A value that is going to be escaped for SQL statements + * @param {boolean=} unquoted - An optional value that indicates whether the resulted string will be quoted or not. + * @returns {string} - The equivalent SQL string value + */ + escape(value, unquoted) { + if (typeof value === 'boolean') { + return value ? 'TRUE' : 'FALSE'; + } + if (value instanceof Date) { + return this.escapeDate(value); + } + let res = super.escape.bind(this)(value, unquoted); + if (typeof value === 'string') { + // escape single quotes for PostgreSQL + res = res.replace(/\\'/g, '\'\''); + } + return res; + } + + /** + * @param {Date|*} val + * @returns {string} + */ + escapeDate(val) { + const year = val.getFullYear(); + const month = zeroPad(val.getMonth() + 1, 2); + const day = zeroPad(val.getDate(), 2); + const hour = zeroPad(val.getHours(), 2); + const minute = zeroPad(val.getMinutes(), 2); + const second = zeroPad(val.getSeconds(), 2); + const millisecond = zeroPad(val.getMilliseconds(), 3); + const offset = val.getTimezoneOffset(); + const timezone = (offset <= 0 ? '+' : '-') + zeroPad(-Math.floor(offset / 60), 2) + ':' + zeroPad(offset % 60, 2); + return '\'' + year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second + '.' + millisecond + timezone + '\''; + } + + /** + * Formats a SELECT statement with LIMIT/OFFSET. + * @param {import('../query').QueryExpression} obj + * @returns {string} + */ + formatLimitSelect(obj) { + let sql = this.formatSelect(obj); + if (obj.$take) { + sql = sql.concat(' LIMIT ', obj.$take.toString()); + if (obj.$skip) { + sql = sql.concat(' OFFSET ', obj.$skip.toString()); + } + } + return sql; + } + + /** + * Implements indexOf(str,substr) expression formatter. + * @param {string} p0 The source string + * @param {string} p1 The string to search for + * @returns {string} + */ + $indexof(p0, p1) { + return sprintf('(STRPOS(%s,%s)-1)', this.escape(p0), this.escape(p1)); + } + + /** + * Implements indexOf(str,substr) expression formatter. + * @param {string} p0 The source string + * @param {string} p1 The string to search for + * @returns {string} + */ + $indexOf(p0, p1) { + return sprintf('(STRPOS(%s,%s)-1)', this.escape(p0), this.escape(p1)); + } + + /** + * Implements contains(a,b) expression formatter. + * @param {*} p0 The source string + * @param {*} p1 The string to search for + * @returns {string} + */ + $text(p0, p1) { + return sprintf('(STRPOS(%s,%s)-1)>=0', this.escape(p0), this.escape(p1)); + } + + /** + * Implements simple regular expression formatter using PostgreSQL's ~ operator. + * @param {*} p0 The source string or field + * @param {*} p1 The regular expression pattern + * @returns {string} + */ + $regex(p0, p1) { + return sprintf('(%s ~ %s)', this.escape(p0), this.escape(p1)); + } + + /** + * Implements concat(a,b) expression formatter. + * @param {...*} arg + * @returns {string} + */ + // eslint-disable-next-line no-unused-vars + $concat(arg) { + const args = Array.from(arguments); + if (args.length < 2) { + throw new Error('Concat method expects two or more arguments'); + } + return sprintf('CONCAT(%s)', args.map((a) => this.escape(a)).join(', ')); + } + + /** + * Implements substring(str,pos) expression formatter. + * @param {String} p0 The source string + * @param {Number} pos The starting position + * @param {Number=} length The length of the resulted string + * @returns {string} + */ + $substring(p0, pos, length) { + if (length) { + return sprintf('SUBSTRING(%s,%s + 1,%s)', this.escape(p0), this.escape(pos), this.escape(length)); + } else { + return sprintf('SUBSTRING(%s,%s + 1)', this.escape(p0), this.escape(pos)); + } + } + + /** + * Implements substring(str,pos) expression formatter. + * @param {String} p0 The source string + * @param {Number} pos The starting position + * @param {Number=} length The length of the resulted string + * @returns {string} + */ + $substr(p0, pos, length) { + return this.$substring(p0, pos, length); + } + + /** + * Implements length(a) expression formatter. + * @param {*} p0 + * @returns {string} + */ + $length(p0) { + return sprintf('LENGTH(%s)', this.escape(p0)); + } + + $ceiling(p0) { + return sprintf('CEILING(%s)', this.escape(p0)); + } + + $startswith(p0, p1) { + return this.$startsWith(p0, p1); + } + + $startsWith(p0, p1) { + if (p0 == null || p1 == null) + return ''; + return sprintf('(%s LIKE %s)', this.escape(p0), this.escape(this.escape(p1, true) + '%', true)); + } + + $endswith(p0, p1) { + return this.$endsWith(p0, p1); + } + + $endsWith(p0, p1) { + if (p0 == null || p1 == null) + return ''; + return sprintf('(%s LIKE %s)', this.escape(p0), this.escape('%' + this.escape(p1, true), true)); + } + + $contains(p0, p1) { + if (p0 == null || p1 == null) + return ''; + return sprintf('(%s LIKE %s)', this.escape(p0), this.escape('%' + this.escape(p1, true) + '%', true)); + } + + $day(p0) { + return sprintf('EXTRACT(DAY FROM %s)', this.escape(p0)); + } + + $dayOfMonth(p0) { + return sprintf('EXTRACT(DAY FROM %s)', this.escape(p0)); + } + + $month(p0) { + return sprintf('EXTRACT(MONTH FROM %s)', this.escape(p0)); + } + + $year(p0) { + return sprintf('EXTRACT(YEAR FROM %s)', this.escape(p0)); + } + + $hour(p0) { + return sprintf('EXTRACT(HOUR FROM %s)', this.escape(p0)); + } + + $hours(p0) { + return this.$hour(p0); + } + + $minute(p0) { + return sprintf('EXTRACT(MINUTE FROM %s)', this.escape(p0)); + } + + $minutes(p0) { + return this.$minute(p0); + } + + $second(p0) { + return sprintf('EXTRACT(SECOND FROM %s)', this.escape(p0)); + } + + $seconds(p0) { + return this.$second(p0); + } + + $date(p0) { + return sprintf('DATE(%s)', this.escape(p0)); + } + + /** + * @param {*} p0 + * @param {*} p1 + * @returns {string} + */ + $ifnull(p0, p1) { + return sprintf('COALESCE(%s, %s)', this.escape(p0), this.escape(p1)); + } + + /** + * @param {*} p0 + * @param {*} p1 + * @returns {string} + */ + $ifNull(p0, p1) { + return sprintf('COALESCE(%s, %s)', this.escape(p0), this.escape(p1)); + } + + $toString(expr) { + return sprintf('CAST(%s AS TEXT)', this.escape(expr)); + } + + $toInt(expr) { + return sprintf('CAST(%s AS INTEGER)', this.escape(expr)); + } + + $toDouble(expr) { + return sprintf('CAST(%s AS FLOAT)', this.escape(expr)); + } + + /** + * @param {*} expr + * @param {number=} precision + * @param {number=} scale + * @returns {string} + */ + $toDecimal(expr, precision, scale) { + const p = typeof precision === 'number' ? Math.floor(precision) : 19; + const s = typeof scale === 'number' ? Math.floor(scale) : 8; + return sprintf('CAST(%s AS DECIMAL(%s,%s))', this.escape(expr), p, s); + } + + $toLong(expr) { + return sprintf('CAST(%s AS BIGINT)', this.escape(expr)); + } + + $toBoolean(expr) { + return sprintf('CAST(%s AS BOOLEAN)', this.escape(expr)); + } + + /** + * @param {*} expr + * @param {('date'|'datetime'|'timestamp')} type + * @returns {string} + */ + $toDate(expr, type) { + switch (type) { + case 'date': + return sprintf('CAST(%s AS DATE)', this.escape(expr)); + case 'datetime': + return sprintf('CAST(%s AS TIMESTAMP)', this.escape(expr)); + case 'timestamp': + return sprintf('CAST(%s AS TIMESTAMPTZ)', this.escape(expr)); + default: + return sprintf('CAST(%s AS DATE)', this.escape(expr)); + } + } + + $toGuid(expr) { + return sprintf('MD5(%s)::UUID', this.escape(expr)); + } + + $uuid() { + return 'gen_random_uuid()'; + } + + /** + * @param {('date'|'datetime'|'timestamp')} type + * @returns {string} + */ + $getDate(type) { + switch (type) { + case 'date': + return 'CURRENT_DATE'; + case 'datetime': + return 'NOW()'; + case 'timestamp': + return 'NOW()'; + default: + return 'NOW()'; + } + } +} + +export { + PostgreSQLDialect +}; diff --git a/src/dialects/sqlite.js b/src/dialects/sqlite.js new file mode 100644 index 0000000..168f323 --- /dev/null +++ b/src/dialects/sqlite.js @@ -0,0 +1,516 @@ +// MOST Web Framework Codename Zero Gravity Copyright (c) 2017-2022, THEMOST LP All rights reserved +import { sprintf } from 'sprintf-js'; +import { SqlFormatter, QueryField } from '../formatter'; +import { isObjectDeep } from '../is-object'; + +const REGEXP_SINGLE_QUOTE = /\\'/g; +const SINGLE_QUOTE_ESCAPE = '\'\''; +const REGEXP_DOUBLE_QUOTE = /\\"/g; +const DOUBLE_QUOTE_ESCAPE = '"'; +const REGEXP_SLASH = /\\\\/g; +const SLASH_ESCAPE = '\\'; + +function zeroPad(number, length) { + number = number || 0; + let res = number.toString(); + while (res.length < length) { + res = '0' + res; + } + return res; +} + +/** + * Represents the SQLite SQL dialect formatter. + * @class SqliteDialect + * @augments {SqlFormatter} + */ +class SqliteDialect extends SqlFormatter { + + static get NAME_FORMAT() { + return '`$1`'; + } + + /** + * @constructor + */ + constructor() { + super(); + this.settings = { + nameFormat: SqliteDialect.NAME_FORMAT, + forceAlias: true + }; + } + + /** + * Escapes an object or a value and returns the equivalent sql value. + * @param {*} value - A value that is going to be escaped for SQL statements + * @param {boolean=} unquoted - An optional value that indicates whether the resulted string will be quoted or not. + * @returns {string} - The equivalent SQL string value + */ + escape(value, unquoted) { + if (typeof value === 'boolean') { + return value ? '1' : '0'; + } + if (value instanceof Date) { + return this.escapeDate(value); + } + // serialize array of objects as json array + if (Array.isArray(value)) { + // find first non-object value + const index = value.filter((x) => { + return x != null; + }).findIndex((x) => { + return isObjectDeep(x) === false; + }); + // if all values are objects + if (index === -1) { + return this.escape(JSON.stringify(value)); // return as json array + } + } + let res = super.escape.bind(this)(value, unquoted); + if (typeof value === 'string') { + if (REGEXP_SINGLE_QUOTE.test(res)) + //escape single quote (that is already escaped) + res = res.replace(/\\'/g, SINGLE_QUOTE_ESCAPE); + //escape double quote (that is already escaped) + res = res.replace(/\\"/g, DOUBLE_QUOTE_ESCAPE); + if (REGEXP_SLASH.test(res)) + //escape slash (that is already escaped) + res = res.replace(/\\\\/g, SLASH_ESCAPE); + } + return res; + } + + /** + * @param {Date|*} val + * @returns {string} + */ + escapeDate(val) { + const year = val.getFullYear(); + const month = zeroPad(val.getMonth() + 1, 2); + const day = zeroPad(val.getDate(), 2); + const hour = zeroPad(val.getHours(), 2); + const minute = zeroPad(val.getMinutes(), 2); + const second = zeroPad(val.getSeconds(), 2); + const millisecond = zeroPad(val.getMilliseconds(), 3); + //format timezone + const offset = val.getTimezoneOffset(); + const timezone = (offset <= 0 ? '+' : '-') + zeroPad(-Math.floor(offset / 60), 2) + ':' + zeroPad(offset % 60, 2); + return '\'' + year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second + '.' + millisecond + timezone + '\''; + } + + /** + * Implements indexOf(str,substr) expression formatter. + * @param {string} p0 The source string + * @param {string} p1 The string to search for + * @returns {string} + */ + $indexof(p0, p1) { + return sprintf('(INSTR(%s,%s)-1)', this.escape(p0), this.escape(p1)); + } + + /** + * Implements indexOf(str,substr) expression formatter. + * @param {string} p0 The source string + * @param {string} p1 The string to search for + * @returns {string} + */ + $indexOf(p0, p1) { + return sprintf('(INSTR(%s,%s)-1)', this.escape(p0), this.escape(p1)); + } + + /** + * Implements contains(a,b) expression formatter. + * @param {*} p0 The source string + * @param {*} p1 The string to search for + * @returns {string} + */ + $text(p0, p1) { + return sprintf('(INSTR(%s,%s)-1)>=0', this.escape(p0), this.escape(p1)); + } + + /** + * Implements simple regular expression formatter. + * Important Note: SQLite 3 does not provide a core sql function for regular expression matching. + * @param {*} p0 The source string or field + * @param {*} p1 The string to search for + */ + $regex(p0, p1) { + //escape expression + let s1 = this.escape(p1, true); + //implement starts with equivalent for LIKE T-SQL + if (/^\^/.test(s1)) { + s1 = s1.replace(/^\^/, ''); + } else { + s1 = '%' + s1; + } + //implement ends with equivalent for LIKE T-SQL + if (/\$$/.test(s1)) { + s1 = s1.replace(/\$$/, ''); + } else { + s1 += '%'; + } + return sprintf('LIKE(\'%s\',%s) >= 1', s1, this.escape(p0)); + } + + /** + * Implements concat(a,b) expression formatter. + * @param {...*} arg + * @returns {string} + */ + // eslint-disable-next-line no-unused-vars + $concat(arg) { + const args = Array.from(arguments); + if (args.length < 2) { + throw new Error('Concat method expects two or more arguments'); + } + let result = '('; + result += Array.from(args).map((a) => { + return `IFNULL(${this.escape(a)},'')` + }).join(' || '); + result += ')'; + return result; + } + + /** + * Implements substring(str,pos) expression formatter. + * @param {String} p0 The source string + * @param {Number} pos The starting position + * @param {Number=} length The length of the resulted string + * @returns {string} + */ + $substring(p0, pos, length) { + if (length) { + return sprintf('SUBSTR(%s,%s + 1,%s)', this.escape(p0), this.escape(pos), this.escape(length)); + } else { + return sprintf('SUBSTR(%s,%s + 1)', this.escape(p0), this.escape(pos)); + } + } + + /** + * Implements substring(str,pos) expression formatter. + * @param {String} p0 The source string + * @param {Number} pos The starting position + * @param {Number=} length The length of the resulted string + * @returns {string} + */ + $substr(p0, pos, length) { + if (length) { + return sprintf('SUBSTR(%s,%s + 1,%s)', this.escape(p0), this.escape(pos), this.escape(length)); + } else { + return sprintf('SUBSTR(%s,%s + 1)', this.escape(p0), this.escape(pos)); + } + } + + /** + * Implements length(a) expression formatter. + * @param {*} p0 + * @returns {string} + */ + $length(p0) { + return sprintf('LENGTH(%s)', this.escape(p0)); + } + + $ceiling(p0) { + return sprintf('CEIL(%s)', this.escape(p0)); + } + + $startswith(p0, p1) { + return this.$startsWith(p0, p1); + } + + $startsWith(p0, p1) { + if (p0 == null || p1 == null) + return ''; + return 'LIKE(\'' + this.escape(p1, true) + '%\',' + this.escape(p0) + ')'; + } + + $contains(p0, p1) { + if (p0 == null || p1 == null) + return ''; + return 'LIKE(\'%' + this.escape(p1, true) + '%\',' + this.escape(p0) + ')'; + } + + $endswith(p0, p1) { + return this.$endsWith(p0, p1); + } + + $endsWith(p0, p1) { + if (p0 == null || p1 == null) + return ''; + return 'LIKE(\'%' + this.escape(p1, true) + '\',' + this.escape(p0) + ')'; + } + + $day(p0) { + return `CAST(strftime('%d', ${this.escape(p0)}) AS INTEGER)`; + } + + $dayOfMonth(p0) { + return `CAST(strftime('%d', ${this.escape(p0)}) AS INTEGER)`; + } + + $month(p0) { + return `CAST(strftime('%m', ${this.escape(p0)}) AS INTEGER)`; + } + + $year(p0) { + return `CAST(strftime('%Y', ${this.escape(p0)}) AS INTEGER)`; + } + + $hour(p0) { + return `CAST(strftime('%H', ${this.escape(p0)}) AS INTEGER)`; + } + + $hours(p0) { + return this.$hour(p0); + } + + $minute(p0) { + return `CAST(strftime('%M', ${this.escape(p0)}) AS INTEGER)`; + } + + $minutes(p0) { + return this.$minute(p0); + } + + $second(p0) { + return `CAST(strftime('%S', ${this.escape(p0)}) AS INTEGER)`; + } + + $seconds(p0) { + return this.$second(p0); + } + + $date(p0) { + return 'date(' + this.escape(p0) + ')'; + } + + /** + * @param {*} p0 + * @param {*} p1 + * @returns {string} + */ + $ifnull(p0, p1) { + return sprintf('IFNULL(%s, %s)', this.escape(p0), this.escape(p1)); + } + + /** + * @param {*} p0 + * @param {*} p1 + * @returns {string} + */ + $ifNull(p0, p1) { + return sprintf('IFNULL(%s, %s)', this.escape(p0), this.escape(p1)); + } + + $toString(p0) { + return sprintf('CAST(%s AS TEXT)', this.escape(p0)); + } + + $toInt(expr) { + return sprintf('CAST(%s AS INT)', this.escape(expr)); + } + + $toDouble(expr) { + return this.$toDecimal(expr, 19, 8); + } + + /** + * @param {*} expr + * @param {number=} precision + * @param {number=} scale + * @returns {string} + */ + $toDecimal(expr, precision, scale) { + const p = typeof precision === 'number' ? Math.floor(precision) : 19; + const s = typeof scale === 'number' ? Math.floor(scale) : 8; + return sprintf('CAST(%s as DECIMAL(%s,%s))', this.escape(expr), p, s); + } + + $toLong(expr) { + return sprintf('CAST(%s AS BIGINT)', this.escape(expr)); + } + + $toBoolean(expr) { + return sprintf('CAST(%s AS INTEGER)', this.escape(expr)); + } + + /** + * @param {*} expr + * @param {('date'|'datetime'|'timestamp')} type + * @returns {string} + */ + // eslint-disable-next-line no-unused-vars + $toDate(expr, type) { + return sprintf('date(%s)', this.escape(expr)); + } + + $toGuid(expr) { + return sprintf('uuid_str(crypto_md5(%s))', this.escape(expr)); + } + + $uuid() { + return 'uuid4()'; + } + + /** + * @param {('date'|'datetime'|'timestamp')} type + * @returns {string} + */ + $getDate(type) { + switch (type) { + case 'date': + return 'date(\'now\')'; + case 'datetime': + // eslint-disable-next-line quotes + return `strftime('%F %H:%M:%f+00:00', 'now')`; + case 'timestamp': + // eslint-disable-next-line quotes + return `STRFTIME('%Y-%m-%d %H:%M:%f', DATETIME('now', 'localtime')) || PRINTF('%+.2d:%.2d', ROUND((JULIANDAY('now', 'localtime') - JULIANDAY('now')) * 24), ABS(ROUND((JULIANDAY('now', 'localtime') - JULIANDAY('now')) * 24 * 60) % 60))`; + default: + // eslint-disable-next-line quotes + return `strftime('%F %H:%M:%f+00:00', 'now')`; + } + } + + /** + * @param {*} expr + * @return {string} + */ + $jsonGet(expr) { + if (typeof expr.$name !== 'string') { + throw new Error('Invalid json expression. Expected a string'); + } + const parts = expr.$name.split('.'); + const extract = this.escapeName(parts.splice(0, 2).join('.')); + return `json_extract(${extract}, '$.${parts.join('.')}')`; + } + + /** + * @param {*} expr + * @return {string} + */ + $jsonEach(expr) { + return `json_each(${this.escapeName(expr)})`; + } + + /** + * @param {...*} expr + * @returns {string} + */ + $jsonObject(expr) { + // handle select expression + if (expr.$select) { + // get select fields + const args = Object.keys(expr.$select).reduce((previous, key) => { + const fields = expr.$select[key]; + previous.push.apply(previous, fields.map((field) => { + if (typeof field === 'string') { + return new QueryField(field); + } + return field; + })); + return previous; + }, []); + const [key] = Object.keys(expr.$select); + // prepare select expression to return json array + expr.$select[key] = [ + { + $jsonObject: args // use json_object function + } + ]; + // format select expression using json_object + return `(${this.format(expr)})`; + } + // expected an array of QueryField objects + const args = Array.from(arguments).reduce((previous, current) => { + // get the first key of the current object + let [name] = Object.keys(current); + let value; + // if the name is not a string then throw an error + if (typeof name !== 'string') { + throw new Error('Invalid json object expression. The attribute name cannot be determined.'); + } + // if the given name is a dialect function (starts with $) then use the current value as is + // otherwise create a new QueryField object + if (name.startsWith('$')) { + value = new QueryField(current[name]); + name = value.getName(); + } else { + value = current instanceof QueryField ? new QueryField(current[name]) : current[name]; + } + // escape json attribute name and value + previous.push(this.escape(name), this.escape(value)); + return previous; + }, []); + return `json_object(${args.join(',')})`; + } + + /** + * @param {{ $jsonGet: Array<*> }} expr + * @returns {string} + */ + $jsonGroupArray(expr) { + const [key] = Object.keys(expr); + if (key !== '$jsonObject') { + throw new Error('Invalid json group array expression. Expected a json object expression'); + } + return `json_group_array(${this.escape(expr)})`; + } + + /** + * @param {import('../query').QueryExpression} expr + * @returns {string} + */ + $jsonArray(expr) { + if (expr == null) { + throw new Error('The given query expression cannot be null'); + } + if (expr instanceof QueryField) { + // escape expr as field and waiting for parsing results as json array + return this.escape(expr); + } + // treat expr as select expression + if (expr.$select) { + // get select fields + const args = Object.keys(expr.$select).reduce((previous, key) => { + previous.push.apply(previous, expr.$select[key]); + return previous; + }, []); + const [key] = Object.keys(expr.$select); + // prepare select expression to return json array + expr.$select[key] = [ + { + $jsonGroupArray: [ // use json_group_array function + { + $jsonObject: args // use json_object function + } + ] + } + ]; + return `(${this.format(expr)})`; + } + // treat expression as query field + if (Object.prototype.hasOwnProperty.call(expr, '$name')) { + return this.escape(expr); + } + // treat expression as value + if (Object.prototype.hasOwnProperty.call(expr, '$value')) { + if (Array.isArray(expr.$value)) { + return this.escape(JSON.stringify(expr.$value)); + } + return this.escape(expr); + } + if (Object.prototype.hasOwnProperty.call(expr, '$literal')) { + if (Array.isArray(expr.$literal)) { + return this.escape(JSON.stringify(expr.$literal)); + } + return this.escape(expr); + } + throw new Error('Invalid json array expression. Expected a valid select expression'); + } +} + +export { + SqliteDialect +}; diff --git a/src/index.js b/src/index.js index 64c1c03..3997ec7 100644 --- a/src/index.js +++ b/src/index.js @@ -12,4 +12,5 @@ export * from './closures/DateMethodParser'; export * from './closures/StringMethodParser'; export * from './object-name.validator'; export * from './open-data-query.expression'; -export * from './open-data-query.formatter'; \ No newline at end of file +export * from './open-data-query.formatter'; +export * from './dialects/index'; \ No newline at end of file From b914dad2770df7f01ed302c7fe670d8096d13763 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:10:54 +0000 Subject: [PATCH 4/4] Add OracleDialect SQL formatter Co-authored-by: kbarbounakis <9191768+kbarbounakis@users.noreply.github.com> --- spec/SqlDialects.spec.js | 205 ++++++++++++++++++++++- src/dialects/index.js | 1 + src/dialects/oracle.js | 352 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 556 insertions(+), 2 deletions(-) create mode 100644 src/dialects/oracle.js diff --git a/spec/SqlDialects.spec.js b/spec/SqlDialects.spec.js index 1a8d0ea..22711d9 100644 --- a/spec/SqlDialects.spec.js +++ b/spec/SqlDialects.spec.js @@ -3,6 +3,7 @@ import { SqliteDialect } from '../src/dialects/sqlite'; import { MySQLDialect } from '../src/dialects/mysql'; import { MSSQLDialect } from '../src/dialects/mssql'; import { PostgreSQLDialect } from '../src/dialects/pg'; +import { OracleDialect } from '../src/dialects/oracle'; describe('SqlDialects', () => { @@ -497,6 +498,203 @@ describe('SqlDialects', () => { }); }); + describe('OracleDialect', () => { + it('should format a SELECT statement with OFFSET/FETCH', () => { + const Products = new QueryEntity('ProductData'); + const query = new QueryExpression().select('id', 'name').from(Products).take(10).skip(20); + const formatter = new OracleDialect(); + const sql = formatter.formatLimitSelect(query); + expect(sql).toContain('FROM "ProductData"'); + expect(sql).toContain('OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY'); + }); + + it('should format a SELECT with FETCH NEXT (no skip)', () => { + const Products = new QueryEntity('ProductData'); + const query = new QueryExpression().select('id', 'name').from(Products).take(5); + const formatter = new OracleDialect(); + const sql = formatter.formatLimitSelect(query); + expect(sql).toContain('OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY'); + }); + + it('should escape identifiers with double quotes', () => { + const formatter = new OracleDialect(); + expect(formatter.escapeName('name')).toBe('"name"'); + }); + + it('should escape boolean as 1/0', () => { + const formatter = new OracleDialect(); + expect(formatter.escape(true)).toBe('1'); + expect(formatter.escape(false)).toBe('0'); + }); + + it('should format $toString', () => { + const formatter = new OracleDialect(); + const result = formatter.$toString({ $name: 'price' }); + expect(result).toBe('TO_CHAR("price")'); + }); + + it('should format $toInt', () => { + const formatter = new OracleDialect(); + const result = formatter.$toInt({ $name: 'price' }); + expect(result).toBe('CAST("price" AS NUMBER(10,0))'); + }); + + it('should format $toLong', () => { + const formatter = new OracleDialect(); + const result = formatter.$toLong({ $name: 'id' }); + expect(result).toBe('CAST("id" AS NUMBER(19,0))'); + }); + + it('should format $toDouble', () => { + const formatter = new OracleDialect(); + const result = formatter.$toDouble({ $name: 'price' }); + expect(result).toBe('CAST("price" AS NUMBER(19,8))'); + }); + + it('should format $toDecimal', () => { + const formatter = new OracleDialect(); + const result = formatter.$toDecimal({ $name: 'price' }, 10, 2); + expect(result).toBe('CAST("price" AS NUMBER(10,2))'); + }); + + it('should format $toBoolean', () => { + const formatter = new OracleDialect(); + const result = formatter.$toBoolean({ $name: 'active' }); + expect(result).toBe('CAST("active" AS NUMBER(1,0))'); + }); + + it('should format $concat', () => { + const formatter = new OracleDialect(); + const result = formatter.$concat({ $name: 'firstName' }, ' ', { $name: 'lastName' }); + expect(result).toContain('CONCAT('); + }); + + it('should format $length', () => { + const formatter = new OracleDialect(); + const result = formatter.$length({ $name: 'name' }); + expect(result).toBe('LENGTH("name")'); + }); + + it('should format $substring', () => { + const formatter = new OracleDialect(); + const result = formatter.$substring({ $name: 'name' }, 0, 5); + expect(result).toBe('SUBSTR("name",0 + 1,5)'); + }); + + it('should format $indexOf', () => { + const formatter = new OracleDialect(); + const result = formatter.$indexOf({ $name: 'name' }, 'John'); + expect(result).toBe('(INSTR("name",\'John\')-1)'); + }); + + it('should format $startsWith', () => { + const formatter = new OracleDialect(); + const result = formatter.$startsWith({ $name: 'name' }, 'John'); + expect(result).toContain('LIKE'); + expect(result).toContain('John%'); + }); + + it('should format $endsWith', () => { + const formatter = new OracleDialect(); + const result = formatter.$endsWith({ $name: 'name' }, 'son'); + expect(result).toContain('LIKE'); + expect(result).toContain('%son'); + }); + + it('should format $contains', () => { + const formatter = new OracleDialect(); + const result = formatter.$contains({ $name: 'name' }, 'ohn'); + expect(result).toContain('LIKE'); + expect(result).toContain('%ohn%'); + }); + + it('should format $year', () => { + const formatter = new OracleDialect(); + const result = formatter.$year({ $name: 'createdAt' }); + expect(result).toBe('EXTRACT(YEAR FROM "createdAt")'); + }); + + it('should format $month', () => { + const formatter = new OracleDialect(); + const result = formatter.$month({ $name: 'createdAt' }); + expect(result).toBe('EXTRACT(MONTH FROM "createdAt")'); + }); + + it('should format $day', () => { + const formatter = new OracleDialect(); + const result = formatter.$day({ $name: 'createdAt' }); + expect(result).toBe('EXTRACT(DAY FROM "createdAt")'); + }); + + it('should format $hour', () => { + const formatter = new OracleDialect(); + const result = formatter.$hour({ $name: 'createdAt' }); + expect(result).toBe('EXTRACT(HOUR FROM "createdAt")'); + }); + + it('should format $minute', () => { + const formatter = new OracleDialect(); + const result = formatter.$minute({ $name: 'createdAt' }); + expect(result).toBe('EXTRACT(MINUTE FROM "createdAt")'); + }); + + it('should format $second', () => { + const formatter = new OracleDialect(); + const result = formatter.$second({ $name: 'createdAt' }); + expect(result).toBe('EXTRACT(SECOND FROM "createdAt")'); + }); + + it('should format $date', () => { + const formatter = new OracleDialect(); + const result = formatter.$date({ $name: 'createdAt' }); + expect(result).toBe('TRUNC("createdAt")'); + }); + + it('should format $ifNull', () => { + const formatter = new OracleDialect(); + const result = formatter.$ifNull({ $name: 'description' }, ''); + expect(result).toBe('NVL("description", \'\')'); + }); + + it('should format $ceiling', () => { + const formatter = new OracleDialect(); + const result = formatter.$ceiling({ $name: 'price' }); + expect(result).toBe('CEIL("price")'); + }); + + it('should format $regex', () => { + const formatter = new OracleDialect(); + const result = formatter.$regex({ $name: 'name' }, 'John'); + expect(result).toContain('REGEXP_LIKE'); + }); + + it('should format $uuid', () => { + const formatter = new OracleDialect(); + expect(formatter.$uuid()).toBe('LOWER(RAWTOHEX(SYS_GUID()))'); + }); + + it('should format $toDate', () => { + const formatter = new OracleDialect(); + expect(formatter.$toDate({ $name: 'createdAt' }, 'date')).toBe('TRUNC(CAST("createdAt" AS DATE))'); + expect(formatter.$toDate({ $name: 'createdAt' }, 'datetime')).toBe('CAST("createdAt" AS DATE)'); + expect(formatter.$toDate({ $name: 'createdAt' }, 'timestamp')).toBe('CAST("createdAt" AS TIMESTAMP)'); + }); + + it('should format $getDate', () => { + const formatter = new OracleDialect(); + expect(formatter.$getDate('date')).toBe('TRUNC(SYSDATE)'); + expect(formatter.$getDate('datetime')).toBe('SYSDATE'); + expect(formatter.$getDate('timestamp')).toBe('SYSTIMESTAMP'); + }); + + it('should be exported from main index', () => { + const { OracleDialect: dialect } = require('../src/index'); + expect(dialect).toBeDefined(); + const instance = new dialect(); + expect(instance).toBeInstanceOf(OracleDialect); + }); + }); + describe('Dialect common features', () => { it('should all dialects extend SqlFormatter', () => { const { SqlFormatter } = require('../src/index'); @@ -504,6 +702,7 @@ describe('SqlDialects', () => { expect(new MySQLDialect()).toBeInstanceOf(SqlFormatter); expect(new MSSQLDialect()).toBeInstanceOf(SqlFormatter); expect(new PostgreSQLDialect()).toBeInstanceOf(SqlFormatter); + expect(new OracleDialect()).toBeInstanceOf(SqlFormatter); }); it('should all dialects format a basic SELECT', () => { @@ -513,7 +712,8 @@ describe('SqlDialects', () => { new SqliteDialect(), new MySQLDialect(), new MSSQLDialect(), - new PostgreSQLDialect() + new PostgreSQLDialect(), + new OracleDialect() ]; for (const formatter of dialects) { const sql = formatter.formatSelect(query); @@ -527,7 +727,8 @@ describe('SqlDialects', () => { new SqliteDialect(), new MySQLDialect(), new MSSQLDialect(), - new PostgreSQLDialect() + new PostgreSQLDialect(), + new OracleDialect() ]; for (const formatter of dialects) { const result = formatter.$cast({ $name: 'price' }, 'string'); diff --git a/src/dialects/index.js b/src/dialects/index.js index 605fe60..cb6b567 100644 --- a/src/dialects/index.js +++ b/src/dialects/index.js @@ -3,3 +3,4 @@ export { SqliteDialect } from './sqlite'; export { MySQLDialect } from './mysql'; export { MSSQLDialect } from './mssql'; export { PostgreSQLDialect } from './pg'; +export { OracleDialect } from './oracle'; diff --git a/src/dialects/oracle.js b/src/dialects/oracle.js new file mode 100644 index 0000000..2f9f080 --- /dev/null +++ b/src/dialects/oracle.js @@ -0,0 +1,352 @@ +// MOST Web Framework Codename Zero Gravity Copyright (c) 2017-2022, THEMOST LP All rights reserved +import { sprintf } from 'sprintf-js'; +import { SqlFormatter } from '../formatter'; + +function zeroPad(number, length) { + number = number || 0; + let res = number.toString(); + while (res.length < length) { + res = '0' + res; + } + return res; +} + +/** + * Represents the Oracle SQL dialect formatter. + * @class OracleDialect + * @augments {SqlFormatter} + */ +class OracleDialect extends SqlFormatter { + + static get NAME_FORMAT() { + return '"$1"'; + } + + /** + * @constructor + */ + constructor() { + super(); + this.settings = { + nameFormat: OracleDialect.NAME_FORMAT, + forceAlias: true + }; + } + + /** + * Escapes an object or a value and returns the equivalent sql value. + * @param {*} value - A value that is going to be escaped for SQL statements + * @param {boolean=} unquoted - An optional value that indicates whether the resulted string will be quoted or not. + * @returns {string} - The equivalent SQL string value + */ + escape(value, unquoted) { + if (typeof value === 'boolean') { + return value ? '1' : '0'; + } + if (value instanceof Date) { + return this.escapeDate(value); + } + let res = super.escape.bind(this)(value, unquoted); + if (typeof value === 'string') { + // escape single quotes by doubling them + res = res.replace(/\\'/g, '\'\''); + } + return res; + } + + /** + * @param {Date|*} val + * @returns {string} + */ + escapeDate(val) { + const year = val.getFullYear(); + const month = zeroPad(val.getMonth() + 1, 2); + const day = zeroPad(val.getDate(), 2); + const hour = zeroPad(val.getHours(), 2); + const minute = zeroPad(val.getMinutes(), 2); + const second = zeroPad(val.getSeconds(), 2); + return `TIMESTAMP '${year}-${month}-${day} ${hour}:${minute}:${second}'`; + } + + /** + * Formats a SELECT statement with OFFSET/FETCH (Oracle 12c+). + * @param {import('../query').QueryExpression} obj + * @returns {string} + */ + formatLimitSelect(obj) { + let sql = this.formatSelect(obj); + if (obj.$take) { + const skip = obj.$skip || 0; + sql = sql.concat(' OFFSET ', skip.toString(), ' ROWS FETCH NEXT ', obj.$take.toString(), ' ROWS ONLY'); + } + return sql; + } + + /** + * Implements indexOf(str,substr) expression formatter. + * @param {string} p0 The source string + * @param {string} p1 The string to search for + * @returns {string} + */ + $indexof(p0, p1) { + return sprintf('(INSTR(%s,%s)-1)', this.escape(p0), this.escape(p1)); + } + + /** + * Implements indexOf(str,substr) expression formatter. + * @param {string} p0 The source string + * @param {string} p1 The string to search for + * @returns {string} + */ + $indexOf(p0, p1) { + return sprintf('(INSTR(%s,%s)-1)', this.escape(p0), this.escape(p1)); + } + + /** + * Implements contains(a,b) expression formatter. + * @param {*} p0 The source string + * @param {*} p1 The string to search for + * @returns {string} + */ + $text(p0, p1) { + return sprintf('(INSTR(%s,%s)-1)>=0', this.escape(p0), this.escape(p1)); + } + + /** + * Implements simple regular expression formatter using Oracle's REGEXP_LIKE. + * @param {*} p0 The source string or field + * @param {*} p1 The regular expression pattern + * @returns {string} + */ + $regex(p0, p1) { + return sprintf('REGEXP_LIKE(%s, %s)', this.escape(p0), this.escape(p1)); + } + + /** + * Implements concat(a,b) expression formatter. + * Oracle supports both CONCAT() (two-argument) and the || operator. + * For multiple arguments we nest CONCAT calls. + * @param {...*} arg + * @returns {string} + */ + // eslint-disable-next-line no-unused-vars + $concat(arg) { + const args = Array.from(arguments); + if (args.length < 2) { + throw new Error('Concat method expects two or more arguments'); + } + // Build nested CONCAT(a, CONCAT(b, c)) for more than two args + let result = this.escape(args[args.length - 1]); + for (let i = args.length - 2; i >= 0; i--) { + result = sprintf('CONCAT(%s, %s)', this.escape(args[i]), result); + } + return result; + } + + /** + * Implements substring(str,pos) expression formatter. + * Oracle uses SUBSTR (1-based index). + * @param {String} p0 The source string + * @param {Number} pos The starting position (0-based) + * @param {Number=} length The length of the resulted string + * @returns {string} + */ + $substring(p0, pos, length) { + if (length) { + return sprintf('SUBSTR(%s,%s + 1,%s)', this.escape(p0), this.escape(pos), this.escape(length)); + } else { + return sprintf('SUBSTR(%s,%s + 1)', this.escape(p0), this.escape(pos)); + } + } + + /** + * Implements substring(str,pos) expression formatter. + * @param {String} p0 The source string + * @param {Number} pos The starting position (0-based) + * @param {Number=} length The length of the resulted string + * @returns {string} + */ + $substr(p0, pos, length) { + return this.$substring(p0, pos, length); + } + + /** + * Implements length(a) expression formatter. + * @param {*} p0 + * @returns {string} + */ + $length(p0) { + return sprintf('LENGTH(%s)', this.escape(p0)); + } + + $ceiling(p0) { + return sprintf('CEIL(%s)', this.escape(p0)); + } + + $startswith(p0, p1) { + return this.$startsWith(p0, p1); + } + + $startsWith(p0, p1) { + if (p0 == null || p1 == null) + return ''; + return sprintf('(%s LIKE %s)', this.escape(p0), this.escape(this.escape(p1, true) + '%', true)); + } + + $endswith(p0, p1) { + return this.$endsWith(p0, p1); + } + + $endsWith(p0, p1) { + if (p0 == null || p1 == null) + return ''; + return sprintf('(%s LIKE %s)', this.escape(p0), this.escape('%' + this.escape(p1, true), true)); + } + + $contains(p0, p1) { + if (p0 == null || p1 == null) + return ''; + return sprintf('(%s LIKE %s)', this.escape(p0), this.escape('%' + this.escape(p1, true) + '%', true)); + } + + $day(p0) { + return sprintf('EXTRACT(DAY FROM %s)', this.escape(p0)); + } + + $dayOfMonth(p0) { + return sprintf('EXTRACT(DAY FROM %s)', this.escape(p0)); + } + + $month(p0) { + return sprintf('EXTRACT(MONTH FROM %s)', this.escape(p0)); + } + + $year(p0) { + return sprintf('EXTRACT(YEAR FROM %s)', this.escape(p0)); + } + + $hour(p0) { + return sprintf('EXTRACT(HOUR FROM %s)', this.escape(p0)); + } + + $hours(p0) { + return this.$hour(p0); + } + + $minute(p0) { + return sprintf('EXTRACT(MINUTE FROM %s)', this.escape(p0)); + } + + $minutes(p0) { + return this.$minute(p0); + } + + $second(p0) { + return sprintf('EXTRACT(SECOND FROM %s)', this.escape(p0)); + } + + $seconds(p0) { + return this.$second(p0); + } + + $date(p0) { + return sprintf('TRUNC(%s)', this.escape(p0)); + } + + /** + * @param {*} p0 + * @param {*} p1 + * @returns {string} + */ + $ifnull(p0, p1) { + return sprintf('NVL(%s, %s)', this.escape(p0), this.escape(p1)); + } + + /** + * @param {*} p0 + * @param {*} p1 + * @returns {string} + */ + $ifNull(p0, p1) { + return sprintf('NVL(%s, %s)', this.escape(p0), this.escape(p1)); + } + + $toString(expr) { + return sprintf('TO_CHAR(%s)', this.escape(expr)); + } + + $toInt(expr) { + return sprintf('CAST(%s AS NUMBER(10,0))', this.escape(expr)); + } + + $toDouble(expr) { + return this.$toDecimal(expr, 19, 8); + } + + /** + * @param {*} expr + * @param {number=} precision + * @param {number=} scale + * @returns {string} + */ + $toDecimal(expr, precision, scale) { + const p = typeof precision === 'number' ? Math.floor(precision) : 19; + const s = typeof scale === 'number' ? Math.floor(scale) : 8; + return sprintf('CAST(%s AS NUMBER(%s,%s))', this.escape(expr), p, s); + } + + $toLong(expr) { + return sprintf('CAST(%s AS NUMBER(19,0))', this.escape(expr)); + } + + $toBoolean(expr) { + return sprintf('CAST(%s AS NUMBER(1,0))', this.escape(expr)); + } + + /** + * @param {*} expr + * @param {('date'|'datetime'|'timestamp')} type + * @returns {string} + */ + $toDate(expr, type) { + switch (type) { + case 'date': + return sprintf('TRUNC(CAST(%s AS DATE))', this.escape(expr)); + case 'datetime': + return sprintf('CAST(%s AS DATE)', this.escape(expr)); + case 'timestamp': + return sprintf('CAST(%s AS TIMESTAMP)', this.escape(expr)); + default: + return sprintf('TRUNC(CAST(%s AS DATE))', this.escape(expr)); + } + } + + $toGuid(expr) { + return sprintf('LOWER(RAWTOHEX(DBMS_CRYPTO.HASH(UTL_RAW.CAST_TO_RAW(%s), 2)))', this.escape(expr)); + } + + $uuid() { + return 'LOWER(RAWTOHEX(SYS_GUID()))'; + } + + /** + * @param {('date'|'datetime'|'timestamp')} type + * @returns {string} + */ + $getDate(type) { + switch (type) { + case 'date': + return 'TRUNC(SYSDATE)'; + case 'datetime': + return 'SYSDATE'; + case 'timestamp': + return 'SYSTIMESTAMP'; + default: + return 'SYSDATE'; + } + } +} + +export { + OracleDialect +};