@digitalwalletcorp/sql-builder 1.0.2 → 1.2.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 |
@@ -172,6 +234,49 @@ Generates a final SQL string by processing the template with the provided data e
172
234
  | 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)`). |
173
235
  | END | `/*END*/` | Marks the end of an `IF`, `BEGIN`, or `FOR` block. |
174
236
 
237
+ ---
238
+ ### 💡 Supported Property Paths
239
+
240
+ 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.
241
+
242
+ * **Syntax:** `propertyName`, `nested.property`.
243
+ * **Supported:** Direct property access (e.g., `user.id`, `order.items.length`).
244
+ * **Unsupported:** Function calls (e.g., `user.name.trim()`, `order.items.map(...)`) or any complex JavaScript expressions.
245
+
246
+ **Example:**
247
+
248
+ * **Valid Expression:** `/*userId*/` (accesses `entity.userId` as simple property)
249
+ * **Valid Expression:** `/*items*/` (accesses `entity.items` as array)
250
+ * **Invalid Expression:** `/*userId.slice(0, 10)*/` (Function call)
251
+ * **Invalid Expression:** `/*items.filter(...)*/` (Function call)
252
+
253
+ ---
254
+
255
+ ### 💡 Supported `IF` Condition Syntax
256
+
257
+ 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.
258
+
259
+ **Supported Operations:**
260
+
261
+ * **Entity Property Access:** You can reference properties from the `entity` object (e.g., `propertyName`, `nested.property`).
262
+ * **Object Property Access:** Access the `length`, `size` or other property of object (e.g., `String.length`, `Array.length`, `Set.size`, `Map.size`).
263
+ * **Comparison Operators:** `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, `>=`
264
+ * **Logical Operators:** `&&` (AND), `||` (OR), `!` (NOT)
265
+ * **Literals:** Numbers (`123`, `0.5`), Booleans (`true`, `false`), `null`, `undefined`, and string literals (`'value'`, `"value"`).
266
+ * **Parentheses:** `()` for grouping expressions.
267
+
268
+ **Unsupported Operations (and will cause an error if used):**
269
+
270
+ * **Function Calls:** You **cannot** call functions on properties (e.g., `user.name.startsWith('A')`, `array.map(...)`).
271
+
272
+ **Example:**
273
+
274
+ * **Valid Condition:** `user.age > 18 && user.name.length > 0 && user.id != null`
275
+ * **Invalid Condition:** `user.name.startsWith('A')` (Function call)
276
+ * **Invalid Condition:** `user.role = 'admin'` (Assignment)
277
+
278
+ ---
279
+
175
280
  ## 📜 License
176
281
 
177
282
  This project is licensed under the MIT License. See the [LICENSE](https://opensource.org/licenses/MIT) file for details.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digitalwalletcorp/sql-builder",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "This is a library for building SQL",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -0,0 +1,334 @@
1
+ import * as common from './common';
2
+
3
+ type Token =
4
+ | { type: 'IDENTIFIER'; value: string } // 例: param, length
5
+ | { type: 'OPERATOR'; value: string } // 例: >, &&, ===, !=
6
+ | { type: 'NUMBER'; value: number } // 例: 100.123
7
+ | { type: 'BOOLEAN'; value: boolean } // 例: true, false
8
+ | { type: 'NULL' }
9
+ | { type: 'UNDEFINED' }
10
+ | { type: 'STRING'; value: string } // 例: 'abc'
11
+ | { type: 'PARENTHESIS'; value: '(' | ')' };
12
+
13
+ type RpnToken = Token | { type: 'MEMBER_ACCESS_PART', value: string };
14
+
15
+ // 演算子の優先順位と結合性 (より長い演算子を先に定義)
16
+ const PRECEDENCE: {
17
+ [op: string]: {
18
+ precedence: number;
19
+ associativity: 'left' | 'right'
20
+ }
21
+ } = {
22
+ '||': { precedence: 1, associativity: 'left' },
23
+ '&&': { precedence: 2, associativity: 'left' },
24
+ '==': { precedence: 3, associativity: 'left' },
25
+ '!=': { precedence: 3, associativity: 'left' },
26
+ '===': { precedence: 3, associativity: 'left' },
27
+ '!==': { precedence: 3, associativity: 'left' },
28
+ '<': { precedence: 4, associativity: 'left' },
29
+ '<=': { precedence: 4, associativity: 'left' },
30
+ '>': { precedence: 4, associativity: 'left' },
31
+ '>=': { precedence: 4, associativity: 'left' },
32
+ '!': { precedence: 5, associativity: 'right' } // 単項演算子は高優先度
33
+ };
34
+
35
+ /**
36
+ * SQLBuilderの条件文(*IF*)で指定された条件文字列を解析するためのAST。
37
+ * JavaScriptの文法をカバーするものではなく、SQLBuilderで利用可能な限定的な構文のみサポートする。
38
+ */
39
+ export class AbstractSyntaxTree {
40
+
41
+ /**
42
+ * 与えられた条件文字列を構文解析し、entityに対する条件として成立するか評価する
43
+ *
44
+ * @param {string} condition "params != null && params.length > 10" のような条件
45
+ * @param {Record<string, any>} entity
46
+ * @returns {boolean}
47
+ */
48
+ public evaluateCondition(condition: string, entity: Record<string, any>): boolean {
49
+ try {
50
+ const tokens = this.tokenize(condition); // トークン化
51
+ const rpnTokens = this.shuntingYard(tokens); // RPN変換
52
+ const result = this.evaluateRpn(rpnTokens, entity); // 評価
53
+ return result;
54
+ } catch (error) {
55
+ console.warn('Error evaluating condition:', condition, entity, error);
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * 与えられた条件文字列をトークンに分割する
62
+ *
63
+ * @param {string} condition
64
+ * @returns {Token[]}
65
+ */
66
+ public tokenize(condition: string): Token[] {
67
+ const tokens: Token[] = [];
68
+ let i = 0;
69
+ while (i < condition.length) {
70
+ const char = condition[i];
71
+ // 空白をスキップ
72
+ if (/\s/.test(char)) {
73
+ i++;
74
+ continue;
75
+ }
76
+
77
+ // 演算子 (長いものからチェック)
78
+ // 3桁定義
79
+ const chunk3 = condition.substring(i, i + 3);
80
+ switch (chunk3) {
81
+ case '===':
82
+ case '!==':
83
+ tokens.push({ type: 'OPERATOR', value: chunk3 });
84
+ i += 3;
85
+ continue;
86
+ default:
87
+ }
88
+ // 2桁定義
89
+ const chunk2 = condition.substring(i, i + 2);
90
+ switch (chunk2) {
91
+ case '==':
92
+ case '!=':
93
+ case '<=':
94
+ case '>=':
95
+ case '&&':
96
+ case '||':
97
+ tokens.push({ type: 'OPERATOR', value: chunk2 });
98
+ i += 2;
99
+ continue;
100
+ default:
101
+ }
102
+ // 1桁定義
103
+ const chunk1 = char;
104
+ switch (chunk1) {
105
+ case '>':
106
+ case '<':
107
+ case '!':
108
+ case '=':
109
+ tokens.push({ type: 'OPERATOR', value: chunk1 });
110
+ i += 1;
111
+ continue;
112
+ // case '.':
113
+ // tokens.push({ type: 'IDENTIFIER', value: chunk1 }); // '.'も識別子の一部として扱う
114
+ // i += 1;
115
+ // continue;
116
+ case '(':
117
+ case ')':
118
+ tokens.push({ type: 'PARENTHESIS', value: chunk1 });
119
+ i += 1;
120
+ continue;
121
+ default:
122
+ }
123
+
124
+ const reststring = condition.substring(i); // 現在のインデックスから末尾までの文字列
125
+ // 数値リテラル
126
+ const numMatch = reststring.match(/^-?\d+(\.\d+)?/);
127
+ if (numMatch) {
128
+ tokens.push({ type: 'NUMBER', value: parseFloat(numMatch[0]) });
129
+ i += numMatch[0].length;
130
+ continue;
131
+ }
132
+
133
+ // 文字列リテラル
134
+ switch (chunk1) {
135
+ case '\'':
136
+ case '"':
137
+ const quote = chunk1;
138
+ let j = i + 1;
139
+ let strValue = '';
140
+ while (j < condition.length && condition[j] !== quote) {
141
+ // エスケープ文字の処理 (\' や \\) は必要に応じて追加
142
+ if (condition[j] === '\\' && j + 1 < condition.length) {
143
+ strValue += condition[j+1];
144
+ j += 2;
145
+ } else {
146
+ strValue += condition[j];
147
+ j++;
148
+ }
149
+ }
150
+ if (condition[j] === quote) {
151
+ tokens.push({ type: 'STRING', value: strValue });
152
+ i = j + 1;
153
+ continue;
154
+ } else {
155
+ // クォートが閉じられていない
156
+ throw new Error('Unterminated string literal');
157
+ }
158
+ default:
159
+ }
160
+
161
+ // 識別子 (変数名, true, false, null, undefined, length)
162
+ const identMatch = reststring.match(/^[a-zA-Z_][a-zA-Z0-9_.]*/); // ドットを含む識別子
163
+ if (identMatch) {
164
+ const ident = identMatch[0];
165
+ switch (ident) {
166
+ case 'true':
167
+ tokens.push({ type: 'BOOLEAN', value: true });
168
+ break;
169
+ case 'false':
170
+ tokens.push({ type: 'BOOLEAN', value: false });
171
+ break;
172
+ case 'null':
173
+ tokens.push({ type: 'NULL' });
174
+ break;
175
+ case 'undefined':
176
+ tokens.push({ type: 'UNDEFINED' });
177
+ break;
178
+ default:
179
+ tokens.push({ type: 'IDENTIFIER', value: ident }); // プロパティ名
180
+ }
181
+ i += ident.length;
182
+ continue;
183
+ }
184
+
185
+ // 未知の文字
186
+ throw new Error(`Unexpected character in condition: ${char} at index ${i}`);
187
+ }
188
+ return tokens;
189
+ }
190
+
191
+ /**
192
+ * Shunting Yardアルゴリズムで構文を逆ポーランド記法(Reverse Polish Notation)に変換する
193
+ *
194
+ * @param {Token[]} tokens
195
+ * @returns {RpnToken[]}
196
+ */
197
+ private shuntingYard(tokens: Token[]): RpnToken[] {
198
+ const output: RpnToken[] = [];
199
+ const operatorStack: Token[] = [];
200
+
201
+ for (const token of tokens) {
202
+ const type = token.type;
203
+ switch (type) {
204
+ case 'NUMBER':
205
+ case 'BOOLEAN':
206
+ case 'NULL':
207
+ case 'UNDEFINED':
208
+ case 'STRING':
209
+ output.push(token);
210
+ break;
211
+ case 'IDENTIFIER':
212
+ // 識別子(プロパティパスの可能性)をそのまま出力
213
+ output.push(token);
214
+ break;
215
+ case 'OPERATOR':
216
+ const op1 = token;
217
+ while (operatorStack.length) {
218
+ // TODO: 型キャストが必要になるので、Geniericsを強化する
219
+ const op2 = operatorStack[operatorStack.length - 1] as { type: string, value: string };
220
+
221
+ // 括弧内は処理しない
222
+ if (op2.value === '(') {
223
+ break;
224
+ }
225
+
226
+ // 優先順位のルールに従う
227
+ if (
228
+ (PRECEDENCE[op1.value].associativity === 'left' && PRECEDENCE[op1.value].precedence <= PRECEDENCE[op2.value].precedence)
229
+ // ||
230
+ // (PRECEDENCE[op1.value].associativity === 'right' && PRECEDENCE[op1.value].precedence < PRECEDENCE[op2.value].precedence)
231
+ ) {
232
+ output.push(operatorStack.pop()!);
233
+ } else {
234
+ break;
235
+ }
236
+ }
237
+ operatorStack.push(op1);
238
+ break;
239
+ case 'PARENTHESIS':
240
+ if (token.value === '(') {
241
+ operatorStack.push(token);
242
+ } else if (token.value === ')') {
243
+ let foundLeftParen = false;
244
+ while (operatorStack.length > 0) {
245
+ const op = operatorStack.pop()!;
246
+ if ('value' in op && op.value === '(') {
247
+ foundLeftParen = true;
248
+ break;
249
+ }
250
+ output.push(op);
251
+ }
252
+ if (!foundLeftParen) {
253
+ throw new Error('Mismatched parentheses');
254
+ }
255
+ }
256
+ break;
257
+ default:
258
+ throw new Error(`Unexpected token type: ${type}`);
259
+ }
260
+ }
261
+
262
+ while (operatorStack.length) {
263
+ const op = operatorStack.pop()!;
264
+ if ('value' in op && (op.value === '(' || op.value === ')')) {
265
+ throw new Error('Mismatched parentheses');
266
+ }
267
+ output.push(op);
268
+ }
269
+
270
+ return output;
271
+ }
272
+
273
+ /**
274
+ * 逆ポーランド記法(Reverse Polish Notation)のトークンを評価する
275
+ *
276
+ * @param {RpnToken[]} rpnTokens
277
+ * @param {Record<string, any>} entity
278
+ * @returns {boolean}
279
+ */
280
+ private evaluateRpn(rpnTokens: RpnToken[], entity: Record<string, any>): boolean {
281
+ const stack: any[] = [];
282
+
283
+ for (const token of rpnTokens) {
284
+ const type = token.type;
285
+ switch (type) {
286
+ case 'NUMBER':
287
+ case 'BOOLEAN':
288
+ case 'NULL':
289
+ case 'UNDEFINED':
290
+ case 'STRING':
291
+ if ('value' in token) {
292
+ stack.push(token.value);
293
+ }
294
+ break;
295
+ case 'IDENTIFIER':
296
+ stack.push(common.getProperty(entity, token.value));
297
+ break;
298
+ case 'OPERATOR':
299
+ // 単項演算子 '!'
300
+ if (token.value === '!') {
301
+ const operand = stack.pop();
302
+ stack.push(!operand);
303
+ break;
304
+ }
305
+
306
+ // 二項演算子
307
+ const right = stack.pop();
308
+ const left = stack.pop();
309
+
310
+ switch (token.value) {
311
+ case '==': stack.push(left == right); break;
312
+ case '!=': stack.push(left != right); break;
313
+ case '===': stack.push(left === right); break;
314
+ case '!==': stack.push(left !== right); break;
315
+ case '<': stack.push(left < right); break;
316
+ case '<=': stack.push(left <= right); break;
317
+ case '>': stack.push(left > right); break;
318
+ case '>=': stack.push(left >= right); break;
319
+ case '&&': stack.push(left && right); break;
320
+ case '||': stack.push(left || right); break;
321
+ default: throw new Error(`Unknown operator: ${token.value}`);
322
+ }
323
+ break;
324
+ default:
325
+ throw new Error(`Unexpected token type in RPN: ${type}`);
326
+ }
327
+ }
328
+
329
+ if (stack.length !== 1) {
330
+ throw new Error('Invalid expression');
331
+ }
332
+ return !!stack[0];
333
+ }
334
+ }
package/src/common.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * entityで指定したオブジェクトからドットで連結されたプロパティキーに該当する値を取得する
3
+ *
4
+ * @param {Record<string, any>} entity
5
+ * @param {string} property
6
+ * @returns {any}
7
+ */
8
+ export function getProperty(entity: Record<string, any>, property: string): any {
9
+ const propertyPath = property.split('.');
10
+ let value = entity;
11
+ for (const prop of propertyPath) {
12
+ if (value && value.hasOwnProperty(prop)) {
13
+ value = value[prop];
14
+ } else {
15
+ return undefined;
16
+ }
17
+ }
18
+ return value;
19
+ }
@@ -1,3 +1,6 @@
1
+ import * as common from './common';
2
+ import { AbstractSyntaxTree } from './abstract-syntax-tree';
3
+
1
4
  type TagType = 'BEGIN' | 'IF' | 'FOR' | 'END' | 'BIND';
2
5
  type ExtractValueType<T extends 'string' | 'array' | 'object'>
3
6
  = T extends 'string'
@@ -6,6 +9,27 @@ type ExtractValueType<T extends 'string' | 'array' | 'object'>
6
9
  ? any[] | undefined
7
10
  : Record<string, any> | undefined;
8
11
 
12
+ /**
13
+ * PostgreSQL: $1, $2... 配列
14
+ * MySQL: ? 配列
15
+ * SQLite: ?, $name, :name 配列またはオブジェクト
16
+ * SQL Server: ?, `@name` 配列またはオブジェクト
17
+ * Oracle: :name オブジェクト
18
+ *
19
+ * 以下をサポートする
20
+ * ・$1, $2 (postgres)
21
+ * ・? (mysql) SQLite, SQL Serverもこれで代替可能
22
+ * ・:name (oracle) SQLiteもこれで代替可能
23
+ */
24
+ type BindType = 'postgres' | 'mysql' | 'oracle';
25
+
26
+ type BindParameterType<T extends 'postgres' | 'mysql' | 'oracle'>
27
+ = T extends 'postgres'
28
+ ? any[]
29
+ : T extends 'mysql'
30
+ ? any[]
31
+ : Record<string, any>;
32
+
9
33
  interface TagContext {
10
34
  type: TagType;
11
35
  match: string;
@@ -90,6 +114,48 @@ export class SQLBuilder {
90
114
  return result;
91
115
  }
92
116
 
117
+ /**
118
+ * 指定したテンプレートにエンティティの値をバインド可能なプレースホルダー付きSQLを生成し、
119
+ * バインドパラメータと共にタプル型で返却する
120
+ *
121
+ * @param {string} template
122
+ * @param {Record<string, any>} entity
123
+ * @param {BindType} bindType
124
+ * @returns {[string, BindParameterType<T>]}
125
+ */
126
+ public generateParameterizedSQL<T extends BindType>(template: string, entity: Record<string, any>, bindType: T): [string, BindParameterType<T>] {
127
+
128
+ let bindParams: BindParameterType<T>;
129
+ switch (bindType) {
130
+ case 'postgres':
131
+ case 'mysql':
132
+ bindParams = [] as unknown as BindParameterType<T>;
133
+ break;
134
+ case 'oracle':
135
+ bindParams = {} as BindParameterType<T>;
136
+ break;
137
+ default:
138
+ throw new Error(`Unsupported bind type: ${bindType}`);
139
+ }
140
+
141
+ /**
142
+ * 「\/* *\/」で囲まれたすべての箇所を抽出
143
+ */
144
+ const allMatchers = template.match(this.REGEX_TAG_PATTERN);
145
+ if (!allMatchers) {
146
+ return [template, bindParams];
147
+ }
148
+
149
+ const tagContexts = this.createTagContexts(template);
150
+ const pos: SharedIndex = { index: 0 };
151
+ const result = this.parse(pos, template, entity, tagContexts, {
152
+ bindType: bindType,
153
+ bindIndex: 1,
154
+ bindParams: bindParams
155
+ });
156
+ return [result, bindParams];
157
+ }
158
+
93
159
  /**
94
160
  * テンプレートに含まれるタグ構成を解析してコンテキストを返す
95
161
  *
@@ -101,57 +167,56 @@ export class SQLBuilder {
101
167
  /**
102
168
  * 「\/* *\/」で囲まれたすべての箇所を抽出
103
169
  */
104
- const rootMatcher = template.match(this.REGEX_TAG_PATTERN);
170
+ const matches = template.matchAll(this.REGEX_TAG_PATTERN);
105
171
 
106
172
  // まず最初にREGEX_TAG_PATTERNで解析した情報をそのままフラットにTagContextの配列に格納
107
173
  let pos = 0;
108
174
  const tagContexts: TagContext[] = [];
109
- if (rootMatcher) {
110
- for (const matchContent of rootMatcher) {
111
- const index = template.indexOf(matchContent, pos);
112
- pos = index + 1;
113
- const tagContext: TagContext = {
114
- type: null as unknown as TagType,
115
- match: matchContent,
116
- contents: '',
117
- startIndex: index,
118
- endIndex: index + matchContent.length,
119
- sub: [],
120
- parent: null,
121
- status: 0
122
- };
123
- switch (true) {
124
- case matchContent === '/*BEGIN*/': {
125
- tagContext.type = 'BEGIN';
126
- break;
127
- }
128
- case matchContent.startsWith('/*IF'): {
129
- tagContext.type = 'IF';
130
- const contentMatcher = matchContent.match(/^\/\*IF\s+(.*?)\*\/$/);
131
- tagContext.contents = contentMatcher && contentMatcher[1] || '';
132
- break;
133
- }
134
- case matchContent.startsWith('/*FOR'): {
135
- tagContext.type = 'FOR';
136
- const contentMatcher = matchContent.match(/^\/\*FOR\s+(.*?)\*\/$/);
137
- tagContext.contents = contentMatcher && contentMatcher[1] || '';
138
- break;
139
- }
140
- case matchContent === '/*END*/': {
141
- tagContext.type = 'END';
142
- break;
143
- }
144
- default: {
145
- tagContext.type = 'BIND';
146
- const contentMatcher = matchContent.match(/\/\*(.*?)\*\//);
147
- tagContext.contents = contentMatcher && contentMatcher[1] || '';
148
- // ダミー値の終了位置をendIndexに設定
149
- const dummyEndIndex = this.getDummyParamEndIndex(template, tagContext);
150
- tagContext.endIndex = dummyEndIndex;
151
- }
175
+ for (const match of matches) {
176
+ const matchContent = match[0];
177
+ const index = match.index;
178
+ pos = index + 1;
179
+ const tagContext: TagContext = {
180
+ type: 'BIND', // ダミーの初期値。後続処理で適切なタイプに変更する。
181
+ match: matchContent,
182
+ contents: '',
183
+ startIndex: index,
184
+ endIndex: index + matchContent.length,
185
+ sub: [],
186
+ parent: null,
187
+ status: 0
188
+ };
189
+ switch (true) {
190
+ case matchContent === '/*BEGIN*/': {
191
+ tagContext.type = 'BEGIN';
192
+ break;
193
+ }
194
+ case matchContent.startsWith('/*IF'): {
195
+ tagContext.type = 'IF';
196
+ const contentMatcher = matchContent.match(/^\/\*IF\s+(.*?)\*\/$/);
197
+ tagContext.contents = contentMatcher && contentMatcher[1] || '';
198
+ break;
199
+ }
200
+ case matchContent.startsWith('/*FOR'): {
201
+ tagContext.type = 'FOR';
202
+ const contentMatcher = matchContent.match(/^\/\*FOR\s+(.*?)\*\/$/);
203
+ tagContext.contents = contentMatcher && contentMatcher[1] || '';
204
+ break;
205
+ }
206
+ case matchContent === '/*END*/': {
207
+ tagContext.type = 'END';
208
+ break;
209
+ }
210
+ default: {
211
+ tagContext.type = 'BIND';
212
+ const contentMatcher = matchContent.match(/\/\*(.*?)\*\//);
213
+ tagContext.contents = contentMatcher && contentMatcher[1] || '';
214
+ // ダミー値の終了位置をendIndexに設定
215
+ const dummyEndIndex = this.getDummyParamEndIndex(template, tagContext);
216
+ tagContext.endIndex = dummyEndIndex;
152
217
  }
153
- tagContexts.push(tagContext);
154
218
  }
219
+ tagContexts.push(tagContext);
155
220
  }
156
221
 
157
222
  // できあがったTagContextの配列から、BEGEN、IFの場合は次の対応するENDが出てくるまでをsubに入れ直して構造化し、
@@ -218,9 +283,17 @@ export class SQLBuilder {
218
283
  * @param {string} template
219
284
  * @param {Record<string, any>} entity
220
285
  * @param {TagContext[]} tagContexts
286
+ * @param {*} [options]
287
+ * ├ bindType BindType
288
+ * ├ bindIndex number
289
+ * ├ bindParams BindParameterType<T>
221
290
  * @returns {string}
222
291
  */
223
- private parse(pos: SharedIndex, template: string, entity: Record<string, any>, tagContexts: TagContext[]): string {
292
+ private parse<T extends BindType>(pos: SharedIndex, template: string, entity: Record<string, any>, tagContexts: TagContext[], options?: {
293
+ bindType: T,
294
+ bindIndex: number,
295
+ bindParams: BindParameterType<T>
296
+ }): string {
224
297
  let result = '';
225
298
  for (const tagContext of tagContexts) {
226
299
  switch (tagContext.type) {
@@ -228,7 +301,7 @@ export class SQLBuilder {
228
301
  result += template.substring(pos.index, tagContext.startIndex);
229
302
  pos.index = tagContext.endIndex;
230
303
  // BEGINのときは無条件にsubに対して再帰呼び出し
231
- result += this.parse(pos, template, entity, tagContext.sub);
304
+ result += this.parse(pos, template, entity, tagContext.sub, options);
232
305
  break;
233
306
  }
234
307
  case 'IF': {
@@ -237,7 +310,7 @@ export class SQLBuilder {
237
310
  if (this.evaluateCondition(tagContext.contents, entity)) {
238
311
  // IF条件が成立する場合はsubに対して再帰呼び出し
239
312
  tagContext.status = 10;
240
- result += this.parse(pos, template, entity, tagContext.sub);
313
+ result += this.parse(pos, template, entity, tagContext.sub, options);
241
314
  } else {
242
315
  // IF条件が成立しない場合は再帰呼び出しせず、subのENDタグのendIndexをposに設定
243
316
  const endTagContext = tagContext.sub[tagContext.sub.length - 1];
@@ -255,7 +328,10 @@ export class SQLBuilder {
255
328
  for (const value of array) {
256
329
  // 再帰呼び出しによりposが進むので、ループのたびにposを戻す必要がある
257
330
  pos.index = tagContext.endIndex;
258
- result += this.parse(pos, template, { [bindName]: value }, tagContext.sub);
331
+ result += this.parse(pos, template, {
332
+ ...entity,
333
+ [bindName]: value
334
+ }, tagContext.sub, options);
259
335
  // FORループするときは各行で改行する
260
336
  result += '\n';
261
337
  }
@@ -282,8 +358,61 @@ export class SQLBuilder {
282
358
  case 'BIND': {
283
359
  result += template.substring(pos.index, tagContext.startIndex);
284
360
  pos.index = tagContext.endIndex;
285
- const value = this.extractValue(tagContext.contents, entity);
286
- result += value == null ? '' : String(value);
361
+ const value = common.getProperty(entity, tagContext.contents);
362
+ switch (options?.bindType) {
363
+ case 'postgres': {
364
+ // PostgreSQL形式の場合、$Nでバインドパラメータを展開
365
+ if (Array.isArray(value)) {
366
+ const placeholders: string[] = [];
367
+ for (const item of value) {
368
+ placeholders.push(`$${options.bindIndex++}`);
369
+ (options.bindParams as any[]).push(item);
370
+ }
371
+ result += `(${placeholders.join(',')})`; // IN ($1,$2,$3)
372
+ } else {
373
+ result += `$${options.bindIndex++}`;
374
+ (options.bindParams as any[]).push(value);
375
+ }
376
+ break;
377
+ }
378
+ case 'mysql': {
379
+ // MySQL形式の場合、?でバインドパラメータを展開
380
+ if (Array.isArray(value)) {
381
+ const placeholders: string[] = [];
382
+ for (const item of value) {
383
+ placeholders.push('?');
384
+ (options.bindParams as any[]).push(item);
385
+ }
386
+ result += `(${placeholders.join(',')})`; // IN (?,?,?)
387
+ } else {
388
+ result += '?';
389
+ (options.bindParams as any[]).push(value);
390
+ }
391
+ break;
392
+ }
393
+ case 'oracle': {
394
+ // Oracle形式の場合、名前付きバインドでバインドパラメータを展開
395
+ if (Array.isArray(value)) {
396
+ const placeholders: string[] = [];
397
+ for (let i = 0; i < value.length; i++) {
398
+ // 名前付きバインドで配列の場合は名前が重複する可能性があるので枝番を付与
399
+ const paramName = `${tagContext.contents}_${i}`; // :projectNames_0, :projectNames_1
400
+ placeholders.push(`:${paramName}`);
401
+ (options.bindParams as Record<string, any>)[paramName] = value[i];
402
+ }
403
+ result += `(${placeholders.join(',')})`; // IN (:p_0,:p_1,:p3)
404
+ } else {
405
+ result += `:${tagContext.contents}`;
406
+ (options.bindParams as Record<string, any>)[tagContext.contents] = value;
407
+ }
408
+ break;
409
+ }
410
+ default: {
411
+ // generateSQLの場合
412
+ const escapedValue = this.extractValue(tagContext.contents, entity);
413
+ result += escapedValue ?? '';
414
+ }
415
+ }
287
416
  break;
288
417
  }
289
418
  default:
@@ -375,16 +504,9 @@ export class SQLBuilder {
375
504
  */
376
505
  private evaluateCondition(condition: string, entity: Record<string, any>): boolean {
377
506
  try {
378
- const evaluate = new Function('entity', `
379
- with (entity) {
380
- try {
381
- return ${condition};
382
- } catch(error) {
383
- return false;
384
- }
385
- }`
386
- );
387
- return !!evaluate(entity);
507
+ const ast = new AbstractSyntaxTree();
508
+ const result = ast.evaluateCondition(condition, entity);
509
+ return result;
388
510
  } catch (error) {
389
511
  console.warn('Error evaluating condition:', condition, entity, error);
390
512
  return false;
@@ -393,6 +515,7 @@ export class SQLBuilder {
393
515
 
394
516
  /**
395
517
  * entityからparamで指定した値を文字列で取得する
518
+ * entityの値がstringの場合、SQLインジェクションの危険のある文字はエスケープする
396
519
  *
397
520
  * * 返却する値が配列の場合は丸括弧で括り、各項目をカンマで区切る
398
521
  * ('a', 'b', 'c')
@@ -412,15 +535,7 @@ export class SQLBuilder {
412
535
  responseType?: T
413
536
  }): ExtractValueType<T> {
414
537
  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
- }
538
+ const value = common.getProperty(entity, property);
424
539
  let result = '';
425
540
  switch (options?.responseType) {
426
541
  case 'array':