@digitalwalletcorp/sql-builder 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 digitalwalletcorp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # SQL Builder
2
+
3
+ [![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)
4
+
5
+ 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.
6
+
7
+ 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.
8
+
9
+ ## ✨ Features
10
+
11
+ * Dynamic Query Generation: Build complex SQL queries dynamically at runtime.
12
+ * Conditional Logic (`/*IF...*/`): Automatically include or exclude SQL fragments based on JavaScript conditions evaluated against your data.
13
+ * Optional Blocks (`/*BEGIN...*/`): Wrap entire clauses (like `WHERE`) that are only included if at least one inner `/*IF...*/` condition is met.
14
+ * Looping (`/*FOR...*/`): Generate repetitive SQL snippets by iterating over arrays in your data (e.g., for multiple `LIKE` or `OR` conditions).
15
+ * Simple Parameter Binding: Easily bind values from your data object into the SQL query.
16
+ * Zero Dependencies: A single, lightweight class with no external library requirements.
17
+
18
+ ## ✅ Compatibility
19
+
20
+ This library is written in pure, environment-agnostic JavaScript/TypeScript and has zero external dependencies, allowing it to run in various environments.
21
+
22
+ - ✅ **Node.js**: Designed and optimized for server-side use in any modern Node.js environment. This is the **primary and recommended** use case.
23
+ - ⚠️ **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.
24
+
25
+ ## 📦 Instllation
26
+
27
+ ```bash
28
+ npm install @digitalwalletcorp/sql-builder
29
+ # or
30
+ yarn add @digitalwalletcorp/sql-builder
31
+ ```
32
+
33
+ ## 📖 How It Works & Usage
34
+
35
+ 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.
36
+
37
+ ##### Example 1: Dynamic WHERE Clause
38
+
39
+ This is the most common use case. The `WHERE` clause is built dynamically based on which properties exist in the `bindEntity`.
40
+
41
+ ##### Template:
42
+
43
+ ```sql
44
+ SELECT
45
+ id,
46
+ project_name,
47
+ status
48
+ FROM activity
49
+ /*BEGIN*/WHERE
50
+ 1 = 1
51
+ /*IF projectNames != null && projectNames.length*/AND project_name IN /*projectNames*/('project1')/*END*/
52
+ /*IF statuses != null && statuses.length*/AND status IN /*statuses*/(1)/*END*/
53
+ /*END*/
54
+ ORDER BY started_at DESC
55
+ LIMIT /*limit*/100
56
+ ```
57
+
58
+ ##### Code:
59
+
60
+ ```typescript
61
+ import { SQLBuilder } from '@digitalwalletcorp/sql-builder';
62
+
63
+ const builder = new SQLBuilder();
64
+
65
+ const template = `...`; // The SQL template from above
66
+
67
+ // SCENARIO A: Only `statuses` and `limit` are provided.
68
+ const bindEntity1 = {
69
+ statuses: [1, 2, 5],
70
+ limit: 50
71
+ };
72
+
73
+ const sql1 = builder.generateSQL(template, bindEntity1);
74
+ console.log(sql1);
75
+
76
+ // SCENARIO B: No filter conditions are met, so the entire WHERE clause is removed.
77
+ const bindEntity2 = {
78
+ limit: 100
79
+ };
80
+ const sql2 = builder.generateSQL(template, bindEntity2);
81
+ console.log(sql2);
82
+ ```
83
+
84
+ ##### Resulting SQL:
85
+
86
+ * SQL 1 (Scenario A): The `project_name` condition is excluded, but the `status` condition is included.
87
+
88
+ ```sql
89
+ SELECT
90
+ id,
91
+ project_name,
92
+ status
93
+ FROM activity
94
+ WHERE
95
+ 1 = 1
96
+ AND status IN (1,2,5)
97
+ ORDER BY started_at DESC
98
+ LIMIT 50
99
+ ```
100
+
101
+ * SQL 2 (Scenario B): Because no `/*IF...*/` conditions inside the `/*BEGIN*/.../*END*/` block were met, the entire block (including the `WHERE` keyword) is omitted.
102
+
103
+ ```sql
104
+ SELECT
105
+ id,
106
+ project_name,
107
+ status
108
+ FROM activity
109
+ ORDER BY started_at DESC
110
+ LIMIT 100
111
+ ```
112
+
113
+ ##### Example 2: FOR Loop
114
+
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
+
117
+ ##### Template:
118
+
119
+ ```sql
120
+ SELECT * FROM activity
121
+ WHERE 1 = 1
122
+ /*FOR name:projectNames*/AND project_name LIKE '%' || /*name*/'default' || '%'/*END*/
123
+ ```
124
+
125
+ ##### Code:
126
+
127
+ ```typescript
128
+ import { SQLBuilder } from '@digitalwalletcorp/sql-builder';
129
+
130
+ const builder = new SQLBuilder();
131
+ const template = `...`; // The SQL template from above
132
+
133
+ const bindEntity = {
134
+ projectNames: ['api', 'batch', 'frontend']
135
+ };
136
+
137
+ const sql = builder.generateSQL(template, bindEntity);
138
+ console.log(sql);
139
+ ```
140
+
141
+ ##### Resulting SQL:
142
+
143
+ ```sql
144
+ SELECT * FROM activity
145
+ WHERE 1 = 1
146
+ AND project_name LIKE '%' || 'api' || '%'
147
+ AND project_name LIKE '%' || 'batch' || '%'
148
+ AND project_name LIKE '%' || 'frontend' || '%'
149
+ ```
150
+
151
+ ## 📚 API Reference
152
+
153
+ ##### `new SQLBuilder()`
154
+
155
+ Creates a new instance of the SQL builder.
156
+
157
+ ##### `generateSQL(template: string, entity: Record<string, any>): string`
158
+
159
+ Generates a final SQL string by processing the template with the provided data entity.
160
+
161
+ * `template`: The SQL template string containing S2Dao-style comments.
162
+ * `entity`: A data object whose properties are used for evaluating conditions (`/*IF...*/`) and binding values (`/*variable*/`).
163
+ * Returns: The generated SQL string.
164
+
165
+ ## 🪄 Special Comment Syntax
166
+
167
+ | Tag | Syntax | Description |
168
+ | --- | --- | --- |
169
+ | 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. |
170
+ | 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. |
171
+ | 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. |
172
+ | 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
+ | END | `/*END*/` | Marks the end of an `IF`, `BEGIN`, or `FOR` block. |
174
+
175
+ ## 📜 License
176
+
177
+ This project is licensed under the MIT License. See the [LICENSE](https://opensource.org/licenses/MIT) file for details.
178
+
179
+ ## 🎓 Advanced Usage & Examples
180
+
181
+ 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.
182
+
183
+ We recommend Browse the test files to understand how to handle and verify the sequential, race-condition-free execution in various scenarios.
184
+
185
+ You can find the test case in the `/test/specs` directory of our GitHub repository.
186
+
187
+ - **[Explore our Test Suite for Advanced Examples](https://github.com/digitalwalletcorp/sql-builder/tree/main/test/specs)**
package/lib/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './sql-builder';
package/lib/index.js ADDED
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./sql-builder"), exports);
18
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,gDAA8B"}
@@ -0,0 +1,103 @@
1
+ /**
2
+ * 動的SQLを生成する
3
+ *
4
+ * このクラスは、S2Daoが提供していた機能を模したもので、SQLテンプレートとバインドエンティティを渡すことで動的にSQLを生成する。
5
+ *
6
+ * 例)
7
+ * テンプレート
8
+ * ```
9
+ * SELECT COUNT(*) AS cnt FROM activity
10
+ * \/*BEGIN*\/WHERE
11
+ * 1 = 1
12
+ * \/*IF projectNames.length*\/AND project_name IN \/*projectNames*\/('project1')\/*END*\/
13
+ * \/*IF nodeNames.length*\/AND node_name IN \/*nodeNames*\/('node1')\/*END*\/
14
+ * \/*IF jobNames.length*\/AND job_name IN \/*jobNames*\/('job1')\/*END*\/
15
+ * \/*IF statuses.length*\/AND status IN \/*statuses*\/(1)\/*END*\/
16
+ * \/*END*\/
17
+ * ```
18
+ *
19
+ * 呼び出し
20
+ * ```
21
+ * const bindEntity = {
22
+ * projectNames: ['pj1', 'pj2'],
23
+ * nodeNames: ['node1', 'node2'],
24
+ * jobNames: ['job1', 'job2'],
25
+ * statuses: [1, 2]
26
+ * };
27
+ * const sql = builder.generateSQL(template, bindEntity);
28
+ * ```
29
+ *
30
+ * 結果
31
+ * ```
32
+ * SELECT COUNT(*) AS cnt FROM activity
33
+ * WHERE
34
+ * 1 = 1
35
+ * AND project_name IN ('pj1','pj2')
36
+ * AND node_name IN ('node1','node2')
37
+ * AND job_name IN ('job1','job2')
38
+ * AND status IN (1,2)
39
+ * ```
40
+ */
41
+ export declare class SQLBuilder {
42
+ private REGEX_TAG_PATTERN;
43
+ constructor();
44
+ /**
45
+ * 指定したテンプレートにエンティティの値をバインドしたSQLを生成する
46
+ *
47
+ * @param {string} template
48
+ * @param {Record<string, any>} entity
49
+ * @returns {string}
50
+ */
51
+ generateSQL(template: string, entity: Record<string, any>): string;
52
+ /**
53
+ * テンプレートに含まれるタグ構成を解析してコンテキストを返す
54
+ *
55
+ * @param {string} template
56
+ * @returns {TagContext[]}
57
+ */
58
+ private createTagContexts;
59
+ /**
60
+ * テンプレートを分析して生成したSQLを返す
61
+ *
62
+ * @param {SharedIndex} pos 現在処理している文字列の先頭インデックス
63
+ * @param {string} template
64
+ * @param {Record<string, any>} entity
65
+ * @param {TagContext[]} tagContexts
66
+ * @returns {string}
67
+ */
68
+ private parse;
69
+ /**
70
+ * ダミーパラメータの終了インデックスを返す
71
+ *
72
+ * @param {string} template
73
+ * @param {TagContext} tagContext
74
+ * @returns {number}
75
+ */
76
+ private getDummyParamEndIndex;
77
+ /**
78
+ * IF条件が成立するか判定する
79
+ *
80
+ * @param {string} condition `params.length`や`param === 'a'`などの条件式
81
+ * @param {Record<string, any>} entity
82
+ * @returns {boolean}
83
+ */
84
+ private evaluateCondition;
85
+ /**
86
+ * entityからparamで指定した値を文字列で取得する
87
+ *
88
+ * * 返却する値が配列の場合は丸括弧で括り、各項目をカンマで区切る
89
+ * ('a', 'b', 'c')
90
+ * (1, 2, 3)
91
+ * * 返却する値がstring型の場合はシングルクォートで括る
92
+ * 'abc'
93
+ * * 返却する値がnumber型の場合はそのまま返す
94
+ * 1234
95
+ *
96
+ * @param {string} property `obj.param1.param2`などのドットで繋いだプロパティ
97
+ * @param {Record<string, any>} entity
98
+ * @param {*} [options]
99
+ * ├ responseType 'string' | 'array' | 'object'
100
+ * @returns {string}
101
+ */
102
+ private extractValue;
103
+ }
@@ -0,0 +1,416 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SQLBuilder = void 0;
4
+ /**
5
+ * 動的SQLを生成する
6
+ *
7
+ * このクラスは、S2Daoが提供していた機能を模したもので、SQLテンプレートとバインドエンティティを渡すことで動的にSQLを生成する。
8
+ *
9
+ * 例)
10
+ * テンプレート
11
+ * ```
12
+ * SELECT COUNT(*) AS cnt FROM activity
13
+ * \/*BEGIN*\/WHERE
14
+ * 1 = 1
15
+ * \/*IF projectNames.length*\/AND project_name IN \/*projectNames*\/('project1')\/*END*\/
16
+ * \/*IF nodeNames.length*\/AND node_name IN \/*nodeNames*\/('node1')\/*END*\/
17
+ * \/*IF jobNames.length*\/AND job_name IN \/*jobNames*\/('job1')\/*END*\/
18
+ * \/*IF statuses.length*\/AND status IN \/*statuses*\/(1)\/*END*\/
19
+ * \/*END*\/
20
+ * ```
21
+ *
22
+ * 呼び出し
23
+ * ```
24
+ * const bindEntity = {
25
+ * projectNames: ['pj1', 'pj2'],
26
+ * nodeNames: ['node1', 'node2'],
27
+ * jobNames: ['job1', 'job2'],
28
+ * statuses: [1, 2]
29
+ * };
30
+ * const sql = builder.generateSQL(template, bindEntity);
31
+ * ```
32
+ *
33
+ * 結果
34
+ * ```
35
+ * SELECT COUNT(*) AS cnt FROM activity
36
+ * WHERE
37
+ * 1 = 1
38
+ * AND project_name IN ('pj1','pj2')
39
+ * AND node_name IN ('node1','node2')
40
+ * AND job_name IN ('job1','job2')
41
+ * AND status IN (1,2)
42
+ * ```
43
+ */
44
+ class SQLBuilder {
45
+ REGEX_TAG_PATTERN = /\/\*(.*?)\*\//g;
46
+ constructor() {
47
+ }
48
+ /**
49
+ * 指定したテンプレートにエンティティの値をバインドしたSQLを生成する
50
+ *
51
+ * @param {string} template
52
+ * @param {Record<string, any>} entity
53
+ * @returns {string}
54
+ */
55
+ generateSQL(template, entity) {
56
+ /**
57
+ * 「\/* *\/」で囲まれたすべての箇所を抽出
58
+ */
59
+ const allMatchers = template.match(this.REGEX_TAG_PATTERN);
60
+ if (!allMatchers) {
61
+ return template;
62
+ }
63
+ const tagContexts = this.createTagContexts(template);
64
+ const pos = { index: 0 };
65
+ const result = this.parse(pos, template, entity, tagContexts);
66
+ return result;
67
+ }
68
+ /**
69
+ * テンプレートに含まれるタグ構成を解析してコンテキストを返す
70
+ *
71
+ * @param {string} template
72
+ * @returns {TagContext[]}
73
+ */
74
+ createTagContexts(template) {
75
+ // マッチした箇所の開始インデックス、終了インデックス、および階層構造を保持するオブジェクトを構築する
76
+ /**
77
+ * 「\/* *\/」で囲まれたすべての箇所を抽出
78
+ */
79
+ const rootMatcher = template.match(this.REGEX_TAG_PATTERN);
80
+ // まず最初にREGEX_TAG_PATTERNで解析した情報をそのままフラットにTagContextの配列に格納
81
+ let pos = 0;
82
+ const tagContexts = [];
83
+ if (rootMatcher) {
84
+ for (const matchContent of rootMatcher) {
85
+ const index = template.indexOf(matchContent, pos);
86
+ pos = index + 1;
87
+ const tagContext = {
88
+ type: null,
89
+ match: matchContent,
90
+ contents: '',
91
+ startIndex: index,
92
+ endIndex: index + matchContent.length,
93
+ sub: [],
94
+ parent: null,
95
+ status: 0
96
+ };
97
+ switch (true) {
98
+ case matchContent === '/*BEGIN*/': {
99
+ tagContext.type = 'BEGIN';
100
+ break;
101
+ }
102
+ case matchContent.startsWith('/*IF'): {
103
+ tagContext.type = 'IF';
104
+ const contentMatcher = matchContent.match(/^\/\*IF\s+(.*?)\*\/$/);
105
+ tagContext.contents = contentMatcher && contentMatcher[1] || '';
106
+ break;
107
+ }
108
+ case matchContent.startsWith('/*FOR'): {
109
+ tagContext.type = 'FOR';
110
+ const contentMatcher = matchContent.match(/^\/\*FOR\s+(.*?)\*\/$/);
111
+ tagContext.contents = contentMatcher && contentMatcher[1] || '';
112
+ break;
113
+ }
114
+ case matchContent === '/*END*/': {
115
+ tagContext.type = 'END';
116
+ break;
117
+ }
118
+ default: {
119
+ tagContext.type = 'BIND';
120
+ const contentMatcher = matchContent.match(/\/\*(.*?)\*\//);
121
+ tagContext.contents = contentMatcher && contentMatcher[1] || '';
122
+ // ダミー値の終了位置をendIndexに設定
123
+ const dummyEndIndex = this.getDummyParamEndIndex(template, tagContext);
124
+ tagContext.endIndex = dummyEndIndex;
125
+ }
126
+ }
127
+ tagContexts.push(tagContext);
128
+ }
129
+ }
130
+ // できあがったTagContextの配列から、BEGEN、IFの場合は次の対応するENDが出てくるまでをsubに入れ直して構造化し、
131
+ // 以下のような構造の変更する
132
+ /**
133
+ * ```
134
+ * BEGIN
135
+ * ├ IF
136
+ * ├ BIND
137
+ * ├ BIND
138
+ * ├ END
139
+ * ├ BIND
140
+ * END
141
+ * ```
142
+ */
143
+ const parentTagContexts = [];
144
+ const newTagContexts = [];
145
+ for (const tagContext of tagContexts) {
146
+ switch (tagContext.type) {
147
+ case 'BEGIN':
148
+ case 'IF':
149
+ case 'FOR': {
150
+ const parentTagContext = parentTagContexts[parentTagContexts.length - 1];
151
+ if (parentTagContext) {
152
+ // 親タグがある
153
+ tagContext.parent = parentTagContext;
154
+ parentTagContext.sub.push(tagContext);
155
+ }
156
+ else {
157
+ // 親タグがない(最上位)
158
+ newTagContexts.push(tagContext);
159
+ }
160
+ // 後続処理で自身が親になるので自身を追加
161
+ parentTagContexts.push(tagContext);
162
+ break;
163
+ }
164
+ case 'END': {
165
+ const parentTagContext = parentTagContexts.pop();
166
+ // ENDのときは必ず対応するIF/BEGINがあるので、親のsubに追加
167
+ tagContext.parent = parentTagContext;
168
+ parentTagContext.sub.push(tagContext);
169
+ break;
170
+ }
171
+ default: {
172
+ const parentTagContext = parentTagContexts[parentTagContexts.length - 1];
173
+ if (parentTagContext) {
174
+ // 親タグがある
175
+ tagContext.parent = parentTagContext;
176
+ parentTagContext.sub.push(tagContext);
177
+ }
178
+ else {
179
+ // 親タグがない(最上位)
180
+ newTagContexts.push(tagContext);
181
+ }
182
+ }
183
+ }
184
+ }
185
+ return newTagContexts;
186
+ }
187
+ /**
188
+ * テンプレートを分析して生成したSQLを返す
189
+ *
190
+ * @param {SharedIndex} pos 現在処理している文字列の先頭インデックス
191
+ * @param {string} template
192
+ * @param {Record<string, any>} entity
193
+ * @param {TagContext[]} tagContexts
194
+ * @returns {string}
195
+ */
196
+ parse(pos, template, entity, tagContexts) {
197
+ let result = '';
198
+ for (const tagContext of tagContexts) {
199
+ switch (tagContext.type) {
200
+ case 'BEGIN': {
201
+ result += template.substring(pos.index, tagContext.startIndex);
202
+ pos.index = tagContext.endIndex;
203
+ // BEGINのときは無条件にsubに対して再帰呼び出し
204
+ result += this.parse(pos, template, entity, tagContext.sub);
205
+ break;
206
+ }
207
+ case 'IF': {
208
+ result += template.substring(pos.index, tagContext.startIndex);
209
+ pos.index = tagContext.endIndex;
210
+ if (this.evaluateCondition(tagContext.contents, entity)) {
211
+ // IF条件が成立する場合はsubに対して再帰呼び出し
212
+ tagContext.status = 10;
213
+ result += this.parse(pos, template, entity, tagContext.sub);
214
+ }
215
+ else {
216
+ // IF条件が成立しない場合は再帰呼び出しせず、subのENDタグのendIndexをposに設定
217
+ const endTagContext = tagContext.sub[tagContext.sub.length - 1];
218
+ pos.index = endTagContext.endIndex;
219
+ }
220
+ break;
221
+ }
222
+ case 'FOR': {
223
+ const [bindName, collectionName] = tagContext.contents.split(':').map(a => a.trim());
224
+ const array = this.extractValue(collectionName, entity, {
225
+ responseType: 'array'
226
+ });
227
+ if (array) {
228
+ result += template.substring(pos.index, tagContext.startIndex);
229
+ for (const value of array) {
230
+ // 再帰呼び出しによりposが進むので、ループのたびにposを戻す必要がある
231
+ pos.index = tagContext.endIndex;
232
+ result += this.parse(pos, template, { [bindName]: value }, tagContext.sub);
233
+ // FORループするときは各行で改行する
234
+ result += '\n';
235
+ }
236
+ }
237
+ break;
238
+ }
239
+ case 'END': {
240
+ // 2025-04-13 現時点ではBEGINやIFがネストされた場合について期待通りに動作しない
241
+ switch (true) {
242
+ // BEGINの場合、subにIFタグが1つ以上あり、いずれもstatus=10(成功)になっていない
243
+ case tagContext.parent?.type === 'BEGIN'
244
+ && !!tagContext.parent.sub.find(a => a.type === 'IF')
245
+ && !tagContext.parent.sub.find(a => a.type === 'IF' && a.status === 10):
246
+ // IFの場合、IFのstatusがstatus=10(成功)になっていない
247
+ case tagContext.parent?.type === 'IF' && tagContext.parent.status !== 10:
248
+ pos.index = tagContext.endIndex;
249
+ return '';
250
+ default:
251
+ }
252
+ result += template.substring(pos.index, tagContext.startIndex);
253
+ pos.index = tagContext.endIndex;
254
+ return result;
255
+ }
256
+ case 'BIND': {
257
+ result += template.substring(pos.index, tagContext.startIndex);
258
+ pos.index = tagContext.endIndex;
259
+ const value = this.extractValue(tagContext.contents, entity);
260
+ result += value == null ? '' : String(value);
261
+ break;
262
+ }
263
+ default:
264
+ }
265
+ }
266
+ return result;
267
+ }
268
+ /**
269
+ * ダミーパラメータの終了インデックスを返す
270
+ *
271
+ * @param {string} template
272
+ * @param {TagContext} tagContext
273
+ * @returns {number}
274
+ */
275
+ getDummyParamEndIndex(template, tagContext) {
276
+ if (tagContext.type !== 'BIND') {
277
+ throw new Error(`${tagContext.type} に対してgetDummyParamEndIndexが呼び出されました`);
278
+ }
279
+ let quoted = false;
280
+ let bracket = false;
281
+ const chars = Array.from(template);
282
+ for (let i = tagContext.endIndex; i < template.length; i++) {
283
+ const c = chars[i];
284
+ if (bracket) {
285
+ // 丸括弧解析中
286
+ switch (true) {
287
+ case c === ')':
288
+ // 丸括弧終了
289
+ return i + 1;
290
+ case c === '\n':
291
+ throw new Error(`括弧が閉じられていません [${i}]`);
292
+ default:
293
+ }
294
+ }
295
+ else if (quoted) {
296
+ // クォート解析中
297
+ switch (true) {
298
+ case c === '\'':
299
+ // クォート終了
300
+ return i + 1;
301
+ case c === '\n':
302
+ throw new Error(`クォートが閉じられていません [${i}]`);
303
+ default:
304
+ }
305
+ }
306
+ else {
307
+ switch (true) {
308
+ case c === '\'':
309
+ // クォート開始
310
+ quoted = true;
311
+ break;
312
+ case c === '(':
313
+ // 丸括弧開始
314
+ bracket = true;
315
+ break;
316
+ case c === ')':
317
+ throw new Error(`括弧が開始されていません [${i}]`);
318
+ case c === '*' && 1 < i && chars[i - 1] === '/':
319
+ // 次ノード開始
320
+ return i - 1;
321
+ case c === '-' && 1 < i && chars[i - 1] === '-':
322
+ // 行コメント
323
+ return i - 1;
324
+ case c === '\n':
325
+ if (1 < i && chars[i - 1] === '\r') {
326
+ // \r\n
327
+ return i - 1;
328
+ }
329
+ // \n
330
+ return i;
331
+ case c === ' ' || c === '\t':
332
+ // 空白文字
333
+ return i;
334
+ default:
335
+ }
336
+ }
337
+ }
338
+ return template.length;
339
+ }
340
+ /**
341
+ * IF条件が成立するか判定する
342
+ *
343
+ * @param {string} condition `params.length`や`param === 'a'`などの条件式
344
+ * @param {Record<string, any>} entity
345
+ * @returns {boolean}
346
+ */
347
+ evaluateCondition(condition, entity) {
348
+ try {
349
+ const evaluate = new Function('entity', `
350
+ with (entity) {
351
+ try {
352
+ return ${condition};
353
+ } catch(error) {
354
+ return false;
355
+ }
356
+ }`);
357
+ return !!evaluate(entity);
358
+ }
359
+ catch (error) {
360
+ console.warn('Error evaluating condition:', condition, entity, error);
361
+ return false;
362
+ }
363
+ }
364
+ /**
365
+ * entityからparamで指定した値を文字列で取得する
366
+ *
367
+ * * 返却する値が配列の場合は丸括弧で括り、各項目をカンマで区切る
368
+ * ('a', 'b', 'c')
369
+ * (1, 2, 3)
370
+ * * 返却する値がstring型の場合はシングルクォートで括る
371
+ * 'abc'
372
+ * * 返却する値がnumber型の場合はそのまま返す
373
+ * 1234
374
+ *
375
+ * @param {string} property `obj.param1.param2`などのドットで繋いだプロパティ
376
+ * @param {Record<string, any>} entity
377
+ * @param {*} [options]
378
+ * ├ responseType 'string' | 'array' | 'object'
379
+ * @returns {string}
380
+ */
381
+ extractValue(property, entity, options) {
382
+ try {
383
+ const propertyPath = property.split('.');
384
+ let value = entity;
385
+ for (const prop of propertyPath) {
386
+ if (value && value.hasOwnProperty(prop)) {
387
+ value = value[prop];
388
+ }
389
+ else {
390
+ return undefined;
391
+ }
392
+ }
393
+ let result = '';
394
+ switch (options?.responseType) {
395
+ case 'array':
396
+ case 'object':
397
+ return value;
398
+ default:
399
+ // string
400
+ if (Array.isArray(value)) {
401
+ result = `(${value.map(v => typeof v === 'string' ? `'${v}'` : v).join(',')})`;
402
+ }
403
+ else {
404
+ result = typeof value === 'string' ? `'${value}'` : value;
405
+ }
406
+ return result;
407
+ }
408
+ }
409
+ catch (error) {
410
+ console.warn('Error extracting value', property, entity, error);
411
+ return undefined;
412
+ }
413
+ }
414
+ }
415
+ exports.SQLBuilder = SQLBuilder;
416
+ //# sourceMappingURL=sql-builder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sql-builder.js","sourceRoot":"","sources":["../src/sql-builder.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@digitalwalletcorp/sql-builder",
3
+ "version": "1.0.0",
4
+ "description": "This is a library for building SQL",
5
+ "main": "lib/index.js",
6
+ "types": "lib/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc --project tsconfig.build.json",
9
+ "version:patch": "npm version patch",
10
+ "version:minor": "npm version minor",
11
+ "version:major": "npm version major",
12
+ "pack": "rm -rf dest && mkdir -p dest && npm pack --pack-destination=dest",
13
+ "publish:npm": "bash ../bin/publish.sh",
14
+ "test:unit": "NODE_OPTIONS=--max-old-space-size=2048 npx jest --runInBand --logHeapUsage --detectOpenHandles --forceExit --errorOnDeprecated --coverage --silent --unhandled-rejections=strict"
15
+ },
16
+ "keywords": [
17
+ "sql",
18
+ "dynamic",
19
+ "S2Dao"
20
+ ],
21
+ "author": "satoshi kotaki",
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/digitalwalletcorp/sql-builder.git"
26
+ },
27
+ "homepage": "https://github.com/digitalwalletcorp/sql-builder#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/digitalwalletcorp/sql-builder/issues"
30
+ },
31
+ "files": [
32
+ "lib/**/*",
33
+ "src/**/*"
34
+ ],
35
+ "devDependencies": {
36
+ "@types/jest": "^29.5.14",
37
+ "jest": "^29.7.0",
38
+ "ts-jest": "^29.3.4",
39
+ "ts-node": "^10.9.2",
40
+ "typescript": "^5.8.3"
41
+ }
42
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './sql-builder';
@@ -0,0 +1,441 @@
1
+ type TagType = 'BEGIN' | 'IF' | 'FOR' | 'END' | 'BIND';
2
+ type ExtractValueType<T extends 'string' | 'array' | 'object'>
3
+ = T extends 'string'
4
+ ? string | undefined
5
+ : T extends 'array'
6
+ ? any[] | undefined
7
+ : Record<string, any> | undefined;
8
+
9
+ interface TagContext {
10
+ type: TagType;
11
+ match: string;
12
+ contents: string;
13
+ startIndex: number;
14
+ endIndex: number;
15
+ sub: TagContext[];
16
+ parent: TagContext | null;
17
+ status: number; // 0: 初期、10: 成立 IFで条件が成立したかを判断するもの
18
+ }
19
+
20
+ interface SharedIndex {
21
+ index: number;
22
+ }
23
+
24
+ /**
25
+ * 動的SQLを生成する
26
+ *
27
+ * このクラスは、S2Daoが提供していた機能を模したもので、SQLテンプレートとバインドエンティティを渡すことで動的にSQLを生成する。
28
+ *
29
+ * 例)
30
+ * テンプレート
31
+ * ```
32
+ * SELECT COUNT(*) AS cnt FROM activity
33
+ * \/*BEGIN*\/WHERE
34
+ * 1 = 1
35
+ * \/*IF projectNames.length*\/AND project_name IN \/*projectNames*\/('project1')\/*END*\/
36
+ * \/*IF nodeNames.length*\/AND node_name IN \/*nodeNames*\/('node1')\/*END*\/
37
+ * \/*IF jobNames.length*\/AND job_name IN \/*jobNames*\/('job1')\/*END*\/
38
+ * \/*IF statuses.length*\/AND status IN \/*statuses*\/(1)\/*END*\/
39
+ * \/*END*\/
40
+ * ```
41
+ *
42
+ * 呼び出し
43
+ * ```
44
+ * const bindEntity = {
45
+ * projectNames: ['pj1', 'pj2'],
46
+ * nodeNames: ['node1', 'node2'],
47
+ * jobNames: ['job1', 'job2'],
48
+ * statuses: [1, 2]
49
+ * };
50
+ * const sql = builder.generateSQL(template, bindEntity);
51
+ * ```
52
+ *
53
+ * 結果
54
+ * ```
55
+ * SELECT COUNT(*) AS cnt FROM activity
56
+ * WHERE
57
+ * 1 = 1
58
+ * AND project_name IN ('pj1','pj2')
59
+ * AND node_name IN ('node1','node2')
60
+ * AND job_name IN ('job1','job2')
61
+ * AND status IN (1,2)
62
+ * ```
63
+ */
64
+ export class SQLBuilder {
65
+
66
+ private REGEX_TAG_PATTERN = /\/\*(.*?)\*\//g;
67
+
68
+ constructor() {
69
+ }
70
+
71
+ /**
72
+ * 指定したテンプレートにエンティティの値をバインドしたSQLを生成する
73
+ *
74
+ * @param {string} template
75
+ * @param {Record<string, any>} entity
76
+ * @returns {string}
77
+ */
78
+ public generateSQL(template: string, entity: Record<string, any>): string {
79
+ /**
80
+ * 「\/* *\/」で囲まれたすべての箇所を抽出
81
+ */
82
+ const allMatchers = template.match(this.REGEX_TAG_PATTERN);
83
+ if (!allMatchers) {
84
+ return template;
85
+ }
86
+
87
+ const tagContexts = this.createTagContexts(template);
88
+ const pos: SharedIndex = { index: 0 };
89
+ const result = this.parse(pos, template, entity, tagContexts);
90
+ return result;
91
+ }
92
+
93
+ /**
94
+ * テンプレートに含まれるタグ構成を解析してコンテキストを返す
95
+ *
96
+ * @param {string} template
97
+ * @returns {TagContext[]}
98
+ */
99
+ private createTagContexts(template: string): TagContext[] {
100
+ // マッチした箇所の開始インデックス、終了インデックス、および階層構造を保持するオブジェクトを構築する
101
+ /**
102
+ * 「\/* *\/」で囲まれたすべての箇所を抽出
103
+ */
104
+ const rootMatcher = template.match(this.REGEX_TAG_PATTERN);
105
+
106
+ // まず最初にREGEX_TAG_PATTERNで解析した情報をそのままフラットにTagContextの配列に格納
107
+ let pos = 0;
108
+ 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
+ }
152
+ }
153
+ tagContexts.push(tagContext);
154
+ }
155
+ }
156
+
157
+ // できあがったTagContextの配列から、BEGEN、IFの場合は次の対応するENDが出てくるまでをsubに入れ直して構造化し、
158
+ // 以下のような構造の変更する
159
+ /**
160
+ * ```
161
+ * BEGIN
162
+ * ├ IF
163
+ * ├ BIND
164
+ * ├ BIND
165
+ * ├ END
166
+ * ├ BIND
167
+ * END
168
+ * ```
169
+ */
170
+ const parentTagContexts: TagContext[] = [];
171
+ const newTagContexts: TagContext[] = [];
172
+ for (const tagContext of tagContexts) {
173
+ switch (tagContext.type) {
174
+ case 'BEGIN':
175
+ case 'IF':
176
+ case 'FOR': {
177
+ const parentTagContext = parentTagContexts[parentTagContexts.length - 1];
178
+ if (parentTagContext) {
179
+ // 親タグがある
180
+ tagContext.parent = parentTagContext;
181
+ parentTagContext.sub.push(tagContext);
182
+ } else {
183
+ // 親タグがない(最上位)
184
+ newTagContexts.push(tagContext);
185
+ }
186
+ // 後続処理で自身が親になるので自身を追加
187
+ parentTagContexts.push(tagContext);
188
+ break;
189
+ }
190
+ case 'END': {
191
+ const parentTagContext = parentTagContexts.pop()!;
192
+ // ENDのときは必ず対応するIF/BEGINがあるので、親のsubに追加
193
+ tagContext.parent = parentTagContext;
194
+ parentTagContext.sub.push(tagContext);
195
+ break;
196
+ }
197
+ default: {
198
+ const parentTagContext = parentTagContexts[parentTagContexts.length - 1];
199
+ if (parentTagContext) {
200
+ // 親タグがある
201
+ tagContext.parent = parentTagContext;
202
+ parentTagContext.sub.push(tagContext);
203
+ } else {
204
+ // 親タグがない(最上位)
205
+ newTagContexts.push(tagContext);
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ return newTagContexts;
212
+ }
213
+
214
+ /**
215
+ * テンプレートを分析して生成したSQLを返す
216
+ *
217
+ * @param {SharedIndex} pos 現在処理している文字列の先頭インデックス
218
+ * @param {string} template
219
+ * @param {Record<string, any>} entity
220
+ * @param {TagContext[]} tagContexts
221
+ * @returns {string}
222
+ */
223
+ private parse(pos: SharedIndex, template: string, entity: Record<string, any>, tagContexts: TagContext[]): string {
224
+ let result = '';
225
+ for (const tagContext of tagContexts) {
226
+ switch (tagContext.type) {
227
+ case 'BEGIN': {
228
+ result += template.substring(pos.index, tagContext.startIndex);
229
+ pos.index = tagContext.endIndex;
230
+ // BEGINのときは無条件にsubに対して再帰呼び出し
231
+ result += this.parse(pos, template, entity, tagContext.sub);
232
+ break;
233
+ }
234
+ case 'IF': {
235
+ result += template.substring(pos.index, tagContext.startIndex);
236
+ pos.index = tagContext.endIndex;
237
+ if (this.evaluateCondition(tagContext.contents, entity)) {
238
+ // IF条件が成立する場合はsubに対して再帰呼び出し
239
+ tagContext.status = 10;
240
+ result += this.parse(pos, template, entity, tagContext.sub);
241
+ } else {
242
+ // IF条件が成立しない場合は再帰呼び出しせず、subのENDタグのendIndexをposに設定
243
+ const endTagContext = tagContext.sub[tagContext.sub.length - 1];
244
+ pos.index = endTagContext.endIndex;
245
+ }
246
+ break;
247
+ }
248
+ case 'FOR': {
249
+ const [bindName, collectionName] = tagContext.contents.split(':').map(a => a.trim());
250
+ const array = this.extractValue(collectionName, entity, {
251
+ responseType: 'array'
252
+ });
253
+ if (array) {
254
+ result += template.substring(pos.index, tagContext.startIndex);
255
+ for (const value of array) {
256
+ // 再帰呼び出しによりposが進むので、ループのたびにposを戻す必要がある
257
+ pos.index = tagContext.endIndex;
258
+ result += this.parse(pos, template, { [bindName]: value }, tagContext.sub);
259
+ // FORループするときは各行で改行する
260
+ result += '\n';
261
+ }
262
+ }
263
+ break;
264
+ }
265
+ case 'END': {
266
+ // 2025-04-13 現時点ではBEGINやIFがネストされた場合について期待通りに動作しない
267
+ switch (true) {
268
+ // BEGINの場合、subにIFタグが1つ以上あり、いずれもstatus=10(成功)になっていない
269
+ case tagContext.parent?.type === 'BEGIN'
270
+ && !!tagContext.parent.sub.find(a => a.type === 'IF')
271
+ && !tagContext.parent.sub.find(a => a.type === 'IF' && a.status === 10):
272
+ // IFの場合、IFのstatusがstatus=10(成功)になっていない
273
+ case tagContext.parent?.type === 'IF' && tagContext.parent.status !== 10:
274
+ pos.index = tagContext.endIndex;
275
+ return '';
276
+ default:
277
+ }
278
+ result += template.substring(pos.index, tagContext.startIndex);
279
+ pos.index = tagContext.endIndex;
280
+ return result;
281
+ }
282
+ case 'BIND': {
283
+ result += template.substring(pos.index, tagContext.startIndex);
284
+ pos.index = tagContext.endIndex;
285
+ const value = this.extractValue(tagContext.contents, entity);
286
+ result += value == null ? '' : String(value);
287
+ break;
288
+ }
289
+ default:
290
+ }
291
+ }
292
+
293
+ return result;
294
+ }
295
+
296
+ /**
297
+ * ダミーパラメータの終了インデックスを返す
298
+ *
299
+ * @param {string} template
300
+ * @param {TagContext} tagContext
301
+ * @returns {number}
302
+ */
303
+ private getDummyParamEndIndex(template: string, tagContext: TagContext): number {
304
+ if (tagContext.type !== 'BIND') {
305
+ throw new Error(`${tagContext.type} に対してgetDummyParamEndIndexが呼び出されました`);
306
+ }
307
+ let quoted = false;
308
+ let bracket = false;
309
+ const chars: string[] = Array.from(template);
310
+ for (let i = tagContext.endIndex; i < template.length; i++) {
311
+ const c = chars[i];
312
+ if (bracket) {
313
+ // 丸括弧解析中
314
+ switch (true) {
315
+ case c === ')':
316
+ // 丸括弧終了
317
+ return i + 1;
318
+ case c === '\n':
319
+ throw new Error(`括弧が閉じられていません [${i}]`);
320
+ default:
321
+ }
322
+ } else if (quoted) {
323
+ // クォート解析中
324
+ switch (true) {
325
+ case c === '\'':
326
+ // クォート終了
327
+ return i + 1;
328
+ case c === '\n':
329
+ throw new Error(`クォートが閉じられていません [${i}]`);
330
+ default:
331
+ }
332
+ } else {
333
+ switch (true) {
334
+ case c === '\'':
335
+ // クォート開始
336
+ quoted = true;
337
+ break;
338
+ case c === '(':
339
+ // 丸括弧開始
340
+ bracket = true;
341
+ break;
342
+ case c === ')':
343
+ throw new Error(`括弧が開始されていません [${i}]`);
344
+ case c === '*' && 1 < i && chars[i - 1] === '/':
345
+ // 次ノード開始
346
+ return i - 1;
347
+ case c === '-' && 1 < i && chars[i - 1] === '-':
348
+ // 行コメント
349
+ return i - 1;
350
+ case c === '\n':
351
+ if (1 < i && chars[i - 1] === '\r') {
352
+ // \r\n
353
+ return i - 1;
354
+ }
355
+ // \n
356
+ return i;
357
+ case c === ' ' || c === '\t':
358
+ // 空白文字
359
+ return i;
360
+ default:
361
+ }
362
+ }
363
+ }
364
+ return template.length;
365
+ }
366
+
367
+ /**
368
+ * IF条件が成立するか判定する
369
+ *
370
+ * @param {string} condition `params.length`や`param === 'a'`などの条件式
371
+ * @param {Record<string, any>} entity
372
+ * @returns {boolean}
373
+ */
374
+ private evaluateCondition(condition: string, entity: Record<string, any>): boolean {
375
+ try {
376
+ const evaluate = new Function('entity', `
377
+ with (entity) {
378
+ try {
379
+ return ${condition};
380
+ } catch(error) {
381
+ return false;
382
+ }
383
+ }`
384
+ );
385
+ return !!evaluate(entity);
386
+ } catch (error) {
387
+ console.warn('Error evaluating condition:', condition, entity, error);
388
+ return false;
389
+ }
390
+ }
391
+
392
+ /**
393
+ * entityからparamで指定した値を文字列で取得する
394
+ *
395
+ * * 返却する値が配列の場合は丸括弧で括り、各項目をカンマで区切る
396
+ * ('a', 'b', 'c')
397
+ * (1, 2, 3)
398
+ * * 返却する値がstring型の場合はシングルクォートで括る
399
+ * 'abc'
400
+ * * 返却する値がnumber型の場合はそのまま返す
401
+ * 1234
402
+ *
403
+ * @param {string} property `obj.param1.param2`などのドットで繋いだプロパティ
404
+ * @param {Record<string, any>} entity
405
+ * @param {*} [options]
406
+ * ├ responseType 'string' | 'array' | 'object'
407
+ * @returns {string}
408
+ */
409
+ private extractValue<T extends 'string' | 'array' | 'object' = 'string'>(property: string, entity: Record<string, any>, options?: {
410
+ responseType?: T
411
+ }): ExtractValueType<T> {
412
+ try {
413
+ const propertyPath = property.split('.');
414
+ let value: any = entity;
415
+ for (const prop of propertyPath) {
416
+ if (value && value.hasOwnProperty(prop)) {
417
+ value = value[prop];
418
+ } else {
419
+ return undefined as ExtractValueType<T>;
420
+ }
421
+ }
422
+ let result = '';
423
+ switch (options?.responseType) {
424
+ case 'array':
425
+ case 'object':
426
+ return value as ExtractValueType<T>;
427
+ default:
428
+ // string
429
+ if (Array.isArray(value)) {
430
+ result = `(${value.map(v => typeof v === 'string' ? `'${v}'` : v).join(',')})`;
431
+ } else {
432
+ result = typeof value === 'string' ? `'${value}'` : value;
433
+ }
434
+ return result as ExtractValueType<T>;
435
+ }
436
+ } catch (error) {
437
+ console.warn('Error extracting value', property, entity, error);
438
+ return undefined as ExtractValueType<T>;
439
+ }
440
+ }
441
+ }