@rapidd/build 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.
@@ -0,0 +1,540 @@
1
+ /**
2
+ * Deep SQL Analyzer for PostgreSQL RLS Policies
3
+ * Uses extensive regex patterns to extract meaning from SQL expressions
4
+ */
5
+
6
+ class DeepSQLAnalyzer {
7
+ constructor() {
8
+ // Common PostgreSQL function patterns mapped to user fields
9
+ this.functionMappings = {
10
+ // User ID functions
11
+ 'get_current_user_id': 'id',
12
+ 'current_user_id': 'id',
13
+ 'auth.uid': 'id',
14
+ 'auth.user_id': 'id',
15
+
16
+ // Role functions
17
+ 'get_current_user_role': 'role',
18
+ 'current_user_role': 'role',
19
+ 'current_role': 'role',
20
+ 'auth.role': 'role',
21
+
22
+ // Tenant functions
23
+ 'get_current_tenant_id': 'tenant_id',
24
+ 'current_tenant_id': 'tenant_id',
25
+ 'current_tenant': 'tenant_id',
26
+
27
+ // Organization functions
28
+ 'get_current_org_id': 'org_id',
29
+ 'current_org_id': 'org_id',
30
+ 'current_organization_id': 'org_id',
31
+
32
+ // Related entity functions
33
+ 'get_current_student_id': 'student_id',
34
+ 'current_student_id': 'student_id',
35
+ 'get_student_id_for_user': 'student_id',
36
+
37
+ 'get_current_teacher_id': 'teacher_id',
38
+ 'current_teacher_id': 'teacher_id',
39
+ 'get_teacher_id_for_user': 'teacher_id',
40
+
41
+ 'get_current_employee_id': 'employee_id',
42
+ 'current_employee_id': 'employee_id',
43
+ 'get_employee_id_for_user': 'employee_id',
44
+
45
+ 'get_current_customer_id': 'customer_id',
46
+ 'current_customer_id': 'customer_id',
47
+ 'get_customer_id_for_user': 'customer_id'
48
+ };
49
+
50
+ // Session variable mappings
51
+ this.sessionMappings = {
52
+ 'app.current_user_id': 'id',
53
+ 'jwt.claims.sub': 'id',
54
+ 'request.jwt.claims.sub': 'id',
55
+ 'request.jwt.claim.sub': 'id',
56
+
57
+ 'app.current_role': 'role',
58
+ 'jwt.claims.role': 'role',
59
+ 'request.jwt.claim.role': 'role',
60
+
61
+ 'app.current_tenant': 'tenant_id',
62
+ 'app.tenant_id': 'tenant_id',
63
+ 'jwt.claims.tenant_id': 'tenant_id',
64
+
65
+ 'app.org_id': 'org_id',
66
+ 'app.organization_id': 'org_id'
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Analyze SQL expression and extract Prisma filters
72
+ */
73
+ analyzeSQLForFilters(sql) {
74
+ if (!sql || sql.trim() === '') {
75
+ return { filters: [], conditions: [], userContext: {} };
76
+ }
77
+
78
+ const analysis = {
79
+ filters: [],
80
+ conditions: [],
81
+ userContext: {}
82
+ };
83
+
84
+ // Normalize SQL
85
+ sql = this.normalizeSql(sql);
86
+
87
+ // Remove EXISTS subqueries with proper parentheses matching
88
+ const sqlWithoutExists = this.removeExistsSubqueries(sql);
89
+
90
+ // Extract different types of conditions (use sqlWithoutExists to avoid EXISTS internals)
91
+ this.extractDirectComparisons(sqlWithoutExists, analysis);
92
+ this.extractFunctionComparisons(sqlWithoutExists, analysis);
93
+ this.extractSessionVariableComparisons(sqlWithoutExists, analysis);
94
+ this.extractInClauses(sqlWithoutExists, analysis);
95
+
96
+ // Extract EXISTS from original SQL
97
+ this.extractExistsSubqueries(sql, analysis);
98
+
99
+ // Extract from original SQL for these
100
+ this.extractCaseWhenConditions(sql, analysis);
101
+ this.extractRoleChecks(sql, analysis);
102
+ this.extractComplexJoins(sql, analysis);
103
+
104
+ return analysis;
105
+ }
106
+
107
+ /**
108
+ * Normalize SQL for easier parsing
109
+ */
110
+ normalizeSql(sql) {
111
+ let normalized = sql
112
+ .replace(/\s+/g, ' ')
113
+ .replace(/\n/g, ' ')
114
+ .replace(/\t/g, ' ')
115
+ .replace(/::character varying/gi, '') // Remove multi-word type casts first
116
+ .replace(/::[\w_]+\s*\[\]/g, ']') // Replace ::type[] with just ] (preserve array bracket)
117
+ .replace(/::[\w_]+/g, '') // Remove simple type casts
118
+ .trim();
119
+
120
+ // Remove balanced outer wrapping parentheses
121
+ while (normalized.startsWith('(') && normalized.endsWith(')')) {
122
+ const inner = normalized.slice(1, -1);
123
+ // Count parentheses to check if outer pair is balanced
124
+ let depth = 0;
125
+ let isBalanced = true;
126
+ for (let i = 0; i < inner.length; i++) {
127
+ if (inner[i] === '(') depth++;
128
+ if (inner[i] === ')') depth--;
129
+ // If depth goes negative, the outer parens are needed
130
+ if (depth < 0) {
131
+ isBalanced = false;
132
+ break;
133
+ }
134
+ }
135
+ // Only remove if balanced and not a single function call
136
+ if (isBalanced && depth === 0 && (inner.includes(' = ') || inner.includes(' AND ') || inner.includes(' OR '))) {
137
+ normalized = inner.trim();
138
+ } else {
139
+ break;
140
+ }
141
+ }
142
+
143
+ return normalized;
144
+ }
145
+
146
+ /**
147
+ * Remove EXISTS subqueries with proper parentheses matching
148
+ */
149
+ removeExistsSubqueries(sql) {
150
+ let result = sql;
151
+ let changed = true;
152
+
153
+ // Keep removing EXISTS clauses until none are left
154
+ while (changed) {
155
+ changed = false;
156
+ const existsIndex = result.search(/EXISTS\s*\(/i);
157
+
158
+ if (existsIndex !== -1) {
159
+ // Find the matching closing parenthesis
160
+ const startParen = result.indexOf('(', existsIndex);
161
+ let depth = 1;
162
+ let endParen = startParen + 1;
163
+
164
+ while (endParen < result.length && depth > 0) {
165
+ if (result[endParen] === '(') depth++;
166
+ if (result[endParen] === ')') depth--;
167
+ endParen++;
168
+ }
169
+
170
+ // Replace EXISTS(...) with 'true'
171
+ result = result.substring(0, existsIndex) + 'true' + result.substring(endParen);
172
+ changed = true;
173
+ }
174
+ }
175
+
176
+ return result;
177
+ }
178
+
179
+ /**
180
+ * Extract direct field comparisons
181
+ */
182
+ extractDirectComparisons(sql, analysis) {
183
+ // Pattern: field = 'value'
184
+ const stringPattern = /(\w+)\s*=\s*'([^']+)'/gi;
185
+ let match;
186
+ while ((match = stringPattern.exec(sql)) !== null) {
187
+ const field = match[1];
188
+ const value = match[2];
189
+
190
+ // Skip if field is actually a function
191
+ if (field.toLowerCase().includes('current') || field.toLowerCase().includes('get')) {
192
+ continue;
193
+ }
194
+
195
+ analysis.filters.push({
196
+ type: 'equal',
197
+ field: field,
198
+ value: value,
199
+ prisma: `{ ${field}: '${value}' }`
200
+ });
201
+ }
202
+
203
+ // Pattern: field = number
204
+ const numberPattern = /(\w+)\s*=\s*(\d+)(?!\s*\))/gi;
205
+ while ((match = numberPattern.exec(sql)) !== null) {
206
+ const field = match[1];
207
+ const value = match[2];
208
+
209
+ // Skip if field is actually a function
210
+ if (field.toLowerCase().includes('current') || field.toLowerCase().includes('get')) {
211
+ continue;
212
+ }
213
+
214
+ analysis.filters.push({
215
+ type: 'equal',
216
+ field: field,
217
+ value: value,
218
+ prisma: `{ ${field}: ${value} }`
219
+ });
220
+ }
221
+
222
+ // Pattern: field IS NULL
223
+ const isNullPattern = /(\w+)\s+IS\s+NULL/gi;
224
+ while ((match = isNullPattern.exec(sql)) !== null) {
225
+ analysis.filters.push({
226
+ type: 'is_null',
227
+ field: match[1],
228
+ prisma: `{ ${match[1]}: null }`
229
+ });
230
+ }
231
+
232
+ // Pattern: field IS NOT NULL
233
+ const isNotNullPattern = /(\w+)\s+IS\s+NOT\s+NULL/gi;
234
+ while ((match = isNotNullPattern.exec(sql)) !== null) {
235
+ analysis.filters.push({
236
+ type: 'not_null',
237
+ field: match[1],
238
+ prisma: `{ ${match[1]}: { not: null } }`
239
+ });
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Extract function-based comparisons
245
+ */
246
+ extractFunctionComparisons(sql, analysis) {
247
+ // Pattern: field = function()
248
+ const patterns = [
249
+ /(\w+)\s*=\s*([\w.]+)\s*\(\s*\)/gi, // field = function()
250
+ /([\w.]+)\s*\(\s*\)\s*=\s*(\w+)/gi // function() = field
251
+ ];
252
+
253
+ // Normalize dots in function names for lookup
254
+ const normalizeFuncName = (name) => name.replace(/\./g, '_');
255
+
256
+ for (let i = 0; i < patterns.length; i++) {
257
+ const pattern = patterns[i];
258
+ let match;
259
+ while ((match = pattern.exec(sql)) !== null) {
260
+ let field, funcName;
261
+
262
+ // First pattern is: field = function()
263
+ // Second pattern is: function() = field
264
+ if (i === 0) {
265
+ field = match[1];
266
+ funcName = normalizeFuncName(match[2]);
267
+ } else {
268
+ funcName = normalizeFuncName(match[1]);
269
+ field = match[2];
270
+ }
271
+
272
+ // Look up function mapping (check both with/without underscore and original)
273
+ let userField = this.functionMappings[funcName] || this.functionMappings[funcName.toLowerCase()];
274
+
275
+ // Also try the original name without normalization
276
+ if (!userField) {
277
+ const originalName = (i === 0 ? match[2] : match[1]);
278
+ userField = this.functionMappings[originalName] || this.functionMappings[originalName.toLowerCase()];
279
+ }
280
+
281
+ if (userField) {
282
+ // Skip if field is actually a function (e.g., both sides are functions)
283
+ if (field.toLowerCase().includes('current') || field.toLowerCase().includes('get') || field.includes('(')) {
284
+ continue;
285
+ }
286
+
287
+ // Skip if this is part of an ANY clause (handled by extractRoleChecks)
288
+ if (sql.includes(match[0] + ' = ANY') || sql.includes(match[0] + '= ANY') ||
289
+ sql.includes(match[0] + ' =ANY') || sql.includes(match[0] + '=ANY')) {
290
+ continue;
291
+ }
292
+
293
+ analysis.filters.push({
294
+ type: `user_${userField}`,
295
+ field: field,
296
+ userField: userField,
297
+ prisma: `{ ${field}: user?.${userField} }`
298
+ });
299
+
300
+ // Track user context requirements
301
+ const contextKey = `requires${userField.charAt(0).toUpperCase()}${userField.slice(1).replace(/_(.)/g, (_, c) => c.toUpperCase())}`;
302
+ analysis.userContext[contextKey] = true;
303
+ }
304
+ }
305
+ }
306
+
307
+ // Pattern: function() = 'value'
308
+ const funcValuePattern = /([\w.]+)\s*\(\s*\)\s*=\s*'([^']+)'/gi;
309
+ let match;
310
+ while ((match = funcValuePattern.exec(sql)) !== null) {
311
+ const funcName = match[1].replace(/\./g, '_');
312
+ const value = match[2];
313
+
314
+ const userField = this.functionMappings[funcName] || this.functionMappings[funcName.toLowerCase()];
315
+
316
+ if (userField === 'role') {
317
+ analysis.conditions.push({
318
+ type: 'role_equal',
319
+ role: value,
320
+ javascript: `user?.role === '${value}'`
321
+ });
322
+ analysis.userContext.requiresRole = true;
323
+ }
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Extract session variable comparisons
329
+ */
330
+ extractSessionVariableComparisons(sql, analysis) {
331
+ // Pattern: field = current_setting('...')
332
+ const patterns = [
333
+ /(\w+)\s*=\s*(?:\(?\s*current_setting\s*\(\s*'([^']+)'[^)]*\)\s*\)?)/gi,
334
+ /current_setting\s*\(\s*'([^']+)'[^)]*\)\s*=\s*(\w+)/gi
335
+ ];
336
+
337
+ for (let i = 0; i < patterns.length; i++) {
338
+ const pattern = patterns[i];
339
+ let match;
340
+ while ((match = pattern.exec(sql)) !== null) {
341
+ let field, setting;
342
+
343
+ // First pattern: field = current_setting(...)
344
+ // Second pattern: current_setting(...) = field
345
+ if (i === 0) {
346
+ field = match[1];
347
+ setting = match[2];
348
+ } else {
349
+ setting = match[1];
350
+ field = match[2];
351
+ }
352
+
353
+ const userField = this.sessionMappings[setting];
354
+
355
+ if (userField) {
356
+ // Skip if field is actually a function
357
+ if (field.includes('(')) continue;
358
+
359
+ analysis.filters.push({
360
+ type: `session_${userField}`,
361
+ field: field,
362
+ userField: userField,
363
+ prisma: `{ ${field}: user?.${userField} }`
364
+ });
365
+
366
+ // Track user context requirements
367
+ const contextKey = `requires${userField.charAt(0).toUpperCase()}${userField.slice(1).replace(/_(.)/g, (_, c) => c.toUpperCase())}`;
368
+ analysis.userContext[contextKey] = true;
369
+ }
370
+ }
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Extract IN clauses
376
+ */
377
+ extractInClauses(sql, analysis) {
378
+ // Pattern: field IN (values)
379
+ const inPattern = /(\w+)\s+IN\s*\(([^)]+)\)/gi;
380
+ let match;
381
+
382
+ while ((match = inPattern.exec(sql)) !== null) {
383
+ const field = match[1];
384
+ const values = match[2];
385
+
386
+ // Skip if field is a function
387
+ if (field.toLowerCase().includes('current') || field.toLowerCase().includes('get')) {
388
+ continue;
389
+ }
390
+
391
+ // Check if it's a subquery
392
+ if (values.toLowerCase().includes('select')) {
393
+ // Handle subquery
394
+ analysis.conditions.push({
395
+ type: 'in_subquery',
396
+ field: field,
397
+ subquery: values,
398
+ prisma: `/* IN subquery for ${field} - requires manual implementation */`
399
+ });
400
+ } else {
401
+ // Parse values
402
+ const valueList = values
403
+ .split(',')
404
+ .map(v => v.trim().replace(/'/g, ''));
405
+
406
+ const quotedValues = valueList.map(v =>
407
+ isNaN(v) && v !== 'true' && v !== 'false' ? `'${v}'` : v
408
+ );
409
+
410
+ analysis.filters.push({
411
+ type: 'in',
412
+ field: field,
413
+ values: quotedValues,
414
+ prisma: `{ ${field}: { in: [${quotedValues.join(', ')}] } }`
415
+ });
416
+ }
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Extract EXISTS subqueries
422
+ */
423
+ extractExistsSubqueries(sql, analysis) {
424
+ const existsPattern = /EXISTS\s*\(([^)]+(?:\([^)]*\)[^)]*)*)\)/gi;
425
+ let match;
426
+
427
+ while ((match = existsPattern.exec(sql)) !== null) {
428
+ const subquery = match[1];
429
+
430
+ // Try to extract the table and join condition
431
+ const fromMatch = subquery.match(/FROM\s+(\w+)/i);
432
+ const whereMatch = subquery.match(/WHERE\s+(.+)/i);
433
+
434
+ if (fromMatch) {
435
+ const table = fromMatch[1];
436
+ const condition = whereMatch ? whereMatch[1] : '';
437
+
438
+ analysis.conditions.push({
439
+ type: 'exists',
440
+ table: table,
441
+ condition: condition,
442
+ prisma: `/* EXISTS check on ${table} - implement as relation check */`
443
+ });
444
+ }
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Extract CASE WHEN conditions
450
+ */
451
+ extractCaseWhenConditions(sql, analysis) {
452
+ const casePattern = /CASE\s+WHEN\s+([^THEN]+)\s+THEN\s+([^WHEN|ELSE|END]+)(?:\s+WHEN\s+([^THEN]+)\s+THEN\s+([^WHEN|ELSE|END]+))*(?:\s+ELSE\s+([^END]+))?\s+END/gi;
453
+ let match;
454
+
455
+ while ((match = casePattern.exec(sql)) !== null) {
456
+ const conditions = [];
457
+
458
+ // Extract WHEN conditions
459
+ const whenPattern = /WHEN\s+([^THEN]+)\s+THEN\s+([^WHEN|ELSE|END]+)/gi;
460
+ let whenMatch;
461
+
462
+ while ((whenMatch = whenPattern.exec(match[0])) !== null) {
463
+ const condition = whenMatch[1].trim();
464
+ const result = whenMatch[2].trim();
465
+
466
+ conditions.push({
467
+ condition: condition,
468
+ result: result
469
+ });
470
+ }
471
+
472
+ if (conditions.length > 0) {
473
+ analysis.conditions.push({
474
+ type: 'case_when',
475
+ conditions: conditions,
476
+ prisma: `/* CASE WHEN logic - requires conditional implementation */`
477
+ });
478
+ }
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Extract role-based checks
484
+ */
485
+ extractRoleChecks(sql, analysis) {
486
+ // Pattern: (function()) = ANY((ARRAY[...])) or function() = ANY(ARRAY[...])
487
+ // Handle optional wrapping parens around function and multiple parens around ARRAY
488
+ const anyArrayPattern = /\(?([\w.]+)\s*\(\s*\)\)?\s*=\s*ANY\s*\(+\s*(?:ARRAY\s*)?\[([^\]]+)\]/gi;
489
+ let match;
490
+
491
+ while ((match = anyArrayPattern.exec(sql)) !== null) {
492
+ const funcName = match[1].replace(/\./g, '_');
493
+ const values = match[2];
494
+
495
+ const userField = this.functionMappings[funcName] || this.functionMappings[funcName.toLowerCase()];
496
+
497
+ if (userField === 'role') {
498
+ const roles = values
499
+ .split(',')
500
+ .map(r => r.trim().replace(/'/g, ''));
501
+
502
+ analysis.conditions.push({
503
+ type: 'role_any',
504
+ roles: roles,
505
+ javascript: `[${roles.map(r => `'${r}'`).join(', ')}].includes(user?.role)`
506
+ });
507
+ analysis.userContext.requiresRole = true;
508
+ }
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Extract complex JOIN conditions
514
+ */
515
+ extractComplexJoins(sql, analysis) {
516
+ // Look for patterns that suggest relationships
517
+ const relationPatterns = [
518
+ // user owns resource through intermediate table
519
+ /(\w+)\s+IN\s*\(\s*SELECT\s+\w+\s+FROM\s+(\w+)\s+WHERE\s+(\w+)=/gi,
520
+ // team/group membership
521
+ /(\w+)\.(\w+)\s+IN\s*\(\s*SELECT\s+\w+\s+FROM\s+(\w+_members?)\s+WHERE/gi
522
+ ];
523
+
524
+ for (const pattern of relationPatterns) {
525
+ let match;
526
+ while ((match = pattern.exec(sql)) !== null) {
527
+ if (match[2]) {
528
+ analysis.conditions.push({
529
+ type: 'relation',
530
+ table: match[2],
531
+ field: match[1],
532
+ prisma: `/* Relation check through ${match[2]} table */`
533
+ });
534
+ }
535
+ }
536
+ }
537
+ }
538
+ }
539
+
540
+ module.exports = DeepSQLAnalyzer;