@digitalwalletcorp/sql-builder 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,7 +38,7 @@ You provide the `SQLBuilder` with a template string containing special, S2Dao-st
38
38
 
39
39
  This is the most common use case. The `WHERE` clause is built dynamically based on which properties exist in the `bindEntity`.
40
40
 
41
- ##### Template:
41
+ **Template:**
42
42
 
43
43
  ```sql
44
44
  SELECT
@@ -55,7 +55,7 @@ ORDER BY started_at DESC
55
55
  LIMIT /*limit*/100
56
56
  ```
57
57
 
58
- ##### Code:
58
+ **Code:**
59
59
 
60
60
  ```typescript
61
61
  import { SQLBuilder } from '@digitalwalletcorp/sql-builder';
@@ -81,7 +81,7 @@ const sql2 = builder.generateSQL(template, bindEntity2);
81
81
  console.log(sql2);
82
82
  ```
83
83
 
84
- ##### Resulting SQL:
84
+ **Resulting SQL:**
85
85
 
86
86
  * SQL 1 (Scenario A): The `project_name` condition is excluded, but the `status` condition is included.
87
87
 
@@ -114,7 +114,7 @@ LIMIT 100
114
114
 
115
115
  Use a `/*FOR...*/` block to iterate over an array and generate SQL for each item. This is useful for building multiple `LIKE` conditions.
116
116
 
117
- ##### Template:
117
+ **Template:**
118
118
 
119
119
  ```sql
120
120
  SELECT * FROM activity
@@ -122,7 +122,7 @@ WHERE 1 = 1
122
122
  /*FOR name:projectNames*/AND project_name LIKE '%' || /*name*/'default' || '%'/*END*/
123
123
  ```
124
124
 
125
- ##### Code:
125
+ **Code:**
126
126
 
127
127
  ```typescript
128
128
  import { SQLBuilder } from '@digitalwalletcorp/sql-builder';
@@ -138,7 +138,7 @@ const sql = builder.generateSQL(template, bindEntity);
138
138
  console.log(sql);
139
139
  ```
140
140
 
141
- ##### Resulting SQL:
141
+ **Resulting SQL:**
142
142
 
143
143
  ```sql
144
144
  SELECT * FROM activity
@@ -162,6 +162,68 @@ Generates a final SQL string by processing the template with the provided data e
162
162
  * `entity`: A data object whose properties are used for evaluating conditions (`/*IF...*/`) and binding values (`/*variable*/`).
163
163
  * Returns: The generated SQL string.
164
164
 
165
+ ##### `generateParameterizedSQL(template: string, entity: Record<string, any>, bindType: 'postgres' | 'mysql' | 'oracle'): [string, Array<any> | Record<string, any>]`
166
+
167
+ Generates a SQL string with placeholders for prepared statements and returns an array of bind parameters. This method is crucial for preventing SQL injection.
168
+
169
+ * `template`: The SQL template string containing S2Dao-style comments.
170
+ * `entity`: A data object whose properties are used for evaluating conditions (`/*IF...*/`) and binding values.
171
+ * `bindType`: Specifies the database type ('postgres', 'mysql', or 'oracle') to determine the correct placeholder syntax (`$1`, `?`, or `:name`).
172
+
173
+ **Note on `bindType` Mapping:**
174
+ While `bindType` explicitly names PostgreSQL, MySQL, and Oracle, the generated placeholder syntax is compatible with other SQL databases as follows:
175
+
176
+ | `bindType` | Placeholder Syntax | Compatible Databases | Bind Parameter Type |
177
+ | :------------- | :----------------- | :------------------- | :------------------ |
178
+ | `postgres` | `$1`, `$2`, ... | **PostgreSQL** | `Array<any>` |
179
+ | `mysql` | `?`, `?`, ... | **MySQL**, **SQLite**, **SQL Server** (for unnamed parameters) | `Array<any>` |
180
+ | `oracle` | `:name`, `:age`, ... | **Oracle**, **SQLite** (for named parameters) | `Record<string, any>` |
181
+
182
+ * Returns: A tuple `[sql, bindParams]`.
183
+ * `sql`: The generated SQL query with appropriate placeholders.
184
+ * `bindParams`: An array of values (for PostgreSQL/MySQL) or an object of named values (for Oracle) to bind to the placeholders.
185
+
186
+ ##### Example 3: Parameterized SQL with PostgreSQL
187
+
188
+ **Template:**
189
+
190
+ ```sql
191
+ SELECT
192
+ id,
193
+ user_name
194
+ FROM users
195
+ /*BEGIN*/WHERE
196
+ 1 = 1
197
+ /*IF userId != null*/AND user_id = /*userId*/0/*END*/
198
+ /*IF projectNames.length*/AND project_name IN /*projectNames*/('default_project')/*END*/
199
+ /*END*/
200
+ ```
201
+
202
+ **Code:**
203
+
204
+ ```typescript
205
+ import { SQLBuilder } from '@digitalwalletcorp/sql-builder';
206
+
207
+ const builder = new SQLBuilder();
208
+ const template = `...`; // The SQL template from above
209
+
210
+ const bindEntity = {
211
+ userId: 123,
212
+ projectNames: ['project_a', 'project_b']
213
+ };
214
+
215
+ const [sql, params] = builder.generateParameterizedSQL(template, bindEntity, 'postgres');
216
+ console.log('SQL:', sql);
217
+ console.log('Parameters:', params);
218
+ ```
219
+
220
+ **Resulting SQL & Parameters:**
221
+
222
+ ```
223
+ SQL: SELECT id, user_name FROM users WHERE 1 = 1 AND user_id = $1 AND project_name IN ($2, $3)
224
+ Parameters: [ 123, 'project_a', 'project_b' ]
225
+ ```
226
+
165
227
  ## 🪄 Special Comment Syntax
166
228
 
167
229
  | Tag | Syntax | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digitalwalletcorp/sql-builder",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "This is a library for building SQL",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -6,6 +6,27 @@ type ExtractValueType<T extends 'string' | 'array' | 'object'>
6
6
  ? any[] | undefined
7
7
  : Record<string, any> | undefined;
8
8
 
9
+ /**
10
+ * PostgreSQL: $1, $2... 配列
11
+ * MySQL: ? 配列
12
+ * SQLite: ?, $name, :name 配列またはオブジェクト
13
+ * SQL Server: ?, `@name` 配列またはオブジェクト
14
+ * Oracle: :name オブジェクト
15
+ *
16
+ * 以下をサポートする
17
+ * ・$1, $2 (postgres)
18
+ * ・? (mysql) SQLite, SQL Serverもこれで代替可能
19
+ * ・:name (oracle) SQLiteもこれで代替可能
20
+ */
21
+ type BindType = 'postgres' | 'mysql' | 'oracle';
22
+
23
+ type BindParameterType<T extends 'postgres' | 'mysql' | 'oracle'>
24
+ = T extends 'postgres'
25
+ ? any[]
26
+ : T extends 'mysql'
27
+ ? any[]
28
+ : Record<string, any>;
29
+
9
30
  interface TagContext {
10
31
  type: TagType;
11
32
  match: string;
@@ -90,6 +111,48 @@ export class SQLBuilder {
90
111
  return result;
91
112
  }
92
113
 
114
+ /**
115
+ * 指定したテンプレートにエンティティの値をバインド可能なプレースホルダー付きSQLを生成し、
116
+ * バインドパラメータと共にタプル型で返却する
117
+ *
118
+ * @param {string} template
119
+ * @param {Record<string, any>} entity
120
+ * @param {BindType} bindType
121
+ * @returns {[string, BindParameterType<T>]}
122
+ */
123
+ public generateParameterizedSQL<T extends BindType>(template: string, entity: Record<string, any>, bindType: T): [string, BindParameterType<T>] {
124
+
125
+ let bindParams: BindParameterType<T>;
126
+ switch (bindType) {
127
+ case 'postgres':
128
+ case 'mysql':
129
+ bindParams = [] as unknown as BindParameterType<T>;
130
+ break;
131
+ case 'oracle':
132
+ bindParams = {} as BindParameterType<T>;
133
+ break;
134
+ default:
135
+ throw new Error(`Unsupported bind type: ${bindType}`);
136
+ }
137
+
138
+ /**
139
+ * 「\/* *\/」で囲まれたすべての箇所を抽出
140
+ */
141
+ const allMatchers = template.match(this.REGEX_TAG_PATTERN);
142
+ if (!allMatchers) {
143
+ return [template, bindParams];
144
+ }
145
+
146
+ const tagContexts = this.createTagContexts(template);
147
+ const pos: SharedIndex = { index: 0 };
148
+ const result = this.parse(pos, template, entity, tagContexts, {
149
+ bindType: bindType,
150
+ bindIndex: 1,
151
+ bindParams: bindParams
152
+ });
153
+ return [result, bindParams];
154
+ }
155
+
93
156
  /**
94
157
  * テンプレートに含まれるタグ構成を解析してコンテキストを返す
95
158
  *
@@ -218,9 +281,17 @@ export class SQLBuilder {
218
281
  * @param {string} template
219
282
  * @param {Record<string, any>} entity
220
283
  * @param {TagContext[]} tagContexts
284
+ * @param {*} [options]
285
+ * ├ bindType BindType
286
+ * ├ bindIndex number
287
+ * ├ bindParams BindParameterType<T>
221
288
  * @returns {string}
222
289
  */
223
- private parse(pos: SharedIndex, template: string, entity: Record<string, any>, tagContexts: TagContext[]): string {
290
+ private parse<T extends BindType>(pos: SharedIndex, template: string, entity: Record<string, any>, tagContexts: TagContext[], options?: {
291
+ bindType: T,
292
+ bindIndex: number,
293
+ bindParams: BindParameterType<T>
294
+ }): string {
224
295
  let result = '';
225
296
  for (const tagContext of tagContexts) {
226
297
  switch (tagContext.type) {
@@ -228,7 +299,7 @@ export class SQLBuilder {
228
299
  result += template.substring(pos.index, tagContext.startIndex);
229
300
  pos.index = tagContext.endIndex;
230
301
  // BEGINのときは無条件にsubに対して再帰呼び出し
231
- result += this.parse(pos, template, entity, tagContext.sub);
302
+ result += this.parse(pos, template, entity, tagContext.sub, options);
232
303
  break;
233
304
  }
234
305
  case 'IF': {
@@ -237,7 +308,7 @@ export class SQLBuilder {
237
308
  if (this.evaluateCondition(tagContext.contents, entity)) {
238
309
  // IF条件が成立する場合はsubに対して再帰呼び出し
239
310
  tagContext.status = 10;
240
- result += this.parse(pos, template, entity, tagContext.sub);
311
+ result += this.parse(pos, template, entity, tagContext.sub, options);
241
312
  } else {
242
313
  // IF条件が成立しない場合は再帰呼び出しせず、subのENDタグのendIndexをposに設定
243
314
  const endTagContext = tagContext.sub[tagContext.sub.length - 1];
@@ -255,7 +326,7 @@ export class SQLBuilder {
255
326
  for (const value of array) {
256
327
  // 再帰呼び出しによりposが進むので、ループのたびにposを戻す必要がある
257
328
  pos.index = tagContext.endIndex;
258
- result += this.parse(pos, template, { [bindName]: value }, tagContext.sub);
329
+ result += this.parse(pos, template, { [bindName]: value }, tagContext.sub, options);
259
330
  // FORループするときは各行で改行する
260
331
  result += '\n';
261
332
  }
@@ -282,8 +353,61 @@ export class SQLBuilder {
282
353
  case 'BIND': {
283
354
  result += template.substring(pos.index, tagContext.startIndex);
284
355
  pos.index = tagContext.endIndex;
285
- const value = this.extractValue(tagContext.contents, entity);
286
- result += value == null ? '' : String(value);
356
+ const value = this.getProperty(entity, tagContext.contents);
357
+ switch (options?.bindType) {
358
+ case 'postgres': {
359
+ // PostgreSQL形式の場合、$Nでバインドパラメータを展開
360
+ if (Array.isArray(value)) {
361
+ const placeholders: string[] = [];
362
+ for (const item of value) {
363
+ placeholders.push(`$${options.bindIndex++}`);
364
+ (options.bindParams as any[]).push(item);
365
+ }
366
+ result += `(${placeholders.join(',')})`; // IN ($1,$2,$3)
367
+ } else {
368
+ result += `$${options.bindIndex++}`;
369
+ (options.bindParams as any[]).push(value);
370
+ }
371
+ break;
372
+ }
373
+ case 'mysql': {
374
+ // MySQL形式の場合、?でバインドパラメータを展開
375
+ if (Array.isArray(value)) {
376
+ const placeholders: string[] = [];
377
+ for (const item of value) {
378
+ placeholders.push('?');
379
+ (options.bindParams as any[]).push(item);
380
+ }
381
+ result += `(${placeholders.join(',')})`; // IN (?,?,?)
382
+ } else {
383
+ result += '?';
384
+ (options.bindParams as any[]).push(value);
385
+ }
386
+ break;
387
+ }
388
+ case 'oracle': {
389
+ // Oracle形式の場合、名前付きバインドでバインドパラメータを展開
390
+ if (Array.isArray(value)) {
391
+ const placeholders: string[] = [];
392
+ for (let i = 0; i < value.length; i++) {
393
+ // 名前付きバインドで配列の場合は名前が重複する可能性があるので枝番を付与
394
+ const paramName = `${tagContext.contents}_${i}`; // :projectNames_0, :projectNames_1
395
+ placeholders.push(`:${paramName}`);
396
+ (options.bindParams as Record<string, any>)[paramName] = value[i];
397
+ }
398
+ result += `(${placeholders.join(',')})`; // IN (:p_0,:p_1,:p3)
399
+ } else {
400
+ result += `:${tagContext.contents}`;
401
+ (options.bindParams as Record<string, any>)[tagContext.contents] = value;
402
+ }
403
+ break;
404
+ }
405
+ default: {
406
+ // generateSQLの場合
407
+ const escapedValue = this.extractValue(tagContext.contents, entity);
408
+ result += value == null ? '' : escapedValue;
409
+ }
410
+ }
287
411
  break;
288
412
  }
289
413
  default:
@@ -391,8 +515,29 @@ export class SQLBuilder {
391
515
  }
392
516
  }
393
517
 
518
+ /**
519
+ * entityで指定したオブジェクトからドットで連結されたプロパティキーに該当する値を取得する
520
+ *
521
+ * @param {Record<string, any>} entity
522
+ * @param {string} property
523
+ * @returns {any}
524
+ */
525
+ private getProperty(entity: Record<string, any>, property: string): any {
526
+ const propertyPath = property.split('.');
527
+ let value = entity;
528
+ for (const prop of propertyPath) {
529
+ if (value && value.hasOwnProperty(prop)) {
530
+ value = value[prop];
531
+ } else {
532
+ return undefined;
533
+ }
534
+ }
535
+ return value;
536
+ }
537
+
394
538
  /**
395
539
  * entityからparamで指定した値を文字列で取得する
540
+ * entityの値がstringの場合、SQLインジェクションの危険のある文字はエスケープする
396
541
  *
397
542
  * * 返却する値が配列の場合は丸括弧で括り、各項目をカンマで区切る
398
543
  * ('a', 'b', 'c')
@@ -412,15 +557,7 @@ export class SQLBuilder {
412
557
  responseType?: T
413
558
  }): ExtractValueType<T> {
414
559
  try {
415
- const propertyPath = property.split('.');
416
- let value: any = entity;
417
- for (const prop of propertyPath) {
418
- if (value && value.hasOwnProperty(prop)) {
419
- value = value[prop];
420
- } else {
421
- return undefined as ExtractValueType<T>;
422
- }
423
- }
560
+ const value = this.getProperty(entity, property);
424
561
  let result = '';
425
562
  switch (options?.responseType) {
426
563
  case 'array':