@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 +68 -6
- package/package.json +1 -1
- package/src/sql-builder.ts +152 -15
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src/sql-builder.ts
CHANGED
|
@@ -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[]
|
|
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.
|
|
286
|
-
|
|
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
|
|
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':
|