diff --git a/README.md b/README.md index 42125c1..2b013e1 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This library is to facilate using Spraxa's DFramework related applications via N - [API Reference](docs/API_REFERENCE.md) - Complete reference for business objects, filtering, and multi-select columns - [Usage Patterns](docs/USAGE_PATTERNS.md) - Comprehensive guide with common usage patterns and examples - [Database Configuration](docs/DATABASE_CONFIGURATION.md) - Connection pooling, best practices, and configuration examples +- [Case-Insensitive Search](docs/CASE_INSENSITIVE_SEARCH.md) - Configure UPPER, ILIKE, ILIKE-fn (StarRocks), and custom modes for WHERE and ORDER BY - [Batch Operations](docs/BATCH_OPERATIONS.md) - Efficient batch INSERT, UPDATE, DELETE operations with examples - [ElasticSearch Queries](docs/ELASTICSEARCH_QUERIES.md) - SQL and Search API queries with pagination and memory optimization - [New Exports](docs/NEW_EXPORTS.md) - Documentation on newly available exports diff --git a/docs/CASE_INSENSITIVE_SEARCH.md b/docs/CASE_INSENSITIVE_SEARCH.md new file mode 100644 index 0000000..f305db0 --- /dev/null +++ b/docs/CASE_INSENSITIVE_SEARCH.md @@ -0,0 +1,501 @@ +# Case-Insensitive Search & Sorting + +This guide covers all the options DFramework provides for case-insensitive filtering and sorting. These options apply to both the `Sql` (MSSQL) and `MySql` (MySQL / StarRocks) classes and to any business object that inherits from `BusinessBase`. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Quick Start](#quick-start) +3. [Master Switch — `forceCaseInsensitive`](#master-switch--forcecaseinsensitive) +4. [Comparison Modes — `caseInsensitiveMode`](#comparison-modes--caseinsensitivemode) + - [Mode: `'upper'` (default, MSSQL)](#mode-upper-default-mssql) + - [Mode: `'ilike'` (PostgreSQL-style infix)](#mode-ilike-postgresql-style-infix) + - [Mode: `'ilike-fn'` (StarRocks / MySQL-compatible function)](#mode-ilike-fn-starrocks--mysql-compatible-function) + - [Mode: Custom function](#mode-custom-function) +5. [ORDER BY — `caseInsensitiveOrderBy`](#order-by--caseinsensitiveorderby) +6. [Pre-computed Sort Columns — `shadowColumns`](#pre-computed-sort-columns--shadowcolumns) +7. [Full Configuration Examples](#full-configuration-examples) + - [MSSQL](#mssql) + - [MySQL (standard)](#mysql-standard) + - [StarRocks (MySQL-compatible)](#starrocks-mysql-compatible) + - [PostgreSQL-compatible](#postgresql-compatible) +8. [Using with `BusinessBase` / `BusinessBaseRouter`](#using-with-businessbase--businessbaserouter) +9. [Troubleshooting](#troubleshooting) + +--- + +## Overview + +By default DFramework passes filter values and sort fields to the database exactly as they are received. When you want case-insensitive comparisons you must tell DFramework two things: + +| Setting | Purpose | +|---|---| +| `forceCaseInsensitive: true` | **Master switch.** Must be `true` for any transformation to take place. | +| `caseInsensitiveMode` | Which transformation to apply. Defaults to `'upper'`. | +| `caseInsensitiveOrderBy` | Whether (and how) to wrap ORDER BY fields. Defaults to `false` (no wrapping). | +| `shadowColumns` | Map column names to pre-computed sort columns (alternative to wrapping). | + +--- + +## Quick Start + +### MSSQL — `UPPER()` wrapping (default) + +```js +import { Sql } from '@durlabh/dframework'; + +const sql = new Sql(); +await sql.setConfig({ + user: process.env.SQL_USER, + password: process.env.SQL_PASSWORD, + server: process.env.SQL_SERVER, + database: process.env.SQL_DATABASE, + options: { trustServerCertificate: true }, + + // ── Case-insensitive search ────────────────────────────────────────── + forceCaseInsensitive: true, // Enable the feature + // caseInsensitiveMode defaults to 'upper' — no need to set it explicitly +}); +``` + +Generated WHERE clause for a "contains" filter on `Name`: + +```sql +WHERE UPPER(Main.Name) LIKE @Name -- value is '%SEARCHTERM%' (uppercased) +``` + +### StarRocks — `ILIKE()` function syntax + +```js +const mysql = new MySql(); +await mysql.setConfig({ + host: process.env.SR_HOST, + user: process.env.SR_USER, + password: process.env.SR_PASSWORD, + database: process.env.SR_DATABASE, + namedPlaceholders: true, + + // ── Case-insensitive search ────────────────────────────────────────── + forceCaseInsensitive: true, + caseInsensitiveMode: 'ilike-fn', // Use ILIKE(field, value) = 1 syntax +}); +``` + +Generated WHERE clause for the same "contains" filter: + +```sql +WHERE ILIKE(Main.Name, :Name) = 1 -- value is '%searchterm%' (unchanged) +``` + +--- + +## Master Switch — `forceCaseInsensitive` + +``` +forceCaseInsensitive: false (default) +forceCaseInsensitive: true +``` + +When `false` (the default), DFramework passes all filter values to the database as-is. The database engine decides whether comparisons are case-sensitive. + +When `true`, DFramework transforms **every non-date string filter** according to `caseInsensitiveMode` before the SQL is executed. + +> **Important:** Setting `caseInsensitiveMode` alone has no effect. You **must** also set `forceCaseInsensitive: true`. + +Date/datetime fields are always excluded from transformation regardless of this flag. + +--- + +## Comparison Modes — `caseInsensitiveMode` + +### Mode: `'upper'` (default, MSSQL) + +Wraps the column in `UPPER()` and uppercases the filter value. Works with MSSQL and any database that supports `UPPER()`. + +```js +await sql.setConfig({ + // ...connection params... + forceCaseInsensitive: true, + caseInsensitiveMode: 'upper', // default — can be omitted +}); +``` + +| Filter type | Input | Generated SQL | +|---|---|---| +| equals | `John` | `UPPER(Name) = :Name` (value: `JOHN`) | +| contains | `john` | `UPPER(Name) LIKE :Name` (value: `%JOHN%`) | +| starts with | `jo` | `UPPER(Name) LIKE :Name` (value: `JO%`) | +| not equals | `John` | `UPPER(Name) != :Name` (value: `JOHN`) | + +### Mode: `'ilike'` (PostgreSQL-style infix) + +Replaces `=` / `LIKE` with the `ILIKE` infix operator. The value is **not** modified. Use for PostgreSQL or databases that support the `ILIKE` keyword. + +```js +await sql.setConfig({ + // ...connection params... + forceCaseInsensitive: true, + caseInsensitiveMode: 'ilike', +}); +``` + +| Filter type | Input | Generated SQL | +|---|---|---| +| equals | `John` | `Name ILIKE :Name` (value: `John`) | +| contains | `john` | `Name ILIKE :Name` (value: `%john%`) | +| not equals | `John` | `Name NOT ILIKE :Name` (value: `John`) | +| not contains | `john` | `Name NOT ILIKE :Name` (value: `%john%`) | + +### Mode: `'ilike-fn'` (StarRocks / MySQL-compatible function) + +Generates `ILIKE(field, value) = 1` / `= 0` function-call syntax. The value is **not** modified. Use for **StarRocks** and other MySQL-compatible databases that implement `ILIKE` as a function. + +```js +await mysql.setConfig({ + // ...connection params... + forceCaseInsensitive: true, + caseInsensitiveMode: 'ilike-fn', +}); +``` + +| Filter type | Input | Generated SQL | +|---|---|---| +| equals | `John` | `ILIKE(Name, :Name) = 1` (value: `John`) | +| contains | `john` | `ILIKE(Name, :Name) = 1` (value: `%john%`) | +| starts with | `jo` | `ILIKE(Name, :Name) = 1` (value: `jo%`) | +| not equals | `John` | `ILIKE(Name, :Name) = 0` (value: `John`) | +| not contains | `john` | `ILIKE(Name, :Name) = 0` (value: `%john%`) | +| numeric / date | n/a | unchanged (no ILIKE applied) | + +> **Note:** StarRocks' `ILIKE(haystack, pattern)` supports `%` and `_` wildcard characters — the same patterns used by `LIKE`. + +### Mode: Custom function + +For full control over any database dialect, provide a function. The function receives an options object and must return either `{ fieldName, value, operator }` or `{ statementTemplate, value }`. + +```js +// Option A: return transformed field / value / operator +await sql.setConfig({ + forceCaseInsensitive: true, + caseInsensitiveMode: ({ fieldName, value, operator }) => ({ + fieldName: `LOWER(${fieldName})`, + value: typeof value === 'string' ? value.toLowerCase() : value, + operator, + }), +}); + +// Option B: return a complete SQL fragment using {param} as placeholder +await sql.setConfig({ + forceCaseInsensitive: true, + caseInsensitiveMode: ({ fieldName, value, operator }) => { + const upperOp = operator.toUpperCase(); + if (['=', 'LIKE'].includes(upperOp)) { + return { statementTemplate: `SOUNDEX(${fieldName}) = SOUNDEX({param})`, value }; + } + return { fieldName, value, operator }; // fallback + }, +}); +``` + +The custom function receives: + +| Parameter | Type | Description | +|---|---|---| +| `fieldName` | `string` | Column name (may include table alias, e.g. `Main.Name`) | +| `value` | `*` | Filter value | +| `operator` | `string` | SQL operator (`=`, `LIKE`, `!=`, `NOT LIKE`, …) | +| `type` | `string` | Semantic type (e.g. `'date'`, `'dateTime'`) | +| `sqlType` | `*` | mssql / mysql2 type constant | + +--- + +## ORDER BY — `caseInsensitiveOrderBy` + +By default (`false`) sort fields are passed to the database exactly as provided — **no wrapping**. Set this when you want the sort order to be case-insensitive. + +``` +caseInsensitiveOrderBy: false (default — no wrapping) +caseInsensitiveOrderBy: true → UPPER(fieldName) +caseInsensitiveOrderBy: 'upper' → UPPER(fieldName) (same as true) +caseInsensitiveOrderBy: (field) => `LOWER(${field})` (custom) +``` + +> `caseInsensitiveOrderBy` is **independent** of `forceCaseInsensitive` and `caseInsensitiveMode`. You can have case-insensitive ORDER BY without case-insensitive WHERE filtering, and vice versa. + +**Example — UPPER() in ORDER BY:** + +```js +await sql.setConfig({ + // ...connection params... + forceCaseInsensitive: true, // WHERE: apply UPPER() to filters + caseInsensitiveMode: 'upper', + caseInsensitiveOrderBy: true, // ORDER BY: also wrap sort fields +}); +// ORDER BY Name ASC → ORDER BY UPPER(Name) ASC +``` + +**Example — StarRocks: ILIKE for WHERE, no wrapping for ORDER BY:** + +```js +await mysql.setConfig({ + // ...connection params... + forceCaseInsensitive: true, + caseInsensitiveMode: 'ilike-fn', + caseInsensitiveOrderBy: false, // default — ORDER BY unchanged +}); +// WHERE ILIKE(Name, :Name) = 1 ... ORDER BY Name ASC +``` + +**Example — Custom ORDER BY expression:** + +```js +await sql.setConfig({ + // ...connection params... + caseInsensitiveOrderBy: (field) => `COLLATE(${field}, 'en-US-nocase')`, +}); +// ORDER BY Name ASC → ORDER BY COLLATE(Name, 'en-US-nocase') ASC +``` + +--- + +## Pre-computed Sort Columns — `shadowColumns` + +`shadowColumns` provides an alternative to function-based ORDER BY. Instead of wrapping the column at query time, you maintain a separate pre-computed column (e.g. a lower-cased, indexed copy) and DFramework substitutes the column name in the ORDER BY clause. + +```js +await sql.setConfig({ + // ...connection params... + shadowColumns: { + FullName: 'FullName_Lower', // ORDER BY FullName → ORDER BY FullName_Lower + Email: 'Email_Lower', + }, +}); +``` + +This approach avoids function calls in the ORDER BY clause, which allows the database to use an index on the shadow column for efficient sorting. + +`shadowColumns` and `caseInsensitiveOrderBy` can be combined: shadow column substitution happens first, then any `caseInsensitiveOrderBy` wrapping is applied (only to fields that did **not** get substituted by a shadow). + +--- + +## Full Configuration Examples + +### MSSQL + +```js +import { Sql } from '@durlabh/dframework'; + +const sql = new Sql(); +await sql.setConfig({ + user: process.env.SQL_USER, + password: process.env.SQL_PASSWORD, + server: process.env.SQL_SERVER, + database: process.env.SQL_DATABASE, + options: { trustServerCertificate: true }, + + // Case-insensitive WHERE (UPPER mode — works on all MSSQL versions) + forceCaseInsensitive: true, + caseInsensitiveMode: 'upper', // can be omitted — it's the default + + // Optional: also wrap ORDER BY fields + caseInsensitiveOrderBy: true, + + // Optional: use pre-computed sort columns instead + shadowColumns: { + Name: 'Name_Upper', + Email: 'Email_Upper', + }, +}); +``` + +### MySQL (standard) + +```js +import { MySql } from '@durlabh/dframework'; + +const mysql = new MySql(); +await mysql.setConfig({ + host: process.env.MYSQL_HOST, + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD, + database: process.env.MYSQL_DATABASE, + namedPlaceholders: true, + + // MySQL is case-insensitive by default for utf8_general_ci / utf8mb4_general_ci. + // Enable this only if your columns use a case-sensitive collation. + forceCaseInsensitive: true, + caseInsensitiveMode: 'upper', +}); +``` + +### StarRocks (MySQL-compatible) + +```js +import { MySql } from '@durlabh/dframework'; + +const starRocks = new MySql(); +await starRocks.setConfig({ + host: process.env.SR_HOST, + port: process.env.SR_PORT || 9030, + user: process.env.SR_USER, + password: process.env.SR_PASSWORD, + database: process.env.SR_DATABASE, + namedPlaceholders: true, + + // StarRocks supports ILIKE(field, pattern) as a function returning 0/1 + forceCaseInsensitive: true, + caseInsensitiveMode: 'ilike-fn', + + // ORDER BY is left unchanged (default) — StarRocks comparisons are already + // case-insensitive for most VARCHAR columns when ILIKE is used for filtering. + // Set caseInsensitiveOrderBy: true if you need case-insensitive sorting too. + caseInsensitiveOrderBy: false, +}); +``` + +### PostgreSQL-compatible + +```js +import { MySql } from '@durlabh/dframework'; // use MySql for pg-compatible adapters + +const pg = new MySql(); +await pg.setConfig({ + // ...connection params... + + // PostgreSQL supports ILIKE as an infix operator + forceCaseInsensitive: true, + caseInsensitiveMode: 'ilike', + + caseInsensitiveOrderBy: false, // pg ILIKE already handles case in WHERE +}); +``` + +--- + +## Using with `BusinessBase` / `BusinessBaseRouter` + +When you use `BusinessBase` (or any business object that extends it), set the case-insensitive options on the SQL/MySQL instance that is registered as `BusinessBase.businessObject.sql`. This is the instance used internally by all list and filter operations. + +```js +import BusinessBase from '@durlabh/dframework/business/business-base'; +import { MySql } from '@durlabh/dframework'; + +// 1. Create and configure the SQL instance +const sql = new MySql(); +await sql.setConfig({ + host: process.env.SR_HOST, + user: process.env.SR_USER, + password: process.env.SR_PASSWORD, + database: process.env.SR_DATABASE, + namedPlaceholders: true, + + forceCaseInsensitive: true, + caseInsensitiveMode: 'ilike-fn', + caseInsensitiveOrderBy: false, +}); + +// 2. Register it on BusinessBase so all business objects pick it up +BusinessBase.businessObject = { sql }; + +// 3. Your business objects now automatically use ILIKE(field, value) = 1 +// for all list() filter operations. +class ProductsBO extends BusinessBase { + constructor() { + super({ tableName: 'Products', primaryKey: 'ProductId' }); + } +} + +const products = new ProductsBO(); +// Generates: WHERE ILIKE(Main.Name, :Name) = 1 ORDER BY Name ASC +const result = await products.list({ + filter: JSON.stringify([{ field: 'Name', operator: 'contains', value: 'widget' }]), + sort: 'Name ASC', +}); +``` + +### Using with `Framework` + +```js +import { Framework } from '@durlabh/dframework'; +import BusinessBase from '@durlabh/dframework/business/business-base'; + +const framework = new Framework({ logger }); + +// setMySql is shorthand — all setConfig options are passed through +await framework.setMySql({ + host: process.env.SR_HOST, + user: process.env.SR_USER, + password: process.env.SR_PASSWORD, + database: process.env.SR_DATABASE, + namedPlaceholders: true, + + forceCaseInsensitive: true, + caseInsensitiveMode: 'ilike-fn', +}); + +// Register the configured instance +BusinessBase.businessObject = { sql: framework.mysql }; +``` + +--- + +## Troubleshooting + +### Filters still use `UPPER(Name) LIKE` instead of `ILIKE(Name, ...) = 1` + +**Cause:** `caseInsensitiveMode: 'ilike-fn'` is set but `forceCaseInsensitive: true` is missing. + +`caseInsensitiveMode` is only applied when `forceCaseInsensitive` is `true`. Without it, the transformation is never triggered. + +**Fix:** Pass both in the same `setConfig` call: + +```js +await sql.setConfig({ + // ...connection params... + forceCaseInsensitive: true, // ← required + caseInsensitiveMode: 'ilike-fn', // ← selects the mode +}); +``` + +### Filters return no records + +**Possible causes:** + +1. **Missing `forceCaseInsensitive: true`** — the mode is not activated (see above). +2. **Wrong SQL instance** — the `setConfig` was called on a different `Sql`/`MySql` instance than the one used by `BusinessBase.businessObject.sql`. +3. **StarRocks ILIKE with no wildcards** — if you use an "equals" filter (`operator: '='`), the value is not wrapped in `%…%`. `ILIKE(Name, :Name) = 1` with value `'Widget'` matches only the exact string (case-insensitively). If you want substring matching, use `operator: 'contains'`. + +To verify which instance is being used: + +```js +console.log('forceCaseInsensitive:', BusinessBase.businessObject.sql.forceCaseInsensitive); +console.log('caseInsensitiveMode:', BusinessBase.businessObject.sql.caseInsensitiveMode); +``` + +### ORDER BY still wraps with `UPPER()` when I don't want it to + +**Cause:** `caseInsensitiveOrderBy` was set to `true` or `'upper'`. + +**Fix:** Set it to `false` (the default): + +```js +await sql.setConfig({ + // ... + caseInsensitiveOrderBy: false, // default — no wrapping in ORDER BY +}); +``` + +### ILIKE is not recognised by my database + +Not all databases implement `ILIKE`. Use the table below to choose the right mode: + +| Database | Recommended `caseInsensitiveMode` | +|---|---| +| MSSQL | `'upper'` (default) | +| MySQL (utf8_general_ci) | typically not needed — collation handles it | +| MySQL (utf8_bin) | `'upper'` | +| StarRocks | `'ilike-fn'` | +| PostgreSQL | `'ilike'` | +| Other | custom function | diff --git a/docs/DATABASE_CONFIGURATION.md b/docs/DATABASE_CONFIGURATION.md index 485485b..ef98d03 100644 --- a/docs/DATABASE_CONFIGURATION.md +++ b/docs/DATABASE_CONFIGURATION.md @@ -453,6 +453,32 @@ await framework.setMySql({ }); ``` +## Case-Insensitive Filtering and Sorting + +DFramework supports configurable case-insensitive WHERE conditions and ORDER BY wrapping. The key options passed to `setConfig` are: + +| Option | Default | Description | +|---|---|---| +| `forceCaseInsensitive` | `false` | Master switch — must be `true` to enable any transformation | +| `caseInsensitiveMode` | `'upper'` | `'upper'` · `'ilike'` · `'ilike-fn'` · custom function | +| `caseInsensitiveOrderBy` | `false` | `false` · `true`/`'upper'` · custom function | +| `shadowColumns` | `null` | Map column → pre-computed sort column | + +**Quick example for StarRocks:** + +```js +await mysql.setConfig({ + host: '...', user: '...', password: '...', database: '...', + namedPlaceholders: true, + + forceCaseInsensitive: true, + caseInsensitiveMode: 'ilike-fn', // generates: ILIKE(Name, :Name) = 1 + caseInsensitiveOrderBy: false, // ORDER BY unchanged (default) +}); +``` + +See [Case-Insensitive Search](CASE_INSENSITIVE_SEARCH.md) for the full guide covering all modes, ORDER BY options, troubleshooting, and complete examples for MSSQL, MySQL, StarRocks, and PostgreSQL. + ## References - [MSSQL Node.js Driver Documentation](https://github.com/tediousjs/node-mssql) diff --git a/lib/business/business-base.mjs b/lib/business/business-base.mjs index af0c059..972317f 100644 --- a/lib/business/business-base.mjs +++ b/lib/business/business-base.mjs @@ -748,8 +748,16 @@ class BusinessBase { if (sort) { let orderByFields = sort.split(','); - orderByFields = orderByFields.map(field => SqlHelper.sanitizeField(field)); - query += ' ORDER BY ' + SqlHelper.sanitizeField(orderByFields.join(', ')); + orderByFields = orderByFields.map(field => { + const parts = field.trim().split(/\s+/); + const shadowFieldName = sql.applyShadowColumns(parts[0]); + const fieldName = SqlHelper.sanitizeField(shadowFieldName); + const isShadowColumn = shadowFieldName !== parts[0]; + const direction = parts[1] && ['ASC', 'DESC'].includes(parts[1].toUpperCase()) ? parts[1].toUpperCase() : ''; + const wrappedField = isShadowColumn ? fieldName : sql.applyOrderByCaseInsensitive(fieldName); + return direction ? `${wrappedField} ${direction}` : wrappedField; + }); + query += ' ORDER BY ' + orderByFields.join(', '); } if (limit > 0) { diff --git a/lib/sql.js b/lib/sql.js index 3c2a25e..ff556a6 100644 --- a/lib/sql.js +++ b/lib/sql.js @@ -38,12 +38,45 @@ class Sql { parameterPrefix = "@"; forceCaseInsensitive = false; + /** + * Controls how case-insensitive WHERE clause conditions are built when + * `forceCaseInsensitive` is true. + * + * - `'upper'` (default): wraps the field in `UPPER()` and uppercases the value. + * - `'ilike'`: uses the `ILIKE` / `NOT ILIKE` infix operator (e.g. PostgreSQL). + * The value is left unchanged and the field is not wrapped. + * - `'ilike-fn'`: uses the `ILIKE(field, value) = 1` / `= 0` function-call syntax + * (e.g. StarRocks). The value is left unchanged and the field is not wrapped. + * - A custom function `({ fieldName, value, operator, type, sqlType }) => ({ fieldName, value, operator })`: + * full control over the transformation for any database dialect. + * Return `{ statementTemplate, value }` where `statementTemplate` is a SQL fragment + * containing `{param}` as a placeholder for the bound parameter name (e.g. `@Name`). + */ + caseInsensitiveMode = 'upper'; + /** + * Optional map of logical column names to shadow column names used in ORDER BY. + * Shadow columns are pre-computed (e.g. lower-cased and indexed) variants of a + * column that enable efficient case-insensitive sorting. + * + * Example: `{ FullName: 'FullName_Shadow' }` will rewrite + * `ORDER BY FullName ASC` → `ORDER BY FullName_Shadow ASC`. + */ + shadowColumns = null; + /** + * Controls whether ORDER BY sort fields are wrapped in a case-normalising function. + * + * - `false` (default): sort fields are used as-is — no wrapping is applied. + * - `true` or `'upper'`: sort fields are wrapped with `UPPER()`. + * e.g. `ORDER BY Name ASC` → `ORDER BY UPPER(Name) ASC`. + * - A custom function `(fieldName) => string`: returns the wrapped expression for any dialect. + */ + caseInsensitiveOrderBy = false; insertedIdStatement = "SELECT SCOPE_IDENTITY() AS Id;"; inOperatorStrategy = inOperatorStrategies.INNER_JOIN; binaryColumnSuffix = "_Binary"; _tvpAliasCounter = 0; - async setConfig({ logger, timeoutLogLevel = "info", queryLogThreshold = 1000, forceCaseInsensitive, inOperatorStrategy = inOperatorStrategies.INNER_JOIN, ...config } = {}) { + async setConfig({ logger, timeoutLogLevel = "info", queryLogThreshold = 1000, forceCaseInsensitive, inOperatorStrategy = inOperatorStrategies.INNER_JOIN, caseInsensitiveMode, shadowColumns, caseInsensitiveOrderBy, ...config } = {}) { if (logger) { this.logger = logger; } @@ -51,6 +84,15 @@ class Sql { this.timeoutLogLevel = timeoutLogLevel; this.forceCaseInsensitive = forceCaseInsensitive; this.inOperatorStrategy = inOperatorStrategy; + if (caseInsensitiveMode !== undefined) { + this.caseInsensitiveMode = caseInsensitiveMode; + } + if (shadowColumns !== undefined) { + this.shadowColumns = shadowColumns; + } + if (caseInsensitiveOrderBy !== undefined) { + this.caseInsensitiveOrderBy = caseInsensitiveOrderBy; + } this.pool = await this.createPoolConnection(config); } @@ -576,7 +618,153 @@ class Sql { } /** - * Binds parameters to a SQL request and optionally appends a WHERE clause. + * Applies a case-insensitive transformation to a WHERE clause field and value, + * delegating to the appropriate strategy based on `caseInsensitiveMode`. + * + * Called by `addParameters` when `forceCaseInsensitive` is `true` for non-date + * string fields. + * + * @param {Object} params + * @param {string} params.fieldName - SQL field name (may include table alias) + * @param {*} params.value - Parameter value + * @param {string} params.operator - SQL comparison operator (e.g. '=', 'LIKE') + * @param {string} [params.type] - Semantic type (e.g. 'date', 'dateTime') + * @param {*} [params.sqlType] - SQL data type + * @returns {{ fieldName: string, value: *, operator: string } + * |{ statementTemplate: string, value: * }} + * For most modes returns `{ fieldName, value, operator }`. + * For `'ilike-fn'` (and custom functions that choose to) returns + * `{ statementTemplate, value }` where `statementTemplate` is a SQL fragment + * with `{param}` as a placeholder for the bound parameter name (e.g. `@Name`). + */ + applyCaseInsensitive({ fieldName, value, operator, type, sqlType }) { + const { caseInsensitiveMode, dataTypes } = this; + + if (typeof caseInsensitiveMode === 'function') { + return caseInsensitiveMode({ fieldName, value, operator, type, sqlType }); + } + + const isStringLike = typeof value === 'string' || sqlType === dataTypes.string || + (Array.isArray(value) && value.some(v => typeof v === 'string')); + + if (caseInsensitiveMode === 'ilike-fn') { + // StarRocks / dialects that use ILIKE as a function: ILIKE(field, value) = 1 + if (!isStringLike) { + return { fieldName, value, operator }; + } + const upperOp = typeof operator === 'string' ? operator.toUpperCase() : operator; + const posOps = new Set(['=', 'LIKE']); + const negOps = new Set(['!=', '<>', 'NOT LIKE']); + if (posOps.has(upperOp)) { + return { statementTemplate: `ILIKE(${fieldName}, {param}) = 1`, value }; + } + if (negOps.has(upperOp)) { + return { statementTemplate: `ILIKE(${fieldName}, {param}) = 0`, value }; + } + // Operator not handled by ILIKE function — fall through unchanged + return { fieldName, value, operator }; + } + + if (caseInsensitiveMode === 'ilike') { + if (!isStringLike) { + return { fieldName, value, operator }; + } + const ilikeMap = { + '=': 'ILIKE', + '!=': 'NOT ILIKE', + '<>': 'NOT ILIKE', + 'LIKE': 'ILIKE', + 'NOT LIKE': 'NOT ILIKE', + }; + const upperOp = typeof operator === 'string' ? operator.toUpperCase() : operator; + const newOperator = ilikeMap[upperOp] || operator; + return { fieldName, value, operator: newOperator }; + } + + // Default: 'upper' mode — wrap field in UPPER() and uppercase string values. + let newValue = value; + let newFieldName = fieldName; + + if (typeof value === 'string' || sqlType === dataTypes.string) { + if (typeof value === 'string') { + newValue = value.toUpperCase(); + } + newFieldName = `UPPER(${fieldName})`; + } + + if (Array.isArray(value)) { + newValue = value.map(val => typeof val === 'string' ? val.toUpperCase() : val); + const hasString = newValue.some(val => typeof val === 'string'); + if (hasString) { + newFieldName = `UPPER(${fieldName})`; + } + } + + return { fieldName: newFieldName, value: newValue, operator }; + } + + /** + * Applies shadow column substitutions to an ORDER BY sort clause. + * + * Shadow columns are pre-computed (e.g. lower-cased and indexed) variants of a + * column that allow efficient case-insensitive sorting without wrapping the column + * in a function. Configure them via `shadowColumns` on the instance or in `setConfig`. + * + * @param {string} sortClause - Comma-separated sort clause (e.g. `"FullName ASC, CreatedOn DESC"`) + * @returns {string} The sort clause with shadow column names substituted where configured. + * + * @example + * // instance.shadowColumns = { FullName: 'FullName_Shadow' } + * instance.applyShadowColumns('FullName ASC, CreatedOn DESC') + * // → 'FullName_Shadow ASC, CreatedOn DESC' + */ + applyShadowColumns(sortClause) { + const { shadowColumns } = this; + if (!shadowColumns || !sortClause) { + return sortClause; + } + return sortClause.split(',').map(part => { + const trimmed = part.trim(); + // Match the leading field name (with optional table/alias prefix) and capture the rest + const match = trimmed.match(/^([\w.]+)(.*)/); + if (match) { + const fieldName = match[1]; + const rest = match[2]; + const shadowField = shadowColumns[fieldName]; + if (shadowField) { + return shadowField + rest; + } + } + return trimmed; + }).join(', '); + } + + /** + * Applies a case-normalising wrapper to a single ORDER BY field name, based on + * `caseInsensitiveOrderBy`. + * + * - `false` (default): returns `fieldName` unchanged. + * - `true` or `'upper'`: returns `UPPER(fieldName)`. + * - A custom function `(fieldName) => string`: returns the function's result. + * + * Called per-field by the ORDER BY construction in `BusinessBase.list()`. + * + * @param {string} fieldName - A single, already-sanitised column name. + * @returns {string} The (possibly wrapped) column expression. + */ + applyOrderByCaseInsensitive(fieldName) { + const { caseInsensitiveOrderBy } = this; + if (!caseInsensitiveOrderBy) { + return fieldName; + } + if (typeof caseInsensitiveOrderBy === 'function') { + return caseInsensitiveOrderBy(fieldName); + } + // true or 'upper' — wrap with UPPER() + return `UPPER(${fieldName})`; + } + + /** * * Each entry in `parameters` can be a plain value or an options object with the * following properties: @@ -689,19 +877,32 @@ class Sql { continue; } - if (forceCaseInsensitive) { + if (forceCaseInsensitive && forWhere) { const isDateType = type && dateTimeFields.includes(type); - if (!isDateType && (typeof value === 'string' || sqlType === dataTypes.string)) { - value = value.toUpperCase(); - fieldName = `UPPER(${fieldName})`; - } - if (!isDateType && Array.isArray(value)) { - value = value.map(val => typeof val === 'string' ? val.toUpperCase() : val); - // Only apply UPPER() to fieldName if the array contains at least one string - const hasString = value.some(val => typeof val === 'string'); - if(hasString) { - fieldName = `UPPER(${fieldName})`; + if (!isDateType) { + const result = this.applyCaseInsensitive({ fieldName, value, operator, type, sqlType }); + if (result.statementTemplate) { + // The mode returned a complete SQL fragment (e.g. 'ilike-fn'). + // Resolve the {param} placeholder after dot-stripping below. + // Stash template and updated value; skip the normal statement builder. + if (paramName.indexOf('.') > -1) { + const parts = paramName.split('.'); + paramName = parts[parts.length - 1]; + } + const stmt = result.statementTemplate.replaceAll('{param}', buildParameterName(paramName)); + if (sqlType !== undefined && sqlType !== null) { + request.input(paramName, sqlType, result.value); + } else { + request.input(paramName, result.value); + } + if (forWhere) { + whereClauses.push(stmt); + } + continue; } + fieldName = result.fieldName; + value = result.value; + operator = result.operator; } } diff --git a/tests/business-base-orderby.test.js b/tests/business-base-orderby.test.js new file mode 100644 index 0000000..676b7d7 --- /dev/null +++ b/tests/business-base-orderby.test.js @@ -0,0 +1,64 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import BusinessBase from '../lib/business/business-base.mjs'; + +function createBoWithSql(sql) { + class TestBusinessObject extends BusinessBase { } + const bo = new TestBusinessObject(); + bo.standardTable = false; + bo.tableName = 'Users'; + bo.keyField = 'UserId'; + bo.user = {}; + + let capturedQuery = ''; + BusinessBase.businessObject = { + sql: { + ...sql, + createRequest: () => ({ + query: async (query) => { + capturedQuery = query; + return { recordset: [] }; + } + }) + } + }; + + return { + bo, + getCapturedQuery: () => capturedQuery, + }; +} + +test('BusinessBase ORDER BY behaviour', { concurrency: 1 }, async (t) => { + await t.test('list sanitizes ORDER BY direction token to ASC/DESC only', async () => { + const { bo, getCapturedQuery } = createBoWithSql({ + addParameters: ({ query }) => query, + applyShadowColumns: (field) => field, + applyOrderByCaseInsensitive: (field) => `UPPER(${field})`, + }); + + await bo.list({ sort: 'Name DESC OFFSET 1 ROWS', limit: 0, returnCount: false }); + const query = getCapturedQuery(); + + assert.ok(query.includes('ORDER BY UPPER(Name) DESC')); + assert.ok(!query.includes('OFFSET 1 ROWS'), `Unexpected injected token in ORDER BY: ${query}`); + }); + + await t.test('list does not wrap substituted shadow sort fields', async () => { + let applyOrderByCaseInsensitiveCallCount = 0; + const { bo, getCapturedQuery } = createBoWithSql({ + addParameters: ({ query }) => query, + applyShadowColumns: (field) => field === 'Name' ? 'Name_Shadow' : field, + applyOrderByCaseInsensitive: (field) => { + applyOrderByCaseInsensitiveCallCount += 1; + return `UPPER(${field})`; + }, + }); + + await bo.list({ sort: 'Name ASC', limit: 0, returnCount: false }); + const query = getCapturedQuery(); + + assert.ok(query.includes('ORDER BY Name_Shadow ASC')); + assert.equal(applyOrderByCaseInsensitiveCallCount, 0); + }); +}); diff --git a/tests/case-insensitive.test.js b/tests/case-insensitive.test.js new file mode 100644 index 0000000..463fa53 --- /dev/null +++ b/tests/case-insensitive.test.js @@ -0,0 +1,488 @@ +/** + * Tests for configurable case-insensitive WHERE clause handling and shadow columns. + * + * Covers: + * - caseInsensitiveMode: 'upper' (default) — existing UPPER() behaviour + * - caseInsensitiveMode: 'ilike' — ILIKE operator for Starrocks / PostgreSQL + * - caseInsensitiveMode: custom function — full custom control per dialect + * - shadowColumns — ORDER BY shadow column substitution + * - setConfig picks up the new options + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import Sql from '../lib/sql.js'; + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +function createMockRequest() { + return { + parameters: {}, + input: function (name, typeOrValue, value) { + if (arguments.length === 2) { + this.parameters[name] = { value: typeOrValue }; + } else { + this.parameters[name] = { type: typeOrValue, value }; + } + } + }; +} + +function makeSql(overrides = {}) { + const instance = new Sql(); + Object.assign(instance, overrides); + return instance; +} + +// --------------------------------------------------------------------------- +// applyCaseInsensitive — 'upper' mode (default) +// --------------------------------------------------------------------------- + +test("applyCaseInsensitive 'upper': uppercases string value and wraps field in UPPER()", () => { + const sql = makeSql({ caseInsensitiveMode: 'upper', dataTypes: new Sql().dataTypes }); + const result = sql.applyCaseInsensitive({ fieldName: 'UserName', value: 'john', operator: '=' }); + assert.equal(result.fieldName, 'UPPER(UserName)'); + assert.equal(result.value, 'JOHN'); + assert.equal(result.operator, '='); +}); + +test("applyCaseInsensitive 'upper': uppercases array elements that are strings", () => { + const sql = makeSql({ caseInsensitiveMode: 'upper', dataTypes: new Sql().dataTypes }); + const result = sql.applyCaseInsensitive({ fieldName: 'Tag', value: ['alpha', 'beta', 42], operator: 'IN' }); + assert.equal(result.fieldName, 'UPPER(Tag)'); + assert.deepEqual(result.value, ['ALPHA', 'BETA', 42]); + assert.equal(result.operator, 'IN'); +}); + +test("applyCaseInsensitive 'upper': non-string non-array value keeps original", () => { + const sql = makeSql({ caseInsensitiveMode: 'upper', dataTypes: new Sql().dataTypes }); + const result = sql.applyCaseInsensitive({ fieldName: 'Count', value: 5, operator: '>' }); + assert.equal(result.fieldName, 'Count'); + assert.equal(result.value, 5); + assert.equal(result.operator, '>'); +}); + +// --------------------------------------------------------------------------- +// applyCaseInsensitive — 'ilike' mode +// --------------------------------------------------------------------------- + +test("applyCaseInsensitive 'ilike': '=' operator becomes 'ILIKE'", () => { + const sql = makeSql({ caseInsensitiveMode: 'ilike', dataTypes: new Sql().dataTypes }); + const result = sql.applyCaseInsensitive({ fieldName: 'UserName', value: 'john', operator: '=' }); + assert.equal(result.fieldName, 'UserName'); + assert.equal(result.value, 'john'); + assert.equal(result.operator, 'ILIKE'); +}); + +test("applyCaseInsensitive 'ilike': 'LIKE' operator becomes 'ILIKE'", () => { + const sql = makeSql({ caseInsensitiveMode: 'ilike', dataTypes: new Sql().dataTypes }); + const result = sql.applyCaseInsensitive({ fieldName: 'UserName', value: '%jo%', operator: 'LIKE' }); + assert.equal(result.operator, 'ILIKE'); + assert.equal(result.value, '%jo%'); + assert.equal(result.fieldName, 'UserName'); +}); + +test("applyCaseInsensitive 'ilike': 'NOT LIKE' operator becomes 'NOT ILIKE'", () => { + const sql = makeSql({ caseInsensitiveMode: 'ilike', dataTypes: new Sql().dataTypes }); + const result = sql.applyCaseInsensitive({ fieldName: 'UserName', value: '%jo%', operator: 'NOT LIKE' }); + assert.equal(result.operator, 'NOT ILIKE'); +}); + +test("applyCaseInsensitive 'ilike': '!=' operator becomes 'NOT ILIKE'", () => { + const sql = makeSql({ caseInsensitiveMode: 'ilike', dataTypes: new Sql().dataTypes }); + const result = sql.applyCaseInsensitive({ fieldName: 'UserName', value: 'john', operator: '!=' }); + assert.equal(result.operator, 'NOT ILIKE'); +}); + +test("applyCaseInsensitive 'ilike': unrecognised operator is left unchanged", () => { + const sql = makeSql({ caseInsensitiveMode: 'ilike', dataTypes: new Sql().dataTypes }); + const result = sql.applyCaseInsensitive({ fieldName: 'Count', value: 5, operator: '>' }); + assert.equal(result.operator, '>'); + assert.equal(result.fieldName, 'Count'); + assert.equal(result.value, 5); +}); + +// --------------------------------------------------------------------------- +// applyCaseInsensitive — custom function mode +// --------------------------------------------------------------------------- + +test("applyCaseInsensitive custom function: called with correct args and return used", () => { + let calledWith = null; + const customFn = (params) => { + calledWith = params; + return { fieldName: `LOWER(${params.fieldName})`, value: params.value.toLowerCase(), operator: params.operator }; + }; + const sql = makeSql({ caseInsensitiveMode: customFn, dataTypes: new Sql().dataTypes }); + const result = sql.applyCaseInsensitive({ fieldName: 'UserName', value: 'JOHN', operator: '=' }); + assert.ok(calledWith); + assert.equal(calledWith.fieldName, 'UserName'); + assert.equal(result.fieldName, 'LOWER(UserName)'); + assert.equal(result.value, 'john'); +}); + +// --------------------------------------------------------------------------- +// addParameters uses caseInsensitiveMode via applyCaseInsensitive +// --------------------------------------------------------------------------- + +test("addParameters with ILIKE mode: builds ILIKE condition instead of UPPER()", () => { + const sql = makeSql({ forceCaseInsensitive: true, caseInsensitiveMode: 'ilike' }); + const request = createMockRequest(); + const query = sql.addParameters({ + query: 'SELECT 1', + request, + parameters: { UserName: { value: 'john' } }, + forWhere: true + }); + assert.ok(query.includes('ILIKE'), `Expected ILIKE in: ${query}`); + assert.ok(!query.includes('UPPER('), `Should not contain UPPER(): ${query}`); + assert.equal(request.parameters['UserName'].value, 'john'); // value NOT uppercased +}); + +test("addParameters with ILIKE mode: LIKE becomes ILIKE for contains-style filter", () => { + const sql = makeSql({ forceCaseInsensitive: true, caseInsensitiveMode: 'ilike' }); + const request = createMockRequest(); + const query = sql.addParameters({ + query: 'SELECT 1', + request, + parameters: { UserName: { operator: 'LIKE', value: '%jo%' } }, + forWhere: true + }); + assert.ok(query.includes('ILIKE'), `Expected ILIKE in: ${query}`); + assert.equal(request.parameters['UserName'].value, '%jo%'); +}); + +test("addParameters with UPPER mode: wraps field in UPPER() (existing behaviour)", () => { + const sql = makeSql({ forceCaseInsensitive: true, caseInsensitiveMode: 'upper' }); + const request = createMockRequest(); + const query = sql.addParameters({ + query: 'SELECT 1', + request, + parameters: { UserName: { value: 'john' } }, + forWhere: true + }); + assert.ok(query.includes('UPPER(UserName)'), `Expected UPPER(UserName) in: ${query}`); + assert.equal(request.parameters['UserName'].value, 'JOHN'); +}); + +test("addParameters with UPPER mode: preserves wildcard pattern for contains/startsWith/endsWith style LIKE values", () => { + const sql = makeSql({ forceCaseInsensitive: true, caseInsensitiveMode: 'upper' }); + const request = createMockRequest(); + const query = sql.addParameters({ + query: 'SELECT 1', + request, + parameters: { + Contains: { fieldName: 'UserName', operator: 'LIKE', value: '%jo%' }, + StartsWith: { fieldName: 'UserName', operator: 'LIKE', value: 'jo%' }, + EndsWith: { fieldName: 'UserName', operator: 'LIKE', value: '%jo' } + }, + forWhere: true + }); + assert.ok(query.includes('UPPER(UserName) LIKE @Contains')); + assert.ok(query.includes('UPPER(UserName) LIKE @StartsWith')); + assert.ok(query.includes('UPPER(UserName) LIKE @EndsWith')); + assert.equal(request.parameters.Contains.value, '%JO%'); + assert.equal(request.parameters.StartsWith.value, 'JO%'); + assert.equal(request.parameters.EndsWith.value, '%JO'); +}); + +test("addParameters with custom function: custom transformation is applied", () => { + const customFn = ({ fieldName, value, operator }) => ({ + fieldName: `LOWER(${fieldName})`, + value: typeof value === 'string' ? value.toLowerCase() : value, + operator + }); + const sql = makeSql({ forceCaseInsensitive: true, caseInsensitiveMode: customFn }); + const request = createMockRequest(); + const query = sql.addParameters({ + query: 'SELECT 1', + request, + parameters: { UserName: { value: 'JOHN' } }, + forWhere: true + }); + assert.ok(query.includes('LOWER(UserName)'), `Expected LOWER(UserName) in: ${query}`); + assert.equal(request.parameters['UserName'].value, 'john'); +}); + +test("addParameters with ILIKE mode: date fields are not transformed", () => { + const sql = makeSql({ forceCaseInsensitive: true, caseInsensitiveMode: 'ilike' }); + const request = createMockRequest(); + const query = sql.addParameters({ + query: 'SELECT 1', + request, + parameters: { CreatedOn: { value: '2024-01-15', type: 'date' } }, + forWhere: true + }); + assert.ok(!query.includes('ILIKE'), `Date field should not use ILIKE: ${query}`); + assert.ok(!query.includes('UPPER('), `Date field should not use UPPER(): ${query}`); +}); + +test("addParameters with ILIKE mode: numeric fields are not transformed", () => { + const sql = makeSql({ forceCaseInsensitive: true, caseInsensitiveMode: 'ilike' }); + const request = createMockRequest(); + const query = sql.addParameters({ + query: 'SELECT 1', + request, + parameters: { Age: { value: 30 } }, + forWhere: true + }); + assert.ok(!query.includes('ILIKE'), `Numeric field should not use ILIKE: ${query}`); + assert.equal(request.parameters['Age'].value, 30); +}); + +test("addParameters with 'ilike-fn' mode: numeric fields are not transformed", () => { + const sql = makeSql({ forceCaseInsensitive: true, caseInsensitiveMode: 'ilike-fn' }); + const request = createMockRequest(); + const query = sql.addParameters({ + query: 'SELECT 1', + request, + parameters: { Age: { value: 30 } }, + forWhere: true + }); + assert.ok(!query.includes('ILIKE('), `Numeric field should not use ILIKE fn: ${query}`); + assert.equal(request.parameters['Age'].value, 30); +}); + +test("addParameters with forceCaseInsensitive: does not transform non-WHERE values", () => { + const sql = makeSql({ forceCaseInsensitive: true, caseInsensitiveMode: 'upper' }); + const request = createMockRequest(); + const query = sql.addParameters({ + query: 'SELECT 1', + request, + parameters: { UserName: { value: 'john' } }, + forWhere: false + }); + assert.equal(query, 'SELECT 1'); + assert.equal(request.parameters['UserName'].value, 'john'); +}); + +// --------------------------------------------------------------------------- +// setConfig picks up caseInsensitiveMode and shadowColumns +// --------------------------------------------------------------------------- + +test("setConfig sets caseInsensitiveMode", async () => { + const sql = new Sql(); + // Mock createPoolConnection so we don't need a real DB + sql.createPoolConnection = async () => null; + await sql.setConfig({ caseInsensitiveMode: 'ilike' }); + assert.equal(sql.caseInsensitiveMode, 'ilike'); +}); + +test("setConfig sets shadowColumns", async () => { + const sql = new Sql(); + sql.createPoolConnection = async () => null; + const shadows = { FullName: 'FullName_Shadow' }; + await sql.setConfig({ shadowColumns: shadows }); + assert.deepEqual(sql.shadowColumns, shadows); +}); + +test("setConfig does not override caseInsensitiveMode when not provided", async () => { + const sql = new Sql(); + sql.caseInsensitiveMode = 'ilike'; // set beforehand + sql.createPoolConnection = async () => null; + await sql.setConfig({}); // no caseInsensitiveMode key + assert.equal(sql.caseInsensitiveMode, 'ilike'); +}); + +// --------------------------------------------------------------------------- +// applyShadowColumns +// --------------------------------------------------------------------------- + +test("applyShadowColumns: replaces field with shadow column", () => { + const sql = makeSql({ shadowColumns: { FullName: 'FullName_Shadow' } }); + const result = sql.applyShadowColumns('FullName ASC'); + assert.equal(result, 'FullName_Shadow ASC'); +}); + +test("applyShadowColumns: replaces only the matching field in a multi-field sort", () => { + const sql = makeSql({ shadowColumns: { FullName: 'FullName_Shadow' } }); + const result = sql.applyShadowColumns('FullName ASC, CreatedOn DESC'); + assert.equal(result, 'FullName_Shadow ASC, CreatedOn DESC'); +}); + +test("applyShadowColumns: leaves non-mapped fields unchanged", () => { + const sql = makeSql({ shadowColumns: { FullName: 'FullName_Shadow' } }); + const result = sql.applyShadowColumns('CreatedOn DESC'); + assert.equal(result, 'CreatedOn DESC'); +}); + +test("applyShadowColumns: returns original clause when shadowColumns is null", () => { + const sql = makeSql({ shadowColumns: null }); + const result = sql.applyShadowColumns('FullName ASC'); + assert.equal(result, 'FullName ASC'); +}); + +test("applyShadowColumns: returns original clause when sortClause is empty", () => { + const sql = makeSql({ shadowColumns: { FullName: 'FullName_Shadow' } }); + assert.equal(sql.applyShadowColumns(''), ''); + assert.equal(sql.applyShadowColumns(null), null); +}); + +test("applyShadowColumns: handles sort without direction keyword", () => { + const sql = makeSql({ shadowColumns: { FullName: 'FullName_Shadow' } }); + const result = sql.applyShadowColumns('FullName'); + assert.equal(result, 'FullName_Shadow'); +}); + +test("applyShadowColumns: handles multiple shadow columns", () => { + const sql = makeSql({ shadowColumns: { FullName: 'FullName_Shadow', Email: 'Email_Lower' } }); + const result = sql.applyShadowColumns('FullName ASC, Email DESC, CreatedOn ASC'); + assert.equal(result, 'FullName_Shadow ASC, Email_Lower DESC, CreatedOn ASC'); +}); + +// --------------------------------------------------------------------------- +// applyCaseInsensitive — 'ilike-fn' mode (StarRocks function-call syntax) +// --------------------------------------------------------------------------- + +test("applyCaseInsensitive 'ilike-fn': '=' operator returns statementTemplate with = 1", () => { + const sql = makeSql({ caseInsensitiveMode: 'ilike-fn', dataTypes: new Sql().dataTypes }); + const result = sql.applyCaseInsensitive({ fieldName: 'Name', value: '%DOH%', operator: '=' }); + assert.equal(result.statementTemplate, 'ILIKE(Name, {param}) = 1'); + assert.equal(result.value, '%DOH%'); +}); + +test("applyCaseInsensitive 'ilike-fn': 'LIKE' operator returns statementTemplate with = 1", () => { + const sql = makeSql({ caseInsensitiveMode: 'ilike-fn', dataTypes: new Sql().dataTypes }); + const result = sql.applyCaseInsensitive({ fieldName: 'Name', value: '%DOH%', operator: 'LIKE' }); + assert.equal(result.statementTemplate, 'ILIKE(Name, {param}) = 1'); +}); + +test("applyCaseInsensitive 'ilike-fn': '!=' operator returns statementTemplate with = 0", () => { + const sql = makeSql({ caseInsensitiveMode: 'ilike-fn', dataTypes: new Sql().dataTypes }); + const result = sql.applyCaseInsensitive({ fieldName: 'Name', value: 'DOH', operator: '!=' }); + assert.equal(result.statementTemplate, 'ILIKE(Name, {param}) = 0'); +}); + +test("applyCaseInsensitive 'ilike-fn': 'NOT LIKE' operator returns statementTemplate with = 0", () => { + const sql = makeSql({ caseInsensitiveMode: 'ilike-fn', dataTypes: new Sql().dataTypes }); + const result = sql.applyCaseInsensitive({ fieldName: 'Name', value: '%DOH%', operator: 'NOT LIKE' }); + assert.equal(result.statementTemplate, 'ILIKE(Name, {param}) = 0'); +}); + +test("applyCaseInsensitive 'ilike-fn': unrecognised operator falls through unchanged", () => { + const sql = makeSql({ caseInsensitiveMode: 'ilike-fn', dataTypes: new Sql().dataTypes }); + const result = sql.applyCaseInsensitive({ fieldName: 'Age', value: 5, operator: '>' }); + assert.equal(result.fieldName, 'Age'); + assert.equal(result.operator, '>'); + assert.equal(result.value, 5); + assert.ok(!result.statementTemplate); +}); + +// --------------------------------------------------------------------------- +// addParameters with 'ilike-fn' mode +// --------------------------------------------------------------------------- + +test("addParameters with 'ilike-fn' mode: builds ILIKE(field, @param) = 1 for '=' operator", () => { + const sql = makeSql({ forceCaseInsensitive: true, caseInsensitiveMode: 'ilike-fn' }); + const request = createMockRequest(); + const query = sql.addParameters({ + query: 'SELECT 1', + request, + parameters: { Name: { value: '%DOH%' } }, + forWhere: true + }); + assert.ok(query.includes('ILIKE(Name, @Name) = 1'), `Expected ILIKE fn syntax in: ${query}`); + assert.ok(!query.includes('UPPER('), `Should not contain UPPER(): ${query}`); + assert.equal(request.parameters['Name'].value, '%DOH%'); +}); + +test("addParameters with 'ilike-fn' mode: builds ILIKE(field, @param) = 0 for '!=' operator", () => { + const sql = makeSql({ forceCaseInsensitive: true, caseInsensitiveMode: 'ilike-fn' }); + const request = createMockRequest(); + const query = sql.addParameters({ + query: 'SELECT 1', + request, + parameters: { Name: { operator: '!=', value: 'DOH' } }, + forWhere: true + }); + assert.ok(query.includes('ILIKE(Name, @Name) = 0'), `Expected ILIKE fn negation in: ${query}`); +}); + +test("addParameters with 'ilike-fn' mode: LIKE becomes ILIKE fn for contains-style filter", () => { + const sql = makeSql({ forceCaseInsensitive: true, caseInsensitiveMode: 'ilike-fn' }); + const request = createMockRequest(); + const query = sql.addParameters({ + query: 'SELECT 1', + request, + parameters: { Name: { operator: 'LIKE', value: '%DOH%' } }, + forWhere: true + }); + assert.ok(query.includes('ILIKE(Name, @Name) = 1'), `Expected ILIKE fn syntax in: ${query}`); +}); + +test("addParameters with 'ilike-fn' mode: dotted param name uses last segment as @param", () => { + const sql = makeSql({ forceCaseInsensitive: true, caseInsensitiveMode: 'ilike-fn' }); + const request = createMockRequest(); + const query = sql.addParameters({ + query: 'SELECT 1', + request, + parameters: { 'Location.Name': { value: '%DOH%' } }, + forWhere: true + }); + assert.ok(query.includes('ILIKE(Location.Name, @Name) = 1'), `Expected ILIKE fn with dotted field in: ${query}`); +}); + +test("addParameters with 'ilike-fn' mode: date fields are not transformed", () => { + const sql = makeSql({ forceCaseInsensitive: true, caseInsensitiveMode: 'ilike-fn' }); + const request = createMockRequest(); + const query = sql.addParameters({ + query: 'SELECT 1', + request, + parameters: { CreatedOn: { value: '2024-01-15', type: 'date' } }, + forWhere: true + }); + assert.ok(!query.includes('ILIKE('), `Date field should not use ILIKE fn: ${query}`); +}); + +// --------------------------------------------------------------------------- +// applyOrderByCaseInsensitive +// --------------------------------------------------------------------------- + +test("applyOrderByCaseInsensitive: returns field unchanged when caseInsensitiveOrderBy is false", () => { + const sql = makeSql({ caseInsensitiveOrderBy: false }); + assert.equal(sql.applyOrderByCaseInsensitive('Name'), 'Name'); +}); + +test("applyOrderByCaseInsensitive: wraps field with UPPER() when caseInsensitiveOrderBy is true", () => { + const sql = makeSql({ caseInsensitiveOrderBy: true }); + assert.equal(sql.applyOrderByCaseInsensitive('Name'), 'UPPER(Name)'); +}); + +test("applyOrderByCaseInsensitive: wraps field with UPPER() when caseInsensitiveOrderBy is 'upper'", () => { + const sql = makeSql({ caseInsensitiveOrderBy: 'upper' }); + assert.equal(sql.applyOrderByCaseInsensitive('Name'), 'UPPER(Name)'); +}); + +test("applyOrderByCaseInsensitive: custom function is called and its return value used", () => { + const customFn = (field) => `LOWER(${field})`; + const sql = makeSql({ caseInsensitiveOrderBy: customFn }); + assert.equal(sql.applyOrderByCaseInsensitive('Name'), 'LOWER(Name)'); +}); + +// --------------------------------------------------------------------------- +// setConfig picks up caseInsensitiveOrderBy +// --------------------------------------------------------------------------- + +test("setConfig sets caseInsensitiveOrderBy to true", async () => { + const sql = new Sql(); + sql.createPoolConnection = async () => null; + await sql.setConfig({ caseInsensitiveOrderBy: true }); + assert.equal(sql.caseInsensitiveOrderBy, true); +}); + +test("setConfig sets caseInsensitiveOrderBy to false explicitly", async () => { + const sql = new Sql(); + sql.createPoolConnection = async () => null; + sql.caseInsensitiveOrderBy = true; // set beforehand + await sql.setConfig({ caseInsensitiveOrderBy: false }); + assert.equal(sql.caseInsensitiveOrderBy, false); +}); + +test("setConfig does not override caseInsensitiveOrderBy when not provided", async () => { + const sql = new Sql(); + sql.caseInsensitiveOrderBy = true; + sql.createPoolConnection = async () => null; + await sql.setConfig({}); + assert.equal(sql.caseInsensitiveOrderBy, true); +});