forge-sql-orm 2.1.12 → 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.
Files changed (79) hide show
  1. package/README.md +922 -549
  2. package/dist/core/ForgeSQLAnalyseOperations.d.ts.map +1 -1
  3. package/dist/core/ForgeSQLAnalyseOperations.js +257 -0
  4. package/dist/core/ForgeSQLAnalyseOperations.js.map +1 -0
  5. package/dist/core/ForgeSQLCacheOperations.js +172 -0
  6. package/dist/core/ForgeSQLCacheOperations.js.map +1 -0
  7. package/dist/core/ForgeSQLCrudOperations.js +349 -0
  8. package/dist/core/ForgeSQLCrudOperations.js.map +1 -0
  9. package/dist/core/ForgeSQLORM.d.ts +29 -1
  10. package/dist/core/ForgeSQLORM.d.ts.map +1 -1
  11. package/dist/core/ForgeSQLORM.js +1252 -0
  12. package/dist/core/ForgeSQLORM.js.map +1 -0
  13. package/dist/core/ForgeSQLQueryBuilder.d.ts +179 -1
  14. package/dist/core/ForgeSQLQueryBuilder.d.ts.map +1 -1
  15. package/dist/core/ForgeSQLQueryBuilder.js +77 -0
  16. package/dist/core/ForgeSQLQueryBuilder.js.map +1 -0
  17. package/dist/core/ForgeSQLSelectOperations.js +81 -0
  18. package/dist/core/ForgeSQLSelectOperations.js.map +1 -0
  19. package/dist/core/Rovo.d.ts +116 -0
  20. package/dist/core/Rovo.d.ts.map +1 -0
  21. package/dist/core/Rovo.js +647 -0
  22. package/dist/core/Rovo.js.map +1 -0
  23. package/dist/core/SystemTables.js +258 -0
  24. package/dist/core/SystemTables.js.map +1 -0
  25. package/dist/index.js +30 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/lib/drizzle/extensions/additionalActions.d.ts.map +1 -1
  28. package/dist/lib/drizzle/extensions/additionalActions.js +527 -0
  29. package/dist/lib/drizzle/extensions/additionalActions.js.map +1 -0
  30. package/dist/utils/cacheContextUtils.d.ts.map +1 -1
  31. package/dist/utils/cacheContextUtils.js +198 -0
  32. package/dist/utils/cacheContextUtils.js.map +1 -0
  33. package/dist/utils/cacheUtils.d.ts.map +1 -1
  34. package/dist/utils/cacheUtils.js +383 -0
  35. package/dist/utils/cacheUtils.js.map +1 -0
  36. package/dist/utils/forgeDriver.d.ts.map +1 -1
  37. package/dist/utils/forgeDriver.js +139 -0
  38. package/dist/utils/forgeDriver.js.map +1 -0
  39. package/dist/utils/forgeDriverProxy.js +68 -0
  40. package/dist/utils/forgeDriverProxy.js.map +1 -0
  41. package/dist/utils/metadataContextUtils.d.ts.map +1 -1
  42. package/dist/utils/metadataContextUtils.js +26 -0
  43. package/dist/utils/metadataContextUtils.js.map +1 -0
  44. package/dist/utils/requestTypeContextUtils.js +10 -0
  45. package/dist/utils/requestTypeContextUtils.js.map +1 -0
  46. package/dist/utils/sqlHints.js +52 -0
  47. package/dist/utils/sqlHints.js.map +1 -0
  48. package/dist/utils/sqlUtils.d.ts.map +1 -1
  49. package/dist/utils/sqlUtils.js +590 -0
  50. package/dist/utils/sqlUtils.js.map +1 -0
  51. package/dist/webtriggers/applyMigrationsWebTrigger.js +77 -0
  52. package/dist/webtriggers/applyMigrationsWebTrigger.js.map +1 -0
  53. package/dist/webtriggers/clearCacheSchedulerTrigger.js +83 -0
  54. package/dist/webtriggers/clearCacheSchedulerTrigger.js.map +1 -0
  55. package/dist/webtriggers/dropMigrationWebTrigger.js +54 -0
  56. package/dist/webtriggers/dropMigrationWebTrigger.js.map +1 -0
  57. package/dist/webtriggers/dropTablesMigrationWebTrigger.js +54 -0
  58. package/dist/webtriggers/dropTablesMigrationWebTrigger.js.map +1 -0
  59. package/dist/webtriggers/fetchSchemaWebTrigger.js +82 -0
  60. package/dist/webtriggers/fetchSchemaWebTrigger.js.map +1 -0
  61. package/dist/webtriggers/index.js +40 -0
  62. package/dist/webtriggers/index.js.map +1 -0
  63. package/dist/webtriggers/slowQuerySchedulerTrigger.js +80 -0
  64. package/dist/webtriggers/slowQuerySchedulerTrigger.js.map +1 -0
  65. package/package.json +31 -25
  66. package/src/core/ForgeSQLAnalyseOperations.ts +3 -2
  67. package/src/core/ForgeSQLORM.ts +64 -0
  68. package/src/core/ForgeSQLQueryBuilder.ts +200 -1
  69. package/src/core/Rovo.ts +765 -0
  70. package/src/lib/drizzle/extensions/additionalActions.ts +11 -0
  71. package/src/utils/cacheContextUtils.ts +9 -6
  72. package/src/utils/cacheUtils.ts +6 -4
  73. package/src/utils/forgeDriver.ts +3 -7
  74. package/src/utils/metadataContextUtils.ts +1 -3
  75. package/src/utils/sqlUtils.ts +33 -34
  76. package/dist/ForgeSQLORM.js +0 -3922
  77. package/dist/ForgeSQLORM.js.map +0 -1
  78. package/dist/ForgeSQLORM.mjs +0 -3905
  79. package/dist/ForgeSQLORM.mjs.map +0 -1
@@ -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
+ }