forge-sql-orm 2.1.13 → 2.1.14
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 +260 -1
- package/dist/core/ForgeSQLORM.d.ts +29 -1
- package/dist/core/ForgeSQLORM.d.ts.map +1 -1
- package/dist/core/ForgeSQLORM.js +61 -0
- package/dist/core/ForgeSQLORM.js.map +1 -1
- package/dist/core/ForgeSQLQueryBuilder.d.ts +179 -1
- package/dist/core/ForgeSQLQueryBuilder.d.ts.map +1 -1
- package/dist/core/ForgeSQLQueryBuilder.js.map +1 -1
- package/dist/core/Rovo.d.ts +116 -0
- package/dist/core/Rovo.d.ts.map +1 -0
- package/dist/core/Rovo.js +647 -0
- package/dist/core/Rovo.js.map +1 -0
- package/dist/utils/metadataContextUtils.d.ts.map +1 -1
- package/dist/utils/metadataContextUtils.js +1 -3
- package/dist/utils/metadataContextUtils.js.map +1 -1
- package/package.json +7 -6
- package/src/core/ForgeSQLORM.ts +64 -0
- package/src/core/ForgeSQLQueryBuilder.ts +200 -1
- package/src/core/Rovo.ts +765 -0
- package/src/utils/metadataContextUtils.ts +1 -3
package/src/core/Rovo.ts
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ForgeSqlOperation,
|
|
3
|
+
ForgeSqlOrmOptions,
|
|
4
|
+
RlsSettings,
|
|
5
|
+
RovoIntegration,
|
|
6
|
+
RovoIntegrationSetting,
|
|
7
|
+
RovoIntegrationSettingCreator,
|
|
8
|
+
} from "./ForgeSQLQueryBuilder";
|
|
9
|
+
import { Result, sql } from "@forge/sql";
|
|
10
|
+
import { Parser, Select } from "node-sql-parser";
|
|
11
|
+
import { AnyMySqlTable, MySqlColumn } from "drizzle-orm/mysql-core";
|
|
12
|
+
import { getTableName } from "drizzle-orm/table";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Implementation of RovoIntegrationSetting interface.
|
|
16
|
+
* Stores configuration for Rovo query execution including user context, table name, and RLS settings.
|
|
17
|
+
*
|
|
18
|
+
* @class RovoIntegrationSettingImpl
|
|
19
|
+
* @implements {RovoIntegrationSetting}
|
|
20
|
+
*/
|
|
21
|
+
class RovoIntegrationSettingImpl implements RovoIntegrationSetting {
|
|
22
|
+
private readonly accountId: string;
|
|
23
|
+
private readonly tableName: string;
|
|
24
|
+
private readonly contextParam: Record<string, string>;
|
|
25
|
+
private readonly rls: boolean;
|
|
26
|
+
private readonly rlsFields: string[];
|
|
27
|
+
private readonly rlsWherePart: (alias: string) => string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a new RovoIntegrationSettingImpl instance.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} accountId - The account ID of the active user
|
|
33
|
+
* @param {string} tableName - The name of the table to query
|
|
34
|
+
* @param {Record<string, string>} contextParam - Context parameters for query substitution
|
|
35
|
+
* @param {boolean} rls - Whether Row-Level Security is enabled
|
|
36
|
+
* @param {string[]} rlsFields - Array of field names required for RLS validation
|
|
37
|
+
* @param {(alias: string) => string} rlsWherePart - Function that generates WHERE clause for RLS
|
|
38
|
+
*/
|
|
39
|
+
constructor(
|
|
40
|
+
accountId: string,
|
|
41
|
+
tableName: string,
|
|
42
|
+
contextParam: Record<string, string>,
|
|
43
|
+
rls: boolean,
|
|
44
|
+
rlsFields: string[],
|
|
45
|
+
rlsWherePart: (alias: string) => string,
|
|
46
|
+
) {
|
|
47
|
+
this.accountId = accountId;
|
|
48
|
+
this.tableName = tableName;
|
|
49
|
+
this.contextParam = contextParam;
|
|
50
|
+
this.rls = rls;
|
|
51
|
+
this.rlsFields = rlsFields;
|
|
52
|
+
this.rlsWherePart = rlsWherePart;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Gets the account ID of the active user.
|
|
57
|
+
*
|
|
58
|
+
* @returns {string} The account ID of the active user
|
|
59
|
+
*/
|
|
60
|
+
getActiveUser(): string {
|
|
61
|
+
return this.accountId;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Gets the context parameters for query substitution.
|
|
66
|
+
*
|
|
67
|
+
* @returns {Record<string, string>} Map of parameter names to their values
|
|
68
|
+
*/
|
|
69
|
+
getParameters(): Record<string, string> {
|
|
70
|
+
return this.contextParam;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Gets the name of the table to query.
|
|
75
|
+
*
|
|
76
|
+
* @returns {string} The table name
|
|
77
|
+
*/
|
|
78
|
+
getTableName(): string {
|
|
79
|
+
return this.tableName;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Checks if Row-Level Security is enabled.
|
|
84
|
+
*
|
|
85
|
+
* @returns {boolean} True if RLS is enabled, false otherwise
|
|
86
|
+
*/
|
|
87
|
+
isUseRLS(): boolean {
|
|
88
|
+
return this.rls;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Gets the list of field names required for RLS validation.
|
|
93
|
+
*
|
|
94
|
+
* @returns {string[]} Array of field names that must be present in SELECT clause for RLS
|
|
95
|
+
*/
|
|
96
|
+
userScopeFields(): string[] {
|
|
97
|
+
return this.rlsFields;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Generates the WHERE clause for Row-Level Security filtering.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} alias - The table alias to use in the WHERE clause
|
|
104
|
+
* @returns {string} SQL WHERE clause condition for RLS filtering
|
|
105
|
+
*/
|
|
106
|
+
userScopeWhere(alias: string): string {
|
|
107
|
+
return this.rlsWherePart(alias);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Builder class for creating RovoIntegrationSetting instances.
|
|
113
|
+
* Provides a fluent API for configuring Rovo query settings including context parameters and RLS.
|
|
114
|
+
*
|
|
115
|
+
* @class RovoIntegrationSettingCreatorImpl
|
|
116
|
+
* @implements {RovoIntegrationSettingCreator}
|
|
117
|
+
*/
|
|
118
|
+
class RovoIntegrationSettingCreatorImpl implements RovoIntegrationSettingCreator {
|
|
119
|
+
private readonly tableName: string;
|
|
120
|
+
private readonly accountId: string;
|
|
121
|
+
private readonly contextParam: Record<string, string> = {};
|
|
122
|
+
private readonly rlsFields: string[] = [];
|
|
123
|
+
private isUseRls: boolean = false;
|
|
124
|
+
private isUseRlsConditional: () => Promise<boolean> = async () => true;
|
|
125
|
+
private wherePart: (alias: string) => string = () => "";
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Creates a new RovoIntegrationSettingCreatorImpl instance.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} tableName - The name of the table to query
|
|
131
|
+
* @param {string} accountId - The account ID of the active user
|
|
132
|
+
*/
|
|
133
|
+
constructor(tableName: string, accountId: string) {
|
|
134
|
+
this.tableName = tableName;
|
|
135
|
+
this.accountId = accountId;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Adds a context parameter for query substitution.
|
|
140
|
+
* Context parameters are replaced in the SQL query before execution.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} parameterName - The parameter name to replace in the query
|
|
143
|
+
* @param {string} value - The value to substitute for the parameter
|
|
144
|
+
* @returns {RovoIntegrationSettingCreator} This builder instance for method chaining
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```typescript
|
|
148
|
+
* builder.addContextParameter('{{projectKey}}', 'PROJ-123');
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
addContextParameter(parameterName: string, value: string): RovoIntegrationSettingCreator {
|
|
152
|
+
this.contextParam[parameterName] = value;
|
|
153
|
+
return this;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Enables Row-Level Security (RLS) for the query.
|
|
158
|
+
* Returns a RlsSettings builder for configuring RLS options.
|
|
159
|
+
*
|
|
160
|
+
* @returns {RlsSettings} RLS settings builder for configuring security options
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```typescript
|
|
164
|
+
* builder.useRLS()
|
|
165
|
+
* .addRlsColumn(usersTable.id)
|
|
166
|
+
* .addRlsWherePart((alias) => `${alias}.id = '${accountId}'`)
|
|
167
|
+
* .finish();
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
useRLS(): RlsSettings {
|
|
171
|
+
const _this = this;
|
|
172
|
+
/**
|
|
173
|
+
* Internal implementation of RlsSettings interface.
|
|
174
|
+
* Provides fluent API for configuring Row-Level Security settings.
|
|
175
|
+
*
|
|
176
|
+
* @class RlsSettingsImpl
|
|
177
|
+
* @implements {RlsSettings}
|
|
178
|
+
*/
|
|
179
|
+
return new (class RlsSettingsImpl implements RlsSettings {
|
|
180
|
+
private isUseRlsConditionalSettings: () => Promise<boolean> = async () => true;
|
|
181
|
+
private rlsFieldsSettings: string[] = [];
|
|
182
|
+
private wherePartSettings: (alias: string) => string = () => "";
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Sets a conditional function to determine if RLS should be applied.
|
|
186
|
+
*
|
|
187
|
+
* @param {() => Promise<boolean>} condition - Async function that returns true if RLS should be enabled
|
|
188
|
+
* @returns {RlsSettings} This builder instance for method chaining
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```typescript
|
|
192
|
+
* .addRlsCondition(async () => {
|
|
193
|
+
* const user = await getUser();
|
|
194
|
+
* return !user.isAdmin;
|
|
195
|
+
* })
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
addRlsCondition(condition: () => Promise<boolean>): RlsSettings {
|
|
199
|
+
this.isUseRlsConditionalSettings = condition;
|
|
200
|
+
return this;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Adds a column name that must be present in the SELECT clause for RLS validation.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} columnName - The name of the column to require
|
|
207
|
+
* @returns {RlsSettings} This builder instance for method chaining
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* ```typescript
|
|
211
|
+
* .addRlsColumnName('userId')
|
|
212
|
+
* ```
|
|
213
|
+
*/
|
|
214
|
+
addRlsColumnName(columnName: string): RlsSettings {
|
|
215
|
+
this.rlsFieldsSettings.push(columnName);
|
|
216
|
+
return this;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Adds a Drizzle column that must be present in the SELECT clause for RLS validation.
|
|
221
|
+
*
|
|
222
|
+
* @param {MySqlColumn} column - The Drizzle column object
|
|
223
|
+
* @returns {RlsSettings} This builder instance for method chaining
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* ```typescript
|
|
227
|
+
* .addRlsColumn(usersTable.userId)
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
addRlsColumn(column: MySqlColumn): RlsSettings {
|
|
231
|
+
this.rlsFieldsSettings.push(column.name);
|
|
232
|
+
return this;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Sets the WHERE clause function for RLS filtering.
|
|
237
|
+
* The function receives a table alias and should return a SQL WHERE condition.
|
|
238
|
+
*
|
|
239
|
+
* @param {(alias: string) => string} wherePart - Function that generates WHERE clause
|
|
240
|
+
* @returns {RlsSettings} This builder instance for method chaining
|
|
241
|
+
*
|
|
242
|
+
* @example
|
|
243
|
+
* ```typescript
|
|
244
|
+
* .addRlsWherePart((alias) => `${alias}.userId = '${accountId}'`)
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
addRlsWherePart(wherePart: (alias: string) => string): RlsSettings {
|
|
248
|
+
this.wherePartSettings = wherePart;
|
|
249
|
+
return this;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Finishes RLS configuration and returns to the settings builder.
|
|
254
|
+
*
|
|
255
|
+
* @returns {RovoIntegrationSettingCreator} The parent settings builder
|
|
256
|
+
*/
|
|
257
|
+
finish(): RovoIntegrationSettingCreator {
|
|
258
|
+
_this.isUseRls = true;
|
|
259
|
+
this.rlsFieldsSettings.forEach((columnName) => _this.rlsFields.push(columnName));
|
|
260
|
+
_this.wherePart = this.wherePartSettings;
|
|
261
|
+
_this.isUseRlsConditional = this.isUseRlsConditionalSettings;
|
|
262
|
+
return _this;
|
|
263
|
+
}
|
|
264
|
+
})();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Builds and returns the RovoIntegrationSetting instance.
|
|
269
|
+
* Evaluates the RLS condition if RLS is enabled.
|
|
270
|
+
*
|
|
271
|
+
* @returns {Promise<RovoIntegrationSetting>} The configured RovoIntegrationSetting instance
|
|
272
|
+
*
|
|
273
|
+
* @example
|
|
274
|
+
* ```typescript
|
|
275
|
+
* const settings = await builder
|
|
276
|
+
* .addContextParameter('{{projectKey}}', 'PROJ-123')
|
|
277
|
+
* .useRLS()
|
|
278
|
+
* .addRlsColumn(usersTable.id)
|
|
279
|
+
* .addRlsWherePart((alias) => `${alias}.id = '${accountId}'`)
|
|
280
|
+
* .finish()
|
|
281
|
+
* .build();
|
|
282
|
+
* ```
|
|
283
|
+
*/
|
|
284
|
+
async build(): Promise<RovoIntegrationSetting> {
|
|
285
|
+
const useRls = this.isUseRls ? await this.isUseRlsConditional() : false;
|
|
286
|
+
return new RovoIntegrationSettingImpl(
|
|
287
|
+
this.accountId,
|
|
288
|
+
this.tableName,
|
|
289
|
+
this.contextParam,
|
|
290
|
+
useRls,
|
|
291
|
+
this.rlsFields,
|
|
292
|
+
this.wherePart,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Main class for Rovo integration - a secure pattern for natural-language analytics in Forge apps.
|
|
299
|
+
*
|
|
300
|
+
* Rovo provides a secure way to execute dynamic SQL queries with comprehensive security validations:
|
|
301
|
+
* - Only SELECT queries are allowed
|
|
302
|
+
* - Queries are restricted to a single table
|
|
303
|
+
* - JOINs, subqueries, and window functions are blocked
|
|
304
|
+
* - Row-Level Security (RLS) support for data isolation
|
|
305
|
+
* - Post-execution validation of query results
|
|
306
|
+
*
|
|
307
|
+
* @class Rovo
|
|
308
|
+
* @implements {RovoIntegration}
|
|
309
|
+
*
|
|
310
|
+
* @example
|
|
311
|
+
* ```typescript
|
|
312
|
+
* const rovo = forgeSQL.rovo();
|
|
313
|
+
* const settings = await rovo.rovoSettingBuilder(usersTable, accountId)
|
|
314
|
+
* .useRLS()
|
|
315
|
+
* .addRlsColumn(usersTable.id)
|
|
316
|
+
* .addRlsWherePart((alias) => `${alias}.id = '${accountId}'`)
|
|
317
|
+
* .finish()
|
|
318
|
+
* .build();
|
|
319
|
+
*
|
|
320
|
+
* const result = await rovo.dynamicIsolatedQuery(
|
|
321
|
+
* "SELECT id, name FROM users WHERE status = 'active'",
|
|
322
|
+
* settings
|
|
323
|
+
* );
|
|
324
|
+
* ```
|
|
325
|
+
*/
|
|
326
|
+
export class Rovo implements RovoIntegration {
|
|
327
|
+
private readonly forgeOperations: ForgeSqlOperation;
|
|
328
|
+
private readonly options: ForgeSqlOrmOptions;
|
|
329
|
+
/**
|
|
330
|
+
* Creates a new Rovo instance.
|
|
331
|
+
*
|
|
332
|
+
* @param {ForgeSqlOperation} forgeSqlOperations - The ForgeSQL operations instance for query analysis
|
|
333
|
+
* @param options - Configuration options for the ORM
|
|
334
|
+
*/
|
|
335
|
+
constructor(forgeSqlOperations: ForgeSqlOperation, options: ForgeSqlOrmOptions) {
|
|
336
|
+
this.forgeOperations = forgeSqlOperations;
|
|
337
|
+
this.options = options;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Parses SQL query into AST and validates it's a single SELECT statement
|
|
342
|
+
* @param sqlQuery - Normalized SQL query string
|
|
343
|
+
* @returns Parsed AST of the SELECT statement
|
|
344
|
+
* @throws Error if parsing fails or query is not a single SELECT statement
|
|
345
|
+
*/
|
|
346
|
+
private parseSqlQuery(sqlQuery: string): Select {
|
|
347
|
+
const parser = new Parser();
|
|
348
|
+
let ast;
|
|
349
|
+
try {
|
|
350
|
+
ast = parser.astify(sqlQuery);
|
|
351
|
+
} catch (parseError: any) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
`SQL parsing error: ${parseError.message || "Invalid SQL syntax"}. Please check your query syntax.`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Validate that query is a SELECT statement
|
|
358
|
+
// Parser can return either an object (single statement) or an array (multiple statements)
|
|
359
|
+
if (Array.isArray(ast)) {
|
|
360
|
+
if (ast.length !== 1 || ast[0].type !== "select") {
|
|
361
|
+
throw new Error(
|
|
362
|
+
"Only a single SELECT query is allowed. Multiple statements or non-SELECT statements are not permitted.",
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
return ast[0];
|
|
366
|
+
} else if (ast && ast.type === "select") {
|
|
367
|
+
return ast;
|
|
368
|
+
} else {
|
|
369
|
+
throw new Error("Only SELECT queries are allowed.");
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Recursively extracts all table names from SQL AST node
|
|
375
|
+
* @param node - AST node to extract tables from
|
|
376
|
+
* @returns Array of table names (uppercase)
|
|
377
|
+
*/
|
|
378
|
+
private extractTables(node: any): string[] {
|
|
379
|
+
const tables: string[] = [];
|
|
380
|
+
|
|
381
|
+
if (node.type === "table" || node.type === "dual") {
|
|
382
|
+
if (node.table) {
|
|
383
|
+
const tableName = node.table === "dual" ? "dual" : node.table.name || node.table;
|
|
384
|
+
if (tableName && tableName !== "dual") {
|
|
385
|
+
tables.push(tableName.toUpperCase());
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (node.from) {
|
|
391
|
+
if (Array.isArray(node.from)) {
|
|
392
|
+
node.from.forEach((fromItem: any) => {
|
|
393
|
+
tables.push(...this.extractTables(fromItem));
|
|
394
|
+
});
|
|
395
|
+
} else {
|
|
396
|
+
tables.push(...this.extractTables(node.from));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (node.join) {
|
|
401
|
+
if (Array.isArray(node.join)) {
|
|
402
|
+
node.join.forEach((joinItem: any) => {
|
|
403
|
+
tables.push(...this.extractTables(joinItem));
|
|
404
|
+
});
|
|
405
|
+
} else {
|
|
406
|
+
tables.push(...this.extractTables(node.join));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return tables;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Recursively checks if AST node contains scalar subqueries
|
|
415
|
+
* @param node - AST node to check
|
|
416
|
+
* @returns true if node contains scalar subquery, false otherwise
|
|
417
|
+
*/
|
|
418
|
+
private hasScalarSubquery(node: any): boolean {
|
|
419
|
+
if (!node) return false;
|
|
420
|
+
|
|
421
|
+
if (node.type === "subquery" || (node.ast && node.ast.type === "select")) {
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (Array.isArray(node)) {
|
|
426
|
+
return node.some((item) => this.hasScalarSubquery(item));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (typeof node === "object") {
|
|
430
|
+
return Object.values(node).some((value) => this.hasScalarSubquery(value));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Creates a settings builder for Rovo queries using a raw table name.
|
|
438
|
+
*
|
|
439
|
+
* @param {string} tableName - The name of the table to query
|
|
440
|
+
* @param {string} accountId - The account ID of the active user
|
|
441
|
+
* @returns {RovoIntegrationSettingCreator} Builder for configuring Rovo query settings
|
|
442
|
+
*
|
|
443
|
+
* @example
|
|
444
|
+
* ```typescript
|
|
445
|
+
* const builder = rovo.rovoRawSettingBuilder('users', accountId);
|
|
446
|
+
* ```
|
|
447
|
+
*/
|
|
448
|
+
rovoRawSettingBuilder(tableName: string, accountId: string): RovoIntegrationSettingCreator {
|
|
449
|
+
return new RovoIntegrationSettingCreatorImpl(tableName, accountId);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Creates a settings builder for Rovo queries using a Drizzle table object.
|
|
454
|
+
*
|
|
455
|
+
* @param {AnyMySqlTable} table - The Drizzle table object
|
|
456
|
+
* @param {string} accountId - The account ID of the active user
|
|
457
|
+
* @returns {RovoIntegrationSettingCreator} Builder for configuring Rovo query settings
|
|
458
|
+
*
|
|
459
|
+
* @example
|
|
460
|
+
* ```typescript
|
|
461
|
+
* const builder = rovo.rovoSettingBuilder(usersTable, accountId);
|
|
462
|
+
* ```
|
|
463
|
+
*/
|
|
464
|
+
rovoSettingBuilder(table: AnyMySqlTable, accountId: string): RovoIntegrationSettingCreator {
|
|
465
|
+
return this.rovoRawSettingBuilder(getTableName(table), accountId);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Executes a dynamic SQL query with comprehensive security validations.
|
|
470
|
+
*
|
|
471
|
+
* This method performs multiple security checks:
|
|
472
|
+
* 1. Validates that the query is a SELECT statement
|
|
473
|
+
* 2. Ensures the query targets only the specified table
|
|
474
|
+
* 3. Blocks JOINs, subqueries, and window functions
|
|
475
|
+
* 4. Applies Row-Level Security filtering if enabled
|
|
476
|
+
* 5. Validates query results to ensure security fields are present
|
|
477
|
+
*
|
|
478
|
+
* @param {string} dynamicSql - The SQL query to execute (must be a SELECT statement)
|
|
479
|
+
* @param {RovoIntegrationSetting} settings - Configuration settings for the query
|
|
480
|
+
* @returns {Promise<Result<unknown>>} Query execution result with metadata
|
|
481
|
+
* @throws {Error} If the query violates security restrictions
|
|
482
|
+
*
|
|
483
|
+
* @example
|
|
484
|
+
* ```typescript
|
|
485
|
+
* const result = await rovo.dynamicIsolatedQuery(
|
|
486
|
+
* "SELECT id, name, email FROM users WHERE status = 'active' ORDER BY name",
|
|
487
|
+
* settings
|
|
488
|
+
* );
|
|
489
|
+
*
|
|
490
|
+
* console.log(result.rows); // Query results
|
|
491
|
+
* console.log(result.metadata); // Query metadata
|
|
492
|
+
* ```
|
|
493
|
+
*/
|
|
494
|
+
async dynamicIsolatedQuery(
|
|
495
|
+
dynamicSql: string,
|
|
496
|
+
settings: RovoIntegrationSetting,
|
|
497
|
+
): Promise<Result<unknown>> {
|
|
498
|
+
const query: string = dynamicSql;
|
|
499
|
+
const tableName = settings.getTableName();
|
|
500
|
+
const accountId = settings.getActiveUser();
|
|
501
|
+
const parameters = settings.getParameters();
|
|
502
|
+
if (!query || !query.trim()) {
|
|
503
|
+
throw new Error("SQL query is required. Please provide a valid SELECT query.");
|
|
504
|
+
}
|
|
505
|
+
if (!tableName) {
|
|
506
|
+
throw new Error("Table Name is required. Please provide a valid Table Name.");
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Quick validation: check if query starts with SELECT (case-insensitive)
|
|
510
|
+
// This allows us to fail fast for non-SELECT queries before normalization
|
|
511
|
+
const trimmedQuery = query.trim();
|
|
512
|
+
const quickUpper = trimmedQuery.toUpperCase();
|
|
513
|
+
if (!quickUpper.startsWith("SELECT")) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
"Only SELECT queries are allowed. Data modification operations (INSERT, UPDATE, DELETE, etc.) are not permitted.",
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Normalizes SQL query using AST parsing and stringification.
|
|
521
|
+
* This approach is safer than regex-based normalization as it:
|
|
522
|
+
* - Avoids regex backtracking vulnerabilities
|
|
523
|
+
* - Preserves SQL semantics correctly
|
|
524
|
+
* - Handles complex SQL structures properly
|
|
525
|
+
*
|
|
526
|
+
* @param sql - SQL query string to normalize (must be a valid SELECT query)
|
|
527
|
+
* @returns Normalized SQL string
|
|
528
|
+
* @throws Error if parsing fails or query is invalid
|
|
529
|
+
*/
|
|
530
|
+
const normalizeSqlString = (sql: string): string => {
|
|
531
|
+
try {
|
|
532
|
+
const parser = new Parser();
|
|
533
|
+
// Parse SQL to AST
|
|
534
|
+
const ast = parser.astify(sql.trim());
|
|
535
|
+
// Validate it's a SELECT query before normalizing
|
|
536
|
+
if (Array.isArray(ast)) {
|
|
537
|
+
if (ast.length !== 1 || ast[0].type !== "select") {
|
|
538
|
+
throw new Error(
|
|
539
|
+
"Only a single SELECT query is allowed. Multiple statements or non-SELECT statements are not permitted.",
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
} else if (ast && ast.type !== "select") {
|
|
543
|
+
throw new Error("Only SELECT queries are allowed.");
|
|
544
|
+
}
|
|
545
|
+
// Convert AST back to SQL (this normalizes formatting)
|
|
546
|
+
const normalized = parser.sqlify(Array.isArray(ast) ? ast[0] : ast);
|
|
547
|
+
// trim
|
|
548
|
+
return normalized.trim();
|
|
549
|
+
} catch (error: any) {
|
|
550
|
+
// If it's a validation error we threw, re-throw it
|
|
551
|
+
if (
|
|
552
|
+
error.message &&
|
|
553
|
+
(error.message.includes("Only") || error.message.includes("single SELECT"))
|
|
554
|
+
) {
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
// For parsing errors, wrap them in a more user-friendly message
|
|
558
|
+
// Check if error is already wrapped to avoid double wrapping
|
|
559
|
+
if (error.message && error.message.includes("SQL parsing error")) {
|
|
560
|
+
throw error;
|
|
561
|
+
}
|
|
562
|
+
throw new Error(
|
|
563
|
+
`SQL parsing error: ${error.message || "Invalid SQL syntax"}. Please check your query syntax.`,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
let normalized: string;
|
|
568
|
+
try {
|
|
569
|
+
normalized = normalizeSqlString(trimmedQuery);
|
|
570
|
+
} catch (error: any) {
|
|
571
|
+
// Re-throw validation errors as-is
|
|
572
|
+
if (
|
|
573
|
+
error.message &&
|
|
574
|
+
(error.message.includes("Only") || error.message.includes("single SELECT"))
|
|
575
|
+
) {
|
|
576
|
+
throw error;
|
|
577
|
+
}
|
|
578
|
+
// Check if error is already wrapped to avoid double wrapping
|
|
579
|
+
if (error.message && error.message.includes("SQL parsing error")) {
|
|
580
|
+
throw error;
|
|
581
|
+
}
|
|
582
|
+
// For other errors, wrap them
|
|
583
|
+
throw new Error(
|
|
584
|
+
`SQL parsing error: ${error.message || "Invalid SQL syntax"}. Please check your query syntax.`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const upperTableName = tableName.toUpperCase();
|
|
589
|
+
// Validate table name
|
|
590
|
+
// sqlify may add backticks, so we check for both formats: FROM table_name and FROM `table_name`
|
|
591
|
+
const tableNamePattern = new RegExp(`FROM\\s+[\`]?${upperTableName}[\`]?`, "i");
|
|
592
|
+
if (!tableNamePattern.test(normalized)) {
|
|
593
|
+
throw new Error(
|
|
594
|
+
"Queries must target the '" +
|
|
595
|
+
upperTableName +
|
|
596
|
+
"' table only. Other tables are not accessible.",
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (!accountId) {
|
|
601
|
+
throw new Error(
|
|
602
|
+
"Authentication error: User account ID is missing. Please ensure you are logged in.",
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
normalized = normalized.replaceAll("ari:cloud:identity::user/", "");
|
|
606
|
+
Object.entries(parameters).forEach(([key, value]) => {
|
|
607
|
+
normalized = normalized.replaceAll(key, value);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// Parse SQL query to validate structure before execution
|
|
611
|
+
const selectAst = this.parseSqlQuery(normalized);
|
|
612
|
+
|
|
613
|
+
// Extract all tables from the query
|
|
614
|
+
const tablesInQuery = this.extractTables(selectAst);
|
|
615
|
+
const uniqueTables = [...new Set(tablesInQuery)];
|
|
616
|
+
|
|
617
|
+
// Check that only table is used
|
|
618
|
+
const invalidTables = uniqueTables.filter((table) => table !== upperTableName);
|
|
619
|
+
|
|
620
|
+
if (invalidTables.length > 0) {
|
|
621
|
+
throw new Error(
|
|
622
|
+
`Security violation: Query references table(s) other than '${tableName}': ${invalidTables.join(", ")}. ` +
|
|
623
|
+
`Only queries against the ${tableName} table are allowed. ` +
|
|
624
|
+
"JOINs, subqueries, or references to other tables are not permitted for security reasons.",
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Check for scalar subqueries in SELECT columns
|
|
629
|
+
if (selectAst.columns && Array.isArray(selectAst.columns)) {
|
|
630
|
+
const hasSubqueryInColumns = selectAst.columns.some((col: any) => {
|
|
631
|
+
if (col.expr) {
|
|
632
|
+
return this.hasScalarSubquery(col.expr);
|
|
633
|
+
}
|
|
634
|
+
return this.hasScalarSubquery(col);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
if (hasSubqueryInColumns) {
|
|
638
|
+
throw new Error(
|
|
639
|
+
"Security violation: Scalar subqueries in SELECT columns are not allowed. " +
|
|
640
|
+
"Subqueries can be used to access data from other tables or bypass security restrictions. " +
|
|
641
|
+
"Please rewrite your query without using subqueries in the SELECT clause.",
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Check for JOIN operations using EXPLAIN
|
|
647
|
+
const explainRows = await this.forgeOperations.analyze().explainRaw(normalized, []);
|
|
648
|
+
|
|
649
|
+
const hasJoin = explainRows.some((row) => {
|
|
650
|
+
const info = (row.operatorInfo ?? "").toUpperCase();
|
|
651
|
+
return (
|
|
652
|
+
info.includes("JOIN") ||
|
|
653
|
+
info.includes("CARTESIAN") ||
|
|
654
|
+
info.includes("NESTED LOOP") ||
|
|
655
|
+
info.includes("HASH JOIN")
|
|
656
|
+
);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
if (hasJoin) {
|
|
660
|
+
throw new Error(
|
|
661
|
+
"Security violation: JOIN operations are not allowed. " +
|
|
662
|
+
`For security reasons, Rovo analytics only supports queries over the ${tableName} table without joins, subqueries, or references to other tables. ` +
|
|
663
|
+
`Please rewrite your query to use only the ${tableName} table.`,
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Detect window functions (e.g., COUNT(*) OVER(...), ROW_NUMBER() OVER(...))
|
|
668
|
+
// Window functions are not allowed for security
|
|
669
|
+
// Users should use regular aggregate functions with GROUP BY instead
|
|
670
|
+
const hasWindow = explainRows.some((row) => {
|
|
671
|
+
const id = row.id.toUpperCase();
|
|
672
|
+
const info = (row.operatorInfo ?? "").toUpperCase();
|
|
673
|
+
return id.includes("WINDOW") || info.includes(" OVER(") || info.includes(" OVER()");
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
if (hasWindow) {
|
|
677
|
+
throw new Error(
|
|
678
|
+
"Window functions (for example COUNT(*) OVER(...)) are not allowed in Rovo SQL for this app. " +
|
|
679
|
+
"Please rephrase your question so that it uses regular aggregates instead of window functions.",
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Check for references to other tables in the query execution plan
|
|
684
|
+
// This detects JOINs, subqueries, or any other references to tables other than expected
|
|
685
|
+
const tablesInPlan = explainRows.filter(
|
|
686
|
+
(row) =>
|
|
687
|
+
row.accessObject?.startsWith("table:") &&
|
|
688
|
+
row.accessObject?.toLowerCase() !== "table:" + tableName.toLowerCase(),
|
|
689
|
+
);
|
|
690
|
+
if (tablesInPlan.length > 0) {
|
|
691
|
+
throw new Error(
|
|
692
|
+
`Security violation: Query execution plan detected references to tables other than '${tableName.toLowerCase()}'. ` +
|
|
693
|
+
`Only queries against the ${tableName.toLowerCase()} table are allowed. ` +
|
|
694
|
+
"JOINs, subqueries, or references to other tables are not permitted for security reasons.",
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// row-level security protection
|
|
699
|
+
const isUseRLSFiltering = settings.isUseRLS();
|
|
700
|
+
if (isUseRLSFiltering) {
|
|
701
|
+
if (normalized.endsWith(";")) {
|
|
702
|
+
normalized = normalized.slice(0, -1);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
normalized = `
|
|
706
|
+
SELECT *
|
|
707
|
+
FROM (
|
|
708
|
+
${normalized}
|
|
709
|
+
) AS t
|
|
710
|
+
WHERE (${settings.userScopeWhere("t")})
|
|
711
|
+
`;
|
|
712
|
+
}
|
|
713
|
+
if (this.options.logRawSqlQuery) {
|
|
714
|
+
// eslint-disable-next-line no-console
|
|
715
|
+
console.debug("Rovo query: " + normalized);
|
|
716
|
+
}
|
|
717
|
+
const result = await sql.executeRaw(normalized);
|
|
718
|
+
|
|
719
|
+
// Post-execution validation for non-admin users
|
|
720
|
+
// Verify that required security fields exist and come from table
|
|
721
|
+
// Also ensure all fields with orgTable come from (no JOINs or subqueries)
|
|
722
|
+
if (isUseRLSFiltering && result?.metadata?.fields) {
|
|
723
|
+
const fields = result.metadata.fields as Array<{
|
|
724
|
+
name: string;
|
|
725
|
+
schema?: string;
|
|
726
|
+
table?: string;
|
|
727
|
+
orgTable?: string;
|
|
728
|
+
}>;
|
|
729
|
+
|
|
730
|
+
settings.userScopeFields().forEach((field) => {
|
|
731
|
+
const actualFields = fields.filter((f) => f.name.toLowerCase() === field?.toLowerCase());
|
|
732
|
+
if (actualFields.length === 0) {
|
|
733
|
+
throw new Error(
|
|
734
|
+
`Security validation failed: The query must include ${field} as a raw column in the SELECT statement. This field is required for row-level security enforcement.`,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
const actualField = actualFields.find(
|
|
738
|
+
(f) => !f.orgTable || f.orgTable.toUpperCase() !== upperTableName,
|
|
739
|
+
);
|
|
740
|
+
if (actualField) {
|
|
741
|
+
throw new Error(
|
|
742
|
+
`Security validation failed: '${field}' must come directly from the ${upperTableName} table. Joins, subqueries, or table aliases that change the origin of this column are not allowed.`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// Check that all fields with orgTable come from table
|
|
748
|
+
// (This prevents JOINs or subqueries that reference other tables)
|
|
749
|
+
// Note: Fields without orgTable (empty/undefined) are allowed - these are computed/calculated fields
|
|
750
|
+
// We only check fields that have orgTable set - if orgTable exists, it must be table
|
|
751
|
+
const fieldsFromOtherTables = fields.filter(
|
|
752
|
+
(f) => f.orgTable && f.orgTable.toUpperCase() !== upperTableName,
|
|
753
|
+
);
|
|
754
|
+
if (fieldsFromOtherTables.length > 0) {
|
|
755
|
+
throw new Error(
|
|
756
|
+
`Security validation failed: All fields must come from the ${upperTableName} table. ` +
|
|
757
|
+
"Fields from other tables detected, which indicates the use of JOINs, subqueries, or references to other tables. " +
|
|
758
|
+
"This is not allowed for security reasons.",
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return result;
|
|
764
|
+
}
|
|
765
|
+
}
|