UNPKG

@digitalwalletcorp/sql-builder

Version:
606 lines 25.9 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.SQLBuilder = void 0; const common = __importStar(require("./common")); const abstract_syntax_tree_1 = require("./abstract-syntax-tree"); /** * PostgreSQL: $1, $2... 配列 * MySQL: ? 配列 * SQLite: ?, $name, :name 配列またはオブジェクト * Oracle: :name オブジェクト * SQL Server: `@name` 配列またはオブジェクト * BigQuery: `@name` オブジェクト * * 以下をサポートする * ・$1, $2 (postgres) * ・? (mysql) SQLite, SQL Serverもこれで代替可能 * ・:name (oracle) SQLiteもこれで代替可能 * ・`@name` (mssql) * ・`@name` (bigquery) */ const dbTypes = [ 'postgres', 'mysql', 'oracle', 'mssql', 'bigquery' ]; /** * 動的SQLを生成する * * このクラスは、S2Daoが提供していた機能を模したもので、SQLテンプレートとバインドエンティティを渡すことで動的にSQLを生成する。 * * 例) * テンプレート * ``` * SELECT COUNT(*) AS cnt FROM activity * \/*BEGIN*\/WHERE * 1 = 1 * \/*IF projectNames.length*\/AND project_name IN \/*projectNames*\/('project1')\/*END*\/ * \/*IF nodeNames.length*\/AND node_name IN \/*nodeNames*\/('node1')\/*END*\/ * \/*IF jobNames.length*\/AND job_name IN \/*jobNames*\/('job1')\/*END*\/ * \/*IF statuses.length*\/AND status IN \/*statuses*\/(1)\/*END*\/ * \/*END*\/ * ``` * * 呼び出し * ``` * const bindEntity = { * projectNames: ['pj1', 'pj2'], * nodeNames: ['node1', 'node2'], * jobNames: ['job1', 'job2'], * statuses: [1, 2] * }; * const sql = builder.generateSQL(template, bindEntity); * ``` * * 結果 * ``` * SELECT COUNT(*) AS cnt FROM activity * WHERE * 1 = 1 * AND project_name IN ('pj1','pj2') * AND node_name IN ('node1','node2') * AND job_name IN ('job1','job2') * AND status IN (1,2) * ``` */ class SQLBuilder { REGEX_TAG_PATTERN = /\/\*(.*?)\*\//g; bindType; constructor(bindType) { this.bindType = bindType; } /** * 指定したテンプレートにエンティティの値をバインドしたSQLを生成する * * @param {string} template * @param {Record<string, any>} entity * @returns {string} */ generateSQL(template, entity) { /** * 「\/* *\/」で囲まれたすべての箇所を抽出 */ const allMatchers = template.match(this.REGEX_TAG_PATTERN); if (!allMatchers) { return template; } const tagContexts = this.createTagContexts(template); const pos = { index: 0 }; const result = this.parse(pos, template, entity, tagContexts); return result; } /** * 指定したテンプレートにエンティティの値をバインド可能なプレースホルダー付きSQLを生成し、 * バインドパラメータと共にタプル型で返却する * * @param {string} template * @param {Record<string, any>} entity * @param {BindType} [bindType] * @returns {[string, BindParameterType<T>]} */ generateParameterizedSQL(template, entity, bindType) { const bt = bindType || this.bindType; if (!bt) { throw new Error('The bindType parameter is mandatory if bindType is not provided in the constructor.'); } let bindParams; switch (bt) { case 'postgres': case 'mysql': bindParams = []; break; case 'oracle': case 'mssql': case 'bigquery': bindParams = {}; break; default: throw new Error(`Unsupported bind type: ${bt}`); } /** * 「\/* *\/」で囲まれたすべての箇所を抽出 */ const allMatchers = template.match(this.REGEX_TAG_PATTERN); if (!allMatchers) { return [template, bindParams]; } const tagContexts = this.createTagContexts(template); const pos = { index: 0 }; const result = this.parse(pos, template, entity, tagContexts, { bindType: bt, bindIndex: 1, bindParams: bindParams }); return [result, bindParams]; } /** * テンプレートに含まれるタグ構成を解析してコンテキストを返す * * @param {string} template * @returns {TagContext[]} */ createTagContexts(template) { // マッチした箇所の開始インデックス、終了インデックス、および階層構造を保持するオブジェクトを構築する /** * 「\/* *\/」で囲まれたすべての箇所を抽出 */ const matches = template.matchAll(this.REGEX_TAG_PATTERN); // まず最初にREGEX_TAG_PATTERNで解析した情報をそのままフラットにTagContextの配列に格納 let pos = 0; const tagContexts = []; for (const match of matches) { const matchContent = match[0]; const index = match.index; pos = index + 1; const tagContext = { type: 'BIND', // ダミーの初期値。後続処理で適切なタイプに変更する。 match: matchContent, contents: '', startIndex: index, endIndex: index + matchContent.length, sub: [], parent: null, status: 0 }; switch (true) { case matchContent === '/*BEGIN*/': { tagContext.type = 'BEGIN'; break; } case matchContent.startsWith('/*IF'): { tagContext.type = 'IF'; const contentMatcher = matchContent.match(/^\/\*IF\s+(.*?)\*\/$/); tagContext.contents = contentMatcher && contentMatcher[1] || ''; break; } case matchContent.startsWith('/*FOR'): { tagContext.type = 'FOR'; const contentMatcher = matchContent.match(/^\/\*FOR\s+(.*?)\*\/$/); tagContext.contents = contentMatcher && contentMatcher[1] || ''; break; } case matchContent === '/*END*/': { tagContext.type = 'END'; break; } default: { tagContext.type = 'BIND'; const contentMatcher = matchContent.match(/\/\*(.*?)\*\//); tagContext.contents = contentMatcher && contentMatcher[1]?.trim() || ''; // ダミー値の終了位置をendIndexに設定 const dummyEndIndex = this.getDummyParamEndIndex(template, tagContext); tagContext.endIndex = dummyEndIndex; } } tagContexts.push(tagContext); } // できあがったTagContextの配列から、BEGEN、IFの場合は次の対応するENDが出てくるまでをsubに入れ直して構造化し、 // 以下のような構造の変更する /** * ``` * BEGIN * ├ IF * ├ BIND * ├ BIND * ├ END * ├ BIND * END * ``` */ const parentTagContexts = []; const newTagContexts = []; for (const tagContext of tagContexts) { switch (tagContext.type) { case 'BEGIN': case 'IF': case 'FOR': { const parentTagContext = parentTagContexts[parentTagContexts.length - 1]; if (parentTagContext) { // 親タグがある tagContext.parent = parentTagContext; parentTagContext.sub.push(tagContext); } else { // 親タグがない(最上位) newTagContexts.push(tagContext); } // 後続処理で自身が親になるので自身を追加 parentTagContexts.push(tagContext); break; } case 'END': { const parentTagContext = parentTagContexts.pop(); // ENDのときは必ず対応するIF/BEGINがあるので、親のsubに追加 tagContext.parent = parentTagContext; parentTagContext.sub.push(tagContext); break; } default: { const parentTagContext = parentTagContexts[parentTagContexts.length - 1]; if (parentTagContext) { // 親タグがある tagContext.parent = parentTagContext; parentTagContext.sub.push(tagContext); } else { // 親タグがない(最上位) newTagContexts.push(tagContext); } } } } return newTagContexts; } /** * テンプレートを分析して生成したSQLを返す * * @param {SharedIndex} pos 現在処理している文字列の先頭インデックス * @param {string} template * @param {Record<string, any>} entity * @param {TagContext[]} tagContexts * @param {*} [options] * ├ bindType BindType * ├ bindIndex number * ├ bindParams BindParameterType<T> * @returns {string} */ parse(pos, template, entity, tagContexts, options) { let result = ''; for (const tagContext of tagContexts) { switch (tagContext.type) { case 'BEGIN': { result += template.substring(pos.index, tagContext.startIndex); pos.index = tagContext.endIndex; // BEGINのときは無条件にsubに対して再帰呼び出し result += this.parse(pos, template, entity, tagContext.sub, options); break; } case 'IF': { result += template.substring(pos.index, tagContext.startIndex); pos.index = tagContext.endIndex; if (this.evaluateCondition(tagContext.contents, entity)) { // IF条件が成立する場合はsubに対して再帰呼び出し tagContext.status = 10; result += this.parse(pos, template, entity, tagContext.sub, options); } else { // IF条件が成立しない場合は再帰呼び出しせず、subのENDタグのendIndexをposに設定 const endTagContext = tagContext.sub[tagContext.sub.length - 1]; pos.index = endTagContext.endIndex; } break; } case 'FOR': { const [bindName, collectionName] = tagContext.contents.split(':').map(a => a.trim()); const array = this.extractValue(collectionName, entity, { responseType: 'array' }); if (array) { result += template.substring(pos.index, tagContext.startIndex); for (const value of array) { // 再帰呼び出しによりposが進むので、ループのたびにposを戻す必要がある pos.index = tagContext.endIndex; result += this.parse(pos, template, { ...entity, [bindName]: value }, tagContext.sub, options); // FORループするときは各行で改行する result += '\n'; } } break; } case 'END': { // 2025-04-13 現時点ではBEGINやIFがネストされた場合について期待通りに動作しない switch (true) { // BEGINの場合、subにIFタグが1つ以上あり、いずれもstatus=10(成功)になっていない case tagContext.parent?.type === 'BEGIN' && !!tagContext.parent.sub.find(a => a.type === 'IF') && !tagContext.parent.sub.find(a => a.type === 'IF' && a.status === 10): // IFの場合、IFのstatusがstatus=10(成功)になっていない case tagContext.parent?.type === 'IF' && tagContext.parent.status !== 10: pos.index = tagContext.endIndex; return ''; default: } result += template.substring(pos.index, tagContext.startIndex); pos.index = tagContext.endIndex; return result; } case 'BIND': { result += template.substring(pos.index, tagContext.startIndex); pos.index = tagContext.endIndex; const value = common.getProperty(entity, tagContext.contents); switch (options?.bindType) { case 'postgres': { // PostgreSQL形式の場合、$Nでバインドパラメータを展開 if (Array.isArray(value)) { const placeholders = []; for (const item of value) { placeholders.push(`$${options.bindIndex++}`); options.bindParams.push(item); } result += `(${placeholders.join(',')})`; // IN ($1,$2,$3) } else { result += `$${options.bindIndex++}`; options.bindParams.push(value); } break; } case 'mysql': { // MySQL形式の場合、?でバインドパラメータを展開 if (Array.isArray(value)) { const placeholders = []; for (const item of value) { placeholders.push('?'); options.bindParams.push(item); } result += `(${placeholders.join(',')})`; // IN (?,?,?) } else { result += '?'; options.bindParams.push(value); } break; } case 'oracle': { // Oracle形式の場合、名前付きバインドでバインドパラメータを展開 if (Array.isArray(value)) { const placeholders = []; for (let i = 0; i < value.length; i++) { // 名前付きバインドで配列の場合は名前が重複する可能性があるので枝番を付与 const paramName = `${tagContext.contents}_${i}`; // :projectNames_0, :projectNames_1 placeholders.push(`:${paramName}`); options.bindParams[paramName] = value[i]; } result += `(${placeholders.join(',')})`; // IN (:p_0,:p_1,:p3) } else { result += `:${tagContext.contents}`; options.bindParams[tagContext.contents] = value; } break; } case 'mssql': { // SQL Server形式の場合、名前付きバインドでバインドパラメータを展開 if (Array.isArray(value)) { const placeholders = []; for (let i = 0; i < value.length; i++) { // 名前付きバインドで配列の場合は名前が重複する可能性があるので枝番を付与 const paramName = `${tagContext.contents}_${i}`; // @projectNames_0, @projectNames_1 placeholders.push(`@${paramName}`); options.bindParams[paramName] = value[i]; } result += `(${placeholders.join(',')})`; // IN (:p_0,:p_1,:p3) } else { result += `@${tagContext.contents}`; options.bindParams[tagContext.contents] = value; } break; } case 'bigquery': { // BigQuery形式の場合、名前付きバインドでバインドパラメータを展開 if (Array.isArray(value)) { // UNNESTを付けないとBigQuery側で正しく配列展開できないケースがある result += `UNNEST(@${tagContext.contents})`; // IN UNNEST(@params) } else { result += `@${tagContext.contents}`; } options.bindParams[tagContext.contents] = value; break; } default: { // generateSQLの場合 const escapedValue = this.extractValue(tagContext.contents, entity); result += escapedValue ?? ''; } } break; } default: } } // 最後に余った部分を追加する result += template.substring(pos.index); return result; } /** * ダミーパラメータの終了インデックスを返す * * @param {string} template * @param {TagContext} tagContext * @returns {number} */ getDummyParamEndIndex(template, tagContext) { if (tagContext.type !== 'BIND') { throw new Error(`${tagContext.type} に対してgetDummyParamEndIndexが呼び出されました`); } let quoted = false; let bracket = false; const chars = Array.from(template); for (let i = tagContext.endIndex; i < template.length; i++) { const c = chars[i]; if (bracket) { // 丸括弧解析中 switch (true) { case c === ')': // 丸括弧終了 return i + 1; case c === '\n': throw new Error(`括弧が閉じられていません [index: ${i}, subsequence: '${template.substring(Math.max(i - 20, 0), i + 20)}']`); default: } } else if (quoted) { // クォート解析中 switch (true) { case c === '\'': // クォート終了 return i + 1; case c === '\n': throw new Error(`クォートが閉じられていません [index: ${i}, subsequence: '${template.substring(Math.max(i - 20, 0), i + 20)}']`); default: } } else { switch (true) { case c === '\'': // クォート開始 quoted = true; break; case c === '(': // 丸括弧開始 bracket = true; break; case c === ')': throw new Error(`括弧が開始されていません [index: ${i}, subsequence: '${template.substring(Math.max(i - 20, 0), i + 20)}']`); case c === '*' && 1 < i && chars[i - 1] === '/': // 次ノード開始 return i - 1; case c === '-' && 1 < i && chars[i - 1] === '-': // 行コメント return i - 1; case c === '\n': if (1 < i && chars[i - 1] === '\r') { // \r\n return i - 1; } // \n return i; case c === ' ' || c === '\t': // 空白文字 return i; case c === ',': return i; default: } } } return template.length; } /** * IF条件が成立するか判定する * * @param {string} condition `params.length`や`param === 'a'`などの条件式 * @param {Record<string, any>} entity * @returns {boolean} */ evaluateCondition(condition, entity) { const ast = new abstract_syntax_tree_1.AbstractSyntaxTree(); const result = ast.evaluateCondition(condition, entity); return result; } /** * entityからparamで指定した値を文字列で取得する * entityの値がstringの場合、SQLインジェクションの危険のある文字はエスケープする * * * 返却する値が配列の場合は丸括弧で括り、各項目をカンマで区切る * ('a', 'b', 'c') * (1, 2, 3) * * 返却する値がstring型の場合はシングルクォートで括る * 'abc' * * 返却する値がnumber型の場合はそのまま返す * 1234 * * 返却する値がboolean型の場合はそのまま返す * true * false * * @param {string} property `obj.param1.param2`などのドットで繋いだプロパティ * @param {Record<string, any>} entity * @param {*} [options] * ├ responseType 'string' | 'array' | 'object' * @returns {string} */ extractValue(property, entity, options) { const value = common.getProperty(entity, property); let result = ''; switch (options?.responseType) { case 'array': case 'object': return value; default: // string if (Array.isArray(value)) { result = `(${value.map(v => typeof v === 'string' ? `'${this.escape(v)}'` : v).join(',')})`; } else { result = typeof value === 'string' ? `'${this.escape(value)}'` : value; } return result; } } /** * SQLインジェクション対策 * * シングルクォートのエスケープ * * バックスラッシュのエスケープ * * @param {string} str * @returns {string} */ escape(str) { let escapedString = str; escapedString = escapedString.replace(/'/g, '\'\''); escapedString = escapedString.replace(/\\/g, '\\\\'); return escapedString; } } exports.SQLBuilder = SQLBuilder; //# sourceMappingURL=sql-builder.js.map