UNPKG

@digitalwalletcorp/sql-builder

Version:
425 lines (312 loc) 15.2 kB
# SQL Builder [![NPM Version](https://img.shields.io/npm/v/%40digitalwalletcorp%2Fsql-builder)](https://www.npmjs.com/package/@digitalwalletcorp/sql-builder) [![License](https://img.shields.io/npm/l/%40digitalwalletcorp%2Fsql-builder)](https://opensource.org/licenses/MIT) [![Build Status](https://img.shields.io/github/actions/workflow/status/digitalwalletcorp/sql-builder/ci.yml?branch=main)](https://github.com/digitalwalletcorp/sql-builder/actions) [![Test Coverage](https://img.shields.io/codecov/c/github/digitalwalletcorp/sql-builder.svg)](https://codecov.io/gh/digitalwalletcorp/sql-builder) Inspired by Java's S2Dao, this TypeScript/JavaScript library dynamically generates SQL. It embeds entity objects into SQL templates, simplifying complex query construction and enhancing readability. Ideal for flexible, type-safe SQL generation without a full ORM. It efficiently handles dynamic `WHERE` clauses, parameter binding, and looping, reducing boilerplate code. The core mechanism involves parsing special SQL comments (`/*IF ...*/`, `/*BEGIN...*/`, etc.) in a template and generating a final query based on a provided data object. ### ✨ Features * Dynamic Query Generation: Build complex SQL queries dynamically at runtime. * Conditional Logic (`/*IF...*/`): Automatically include or exclude SQL fragments based on JavaScript conditions evaluated against your data. * Optional Blocks (`/*BEGIN...*/`): Wrap entire clauses (like `WHERE`) that are only included if at least one inner `/*IF...*/` condition is met. * Looping (`/*FOR...*/`): Generate repetitive SQL snippets by iterating over arrays in your data (e.g., for multiple `LIKE` or `OR` conditions). * Simple Parameter Binding: Easily bind values from your data object into the SQL query. * Zero Dependencies: A single, lightweight class with no external library requirements. ### ✅ Compatibility This library is written in pure, environment-agnostic JavaScript/TypeScript and has zero external dependencies, allowing it to run in various environments. -**Node.js**: Designed and optimized for server-side use in any modern Node.js environment. This is the **primary and recommended** use case. - ⚠️ **Browser-like Environments (Advanced)**: While technically capable of running in browsers (e.g., for use with in-browser databases like SQLite via WebAssembly), generating SQL on the client-side to be sent to a server **is a significant security risk and is strongly discouraged** in typical web applications. ### 📦 Instllation ```bash npm install @digitalwalletcorp/sql-builder # or yarn add @digitalwalletcorp/sql-builder ``` #### 📖 How It Works & Usage You provide the `SQLBuilder` with a template string containing special, S2Dao-style comments and a data object (the "bind entity"). The builder parses the template and generates the final SQL. ##### Example 1: Dynamic WHERE Clause This is the most common use case. The `WHERE` clause is built dynamically based on which properties exist in the `bindEntity`. **Template:** ```sql SELECT id, project_name, status FROM activity /*BEGIN*/WHERE 1 = 1 /*IF projectNames != null && projectNames.length*/AND project_name IN /*projectNames*/('project1')/*END*/ /*IF statuses != null && statuses.length*/AND status IN /*statuses*/(1)/*END*/ /*END*/ ORDER BY started_at DESC LIMIT /*limit*/100 ``` **Code:** ```typescript import { SQLBuilder } from '@digitalwalletcorp/sql-builder'; const builder = new SQLBuilder(); const template = `...`; // The SQL template from above // SCENARIO A: Only `statuses` and `limit` are provided. const bindEntity1 = { statuses: [1, 2, 5], limit: 50 }; const sql1 = builder.generateSQL(template, bindEntity1); console.log(sql1); // SCENARIO B: No filter conditions are met, so the entire WHERE clause is removed. const bindEntity2 = { limit: 100 }; const sql2 = builder.generateSQL(template, bindEntity2); console.log(sql2); ``` **Resulting SQL:** * SQL 1 (Scenario A): The `project_name` condition is excluded, but the `status` condition is included. ```sql SELECT id, project_name, status FROM activity WHERE 1 = 1 AND status IN (1,2,5) ORDER BY started_at DESC LIMIT 50 ``` * SQL 2 (Scenario B): Because no `/*IF...*/` conditions inside the `/*BEGIN*/.../*END*/` block were met, the entire block (including the `WHERE` keyword) is omitted. ```sql SELECT id, project_name, status FROM activity ORDER BY started_at DESC LIMIT 100 ``` ##### Example 2: FOR Loop Use a `/*FOR...*/` block to iterate over an array and generate SQL for each item. This is useful for building multiple `LIKE` conditions. **Template:** ```sql SELECT * FROM activity WHERE 1 = 0 /*FOR name:projectNames*/OR project_name LIKE '%' || /*name*/'default' || '%'/*END*/ ``` **Code:** ```typescript import { SQLBuilder } from '@digitalwalletcorp/sql-builder'; const builder = new SQLBuilder(); const template = `...`; // The SQL template from above const bindEntity = { projectNames: ['api', 'batch', 'frontend'] }; const sql = builder.generateSQL(template, bindEntity); console.log(sql); ``` **Resulting SQL:** ```sql SELECT * FROM activity WHERE 1 = 0 OR project_name LIKE '%' || 'api' || '%' OR project_name LIKE '%' || 'batch' || '%' OR project_name LIKE '%' || 'frontend' || '%' ``` ### 📚 API Reference ##### `new SQLBuilder(bindType?: 'postgres' | 'mysql' | 'oracle' | 'mssql')` Creates a new instance of the SQL builder. The bindType parameter is optional. If provided in the constructor, you do not need to specify it again when calling `generateParameterizedSQL`. This is useful for projects that consistently use a single database type. **Note on `bindType` Mapping:** While `bindType` explicitly names PostgreSQL, MySQL, Oracle and SQL Server the generated placeholder syntax is compatible with other SQL databases as follows: | `bindType` | Placeholder Syntax | Compatible Databases | Bind Parameter Type | | :------------- | :----------------- | :------------------- | :------------------ | | `postgres` | `$1`, `$2`, ... | **PostgreSQL** | `Array<any>` | | `mysql` | `?`, `?`, ... | **MySQL**, **SQLite** (for unnamed parameters) | `Array<any>` | | `oracle` | `:name`, `:age`, ... | **Oracle**, **SQLite** (for named parameters) | `Record<string, any>` | | `mssql` | `@name`, `@age`, ... | **SQL Server** (for named parameters) | `Record<string, any>` | ##### `generateSQL(template: string, entity: Record<string, any>): string` Generates a final SQL string by processing the template with the provided data entity. * `template`: The SQL template string containing S2Dao-style comments. * `entity`: A data object whose properties are used for evaluating conditions (`/*IF...*/`) and binding values (`/*variable*/`). * Returns: The generated SQL string. ⚠️ **Limitations** `generateSQL` is designed to generate **database-agnostic SQL**. Therefore, it does **not support database-specific or non-standard SQL syntax** that requires dialect-aware parsing or parameter binding. Examples of unsupported constructs include (but are not limited to): - PostgreSQL-specific syntax such as `ANY ($1::text[])` - Vendor-specific extensions that cannot be safely rendered as literals If you need to use database-specific features, use `generateParameterizedSQL` with an explicit `bindType`. ##### `generateParameterizedSQL(template: string, entity: Record<string, any>, bindType?: 'postgres' | 'mysql' | 'oracle' | 'mssql'): [string, Array<any> | Record<string, any>]` Generates a SQL string with placeholders for prepared statements and returns an array of bind parameters. This method is crucial for preventing SQL injection. * `template`: The SQL template string containing S2Dao-style comments. * `entity`: A data object whose properties are used for evaluating conditions (`/*IF...*/`) and binding values. * `bindType`: Specifies the database type ('postgres', 'mysql', or 'oracle') to determine the correct placeholder syntax (`$1`, `?`, or `:name`). * Returns: A tuple `[sql, bindParams]`. * `sql`: The generated SQL query with appropriate placeholders. * `bindParams`: An array of values (for PostgreSQL/MySQL) or an object of named values (for Oracle/SQL Server) to bind to the placeholders. ##### Example 3: Parameterized SQL with PostgreSQL **Template:** ```sql SELECT id, user_name FROM users /*BEGIN*/WHERE 1 = 1 /*IF userId != null*/AND user_id = /*userId*/0/*END*/ /*IF projectNames.length*/AND project_name IN /*projectNames*/('default_project')/*END*/ /*END*/ ``` **Code:** ```typescript import { SQLBuilder } from '@digitalwalletcorp/sql-builder'; const builder = new SQLBuilder(); const template = `...`; // The SQL template from above const bindEntity = { userId: 123, projectNames: ['project_a', 'project_b'] }; const [sql, params] = builder.generateParameterizedSQL(template, bindEntity, 'postgres'); console.log('SQL:', sql); console.log('Parameters:', params); ``` **Resulting SQL & Parameters:** ``` SQL: SELECT id, user_name FROM users WHERE 1 = 1 AND user_id = $1 AND project_name IN ($2, $3) Parameters: [ 123, 'project_a', 'project_b' ] ``` ##### Example 4: INSERT with NULL normalization **Template:** ```sql INSERT INTO users ( user_id, user_name, email, age ) VALUES ( /*userId*/0, /*userName*/'anonymous', /*email*/'dummy@example.com', /*age*/0 ) ``` **Code:** ```typescript import { SQLBuilder } from '@digitalwalletcorp/sql-builder'; const builder = new SQLBuilder(); const template = `...`; // The SQL template from above const bindEntity = { userId: 1001, userName: 'Alice', email: undefined, // optional column (not provided) age: null // optional column (explicitly null) }; const sql1 = builder.generateSQL( template, bindEntity ); console.log('SQL1:', sql1); const [sql2, params2] = builder.generateParameterizedSQL( template, bindEntity, 'postgres' ); console.log('SQL2:', sql2); console.log('Parameters2:', params2); ``` **Result:** ```text SQL1: INSERT INTO users ( user_id, user_name, email, age ) VALUES ( 1001, 'Alice', NULL, NULL ) SQL2: INSERT INTO users ( user_id, user_name, email, age ) VALUES ( $1, $2, $3, $4 ) Parameters2: [ 1001, 'Alice', null, null ] ``` **Notes:** - For both `generateSQL` and `generateParameterizedSQL`, `undefined` and `null` values are normalized to SQL `NULL`. - This behavior is especially important for INSERT / UPDATE statements, where the number of columns and values must always match. - NOT NULL constraint violations are intentionally left to the database. - If you need to handle `IS NULL` conditions explicitly, you can use `/*IF */` blocks as shown below: ```sql WHERE 1 = 1 /*IF param == null*/AND param IS NULL/*END*/ /*IF param != null*/AND param = /*param*/'abc'/*END*/ ``` **⚠️ Strict Binding Check:** - Every bind tag (e.g., `/*userId*/`) **must have a corresponding property** in the `bindEntity`. - If a property is missing in the `bindEntity`, the builder will throw an `Error` to prevent generating invalid or unintended SQL. - If you want to bind a `NULL` value, explicitly set the property to `null` or `undefined`. ### 🪄 Special Comment Syntax | Tag | Syntax | Description | | --- | --- | --- | | IF | `/*IF condition*/ ... /*END*/` | Includes the enclosed SQL fragment only if the `condition` evaluates to a truthy value. The condition is a JavaScript expression evaluated against the `entity` object. | | BEGIN | `/*BEGIN*/ ... /*END*/` | A wrapper block, typically for a `WHERE` clause. The entire block is included only if at least one `/*IF...*/` statement inside it is evaluated as true. This intelligently removes the `WHERE` keyword if no filters apply. | | FOR | `/*FOR item:collection*/ ... /*END*/` | Iterates over the `collection` array from the `entity`. For each loop, the enclosed SQL is generated, and the current value is available as the `item` variable for binding. | | Bind Variable | `/*variable*/` | Binds a value from the `entity`. It automatically formats values: strings are quoted (`'value'`), numbers are left as is (`123`), and arrays are turned into comma-separated lists in parentheses (`('a','b',123)`). | | END | `/*END*/` | Marks the end of an `IF`, `BEGIN`, or `FOR` block. | --- #### 💡 Supported Property Paths For `/*variable*/` (Bind Variable) tags and the `collection` part of `/*FOR item:collection*/` tags, you can specify a path to access properties within the `entity` object. * **Syntax:** `propertyName`, `nested.property`. * **Supported:** Direct property access (e.g., `user.id`, `order.items.length`). * **Unsupported:** Function calls (e.g., `user.name.trim()`, `order.items.map(...)`) or any complex JavaScript expressions. **Example:** * **Valid Expression:** `/*userId*/` (accesses `entity.userId` as simple property) * **Valid Expression:** `/*items*/` (accesses `entity.items` as array) * **Invalid Expression:** `/*userId.slice(0, 10)*/` (Function call) * **Invalid Expression:** `/*items.filter(...)*/` (Function call) --- #### 💡 Supported `IF` Condition Syntax The `condition` inside an `/*IF ...*/` tag is evaluated as a JavaScript expression against the `entity` object. To ensure security and maintain simplicity, only a **limited subset of JavaScript syntax** is supported. **Supported Operations:** * **Entity Property Access:** You can reference properties from the `entity` object (e.g., `propertyName`, `nested.property`). * **Object Property Access:** Access the `length`, `size` or other property of object (e.g., `String.length`, `Array.length`, `Set.size`, `Map.size`). * **Comparison Operators:** `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, `>=` * **Logical Operators:** `&&` (AND), `||` (OR), `!` (NOT) * **Literals:** Numbers (`123`, `0.5`), Booleans (`true`, `false`), `null`, `undefined`, and string literals (`'value'`, `"value"`). * **Parentheses:** `()` for grouping expressions. **Unsupported Operations (and will cause an error if used):** * **Function Calls:** You **cannot** call functions on properties (e.g., `user.name.startsWith('A')`, `array.map(...)`). **Example:** * **Valid Condition:** `user.age > 18 && user.name.length > 0 && user.id != null` * **Invalid Condition:** `user.name.startsWith('A')` (Function call) * **Invalid Condition:** `user.role = 'admin'` (Assignment) --- ### 📜 License This project is licensed under the MIT License. See the [LICENSE](https://opensource.org/licenses/MIT) file for details. ### 🎓 Advanced Usage & Examples This README covers the basic usage of the library. For more advanced use cases and a comprehensive look at how to verify its behavior, the test suite serves as practical and up-to-date documentation. We recommend Browse the test files to understand how to handle and verify the sequential, race-condition-free execution in various scenarios. You can find the test case in the `/test/specs` directory of our GitHub repository. - **[Explore our Test Suite for Advanced Examples](https://github.com/digitalwalletcorp/sql-builder/tree/main/test/specs)**