@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,611 @@
1
+ /**
2
+ * SQL to JavaScript/Prisma Filter Converter
3
+ * Converts PostgreSQL RLS policy expressions to JavaScript and Prisma filters
4
+ */
5
+
6
+ /**
7
+ * Parse SQL expression into an AST (Abstract Syntax Tree)
8
+ * @param {string} sql - SQL expression
9
+ * @returns {Object} - Parsed AST
10
+ */
11
+ function parseSqlExpression(sql) {
12
+ if (!sql) return null;
13
+
14
+ // Clean up the SQL
15
+ sql = sql.trim();
16
+
17
+ // Handle parentheses and logical operators
18
+ return parseLogicalExpression(sql);
19
+ }
20
+
21
+ /**
22
+ * Parse logical expressions (AND, OR)
23
+ * @param {string} sql - SQL expression
24
+ * @returns {Object} - AST node
25
+ */
26
+ function parseLogicalExpression(sql) {
27
+ // Find top-level OR (lowest precedence)
28
+ const orParts = splitByOperator(sql, 'OR');
29
+ if (orParts.length > 1) {
30
+ return {
31
+ type: 'OR',
32
+ operands: orParts.map(part => parseLogicalExpression(part))
33
+ };
34
+ }
35
+
36
+ // Find top-level AND (higher precedence than OR)
37
+ const andParts = splitByOperator(sql, 'AND');
38
+ if (andParts.length > 1) {
39
+ return {
40
+ type: 'AND',
41
+ operands: andParts.map(part => parseLogicalExpression(part))
42
+ };
43
+ }
44
+
45
+ // Parse comparison or other expressions
46
+ return parseComparisonExpression(sql);
47
+ }
48
+
49
+ /**
50
+ * Split SQL by operator at the same nesting level
51
+ * @param {string} sql - SQL expression
52
+ * @param {string} operator - Operator to split by (AND, OR)
53
+ * @returns {Array} - Parts split by operator
54
+ */
55
+ function splitByOperator(sql, operator) {
56
+ const parts = [];
57
+ let current = '';
58
+ let depth = 0;
59
+ let i = 0;
60
+
61
+ while (i < sql.length) {
62
+ const char = sql[i];
63
+
64
+ if (char === '(') {
65
+ depth++;
66
+ current += char;
67
+ i++;
68
+ } else if (char === ')') {
69
+ depth--;
70
+ current += char;
71
+ i++;
72
+ } else if (depth === 0) {
73
+ // Check if we're at the operator
74
+ const remaining = sql.substring(i);
75
+ const operatorPattern = new RegExp(`^\\s+${operator}\\s+`, 'i');
76
+ const match = remaining.match(operatorPattern);
77
+
78
+ if (match) {
79
+ parts.push(current.trim());
80
+ current = '';
81
+ i += match[0].length;
82
+ } else {
83
+ current += char;
84
+ i++;
85
+ }
86
+ } else {
87
+ current += char;
88
+ i++;
89
+ }
90
+ }
91
+
92
+ if (current.trim()) {
93
+ parts.push(current.trim());
94
+ }
95
+
96
+ return parts.length > 0 ? parts : [sql];
97
+ }
98
+
99
+ /**
100
+ * Parse comparison expressions
101
+ * @param {string} sql - SQL expression
102
+ * @returns {Object} - AST node
103
+ */
104
+ function parseComparisonExpression(sql) {
105
+ sql = sql.trim();
106
+
107
+ // Remove outer parentheses
108
+ if (sql.startsWith('(') && sql.endsWith(')')) {
109
+ const inner = sql.substring(1, sql.length - 1);
110
+ // Make sure these are matching parentheses
111
+ if (countParentheses(inner) === 0) {
112
+ return parseLogicalExpression(inner);
113
+ }
114
+ }
115
+
116
+ // Handle NOT
117
+ if (sql.toUpperCase().startsWith('NOT ')) {
118
+ return {
119
+ type: 'NOT',
120
+ operand: parseLogicalExpression(sql.substring(4).trim())
121
+ };
122
+ }
123
+
124
+ // Handle = ANY (ARRAY[...]) pattern (PostgreSQL specific)
125
+ const anyArrayMatch = sql.match(/^(.+?)\s*=\s*ANY\s*\(\s*ARRAY\s*\[([^\]]+)\]\s*\)/i);
126
+ if (anyArrayMatch) {
127
+ const left = anyArrayMatch[1].trim();
128
+ const arrayItems = anyArrayMatch[2].split(',').map(item => item.trim());
129
+ return {
130
+ type: 'COMPARISON',
131
+ operator: 'IN',
132
+ left: parseValue(left),
133
+ right: {
134
+ type: 'ARRAY',
135
+ items: arrayItems.map(item => parseValue(item))
136
+ }
137
+ };
138
+ }
139
+
140
+ // Handle IN (SELECT...) - mark as subquery
141
+ const inSelectMatch = sql.match(/^(.+?)\s+IN\s+\(SELECT/i);
142
+ if (inSelectMatch) {
143
+ return {
144
+ type: 'COMPARISON',
145
+ operator: 'IN',
146
+ left: parseValue(inSelectMatch[1].trim()),
147
+ right: { type: 'SUBQUERY', value: '/* Subquery - needs manual implementation */' }
148
+ };
149
+ }
150
+
151
+ // Parse comparison operators
152
+ const comparisonOps = ['<=', '>=', '<>', '!=', '=', '<', '>', ' IN ', ' NOT IN ', ' LIKE ', ' ILIKE ', ' IS NULL', ' IS NOT NULL'];
153
+
154
+ for (const op of comparisonOps) {
155
+ const index = findOperatorIndex(sql, op);
156
+ if (index !== -1) {
157
+ const left = sql.substring(0, index).trim();
158
+ const right = sql.substring(index + op.length).trim();
159
+
160
+ return {
161
+ type: 'COMPARISON',
162
+ operator: op.trim(),
163
+ left: parseValue(left),
164
+ right: op.trim().includes('IS') ? null : parseValue(right)
165
+ };
166
+ }
167
+ }
168
+
169
+ // If no operator found, treat as a value
170
+ return parseValue(sql);
171
+ }
172
+
173
+ /**
174
+ * Find operator index outside of parentheses and quotes
175
+ * @param {string} sql - SQL expression
176
+ * @param {string} operator - Operator to find
177
+ * @returns {number} - Index or -1
178
+ */
179
+ function findOperatorIndex(sql, operator) {
180
+ const upperSql = sql.toUpperCase();
181
+ const upperOp = operator.toUpperCase();
182
+ let depth = 0;
183
+ let inQuotes = false;
184
+ let quoteChar = '';
185
+
186
+ for (let i = 0; i < sql.length; i++) {
187
+ const char = sql[i];
188
+
189
+ if ((char === "'" || char === '"') && !inQuotes) {
190
+ inQuotes = true;
191
+ quoteChar = char;
192
+ } else if (char === quoteChar && inQuotes) {
193
+ inQuotes = false;
194
+ quoteChar = '';
195
+ } else if (!inQuotes) {
196
+ if (char === '(') depth++;
197
+ else if (char === ')') depth--;
198
+ else if (depth === 0 && upperSql.substring(i, i + upperOp.length) === upperOp) {
199
+ // Check word boundaries for operators like IN
200
+ if (upperOp.startsWith(' ')) {
201
+ return i;
202
+ }
203
+ // For symbols like =, <, > etc
204
+ if (i === 0 || !isAlphanumeric(sql[i - 1])) {
205
+ return i;
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ return -1;
212
+ }
213
+
214
+ /**
215
+ * Parse a value (column, literal, function call, etc.)
216
+ * @param {string} value - Value string
217
+ * @returns {Object} - Parsed value
218
+ */
219
+ function parseValue(value) {
220
+ value = value.trim();
221
+
222
+ // Strip PostgreSQL type casts (e.g., 'admin'::user_role -> 'admin')
223
+ const typeCastMatch = value.match(/^(.+)::[\w_]+$/);
224
+ if (typeCastMatch) {
225
+ value = typeCastMatch[1].trim();
226
+ }
227
+
228
+ // Handle NULL
229
+ if (value.toUpperCase() === 'NULL') {
230
+ return { type: 'NULL' };
231
+ }
232
+
233
+ // Handle boolean literals
234
+ if (value.toUpperCase() === 'TRUE') {
235
+ return { type: 'BOOLEAN', value: true };
236
+ }
237
+ if (value.toUpperCase() === 'FALSE') {
238
+ return { type: 'BOOLEAN', value: false };
239
+ }
240
+
241
+ // Handle string literals
242
+ if ((value.startsWith("'") && value.endsWith("'")) ||
243
+ (value.startsWith('"') && value.endsWith('"'))) {
244
+ return {
245
+ type: 'STRING',
246
+ value: value.substring(1, value.length - 1)
247
+ };
248
+ }
249
+
250
+ // Handle numbers
251
+ if (/^-?\d+(\.\d+)?$/.test(value)) {
252
+ return {
253
+ type: 'NUMBER',
254
+ value: parseFloat(value)
255
+ };
256
+ }
257
+
258
+ // Handle array literals (for IN operator)
259
+ if (value.startsWith('(') && value.endsWith(')')) {
260
+ const inner = value.substring(1, value.length - 1);
261
+ const items = inner.split(',').map(item => parseValue(item.trim()));
262
+ return {
263
+ type: 'ARRAY',
264
+ items: items
265
+ };
266
+ }
267
+
268
+ // Handle function calls
269
+ const funcMatch = value.match(/^(\w+)\((.*)\)$/);
270
+ if (funcMatch) {
271
+ return {
272
+ type: 'FUNCTION',
273
+ name: funcMatch[1],
274
+ args: funcMatch[2] ? funcMatch[2].split(',').map(arg => parseValue(arg.trim())) : []
275
+ };
276
+ }
277
+
278
+ // Handle column references (including table.column)
279
+ if (/^[\w.]+$/.test(value)) {
280
+ const parts = value.split('.');
281
+ if (parts.length === 2) {
282
+ return {
283
+ type: 'COLUMN',
284
+ table: parts[0],
285
+ column: parts[1]
286
+ };
287
+ }
288
+ return {
289
+ type: 'COLUMN',
290
+ column: value
291
+ };
292
+ }
293
+
294
+ // Unknown - return as raw
295
+ return {
296
+ type: 'RAW',
297
+ value: value
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Count unmatched parentheses
303
+ */
304
+ function countParentheses(str) {
305
+ let count = 0;
306
+ for (const char of str) {
307
+ if (char === '(') count++;
308
+ if (char === ')') count--;
309
+ }
310
+ return count;
311
+ }
312
+
313
+ /**
314
+ * Check if character is alphanumeric
315
+ */
316
+ function isAlphanumeric(char) {
317
+ return /[a-zA-Z0-9_]/.test(char);
318
+ }
319
+
320
+ /**
321
+ * Convert AST to JavaScript boolean expression
322
+ * @param {Object} ast - Abstract syntax tree
323
+ * @param {string} dataVar - Variable name for data object (e.g., 'data', 'user')
324
+ * @param {string} userVar - Variable name for user object
325
+ * @returns {string} - JavaScript expression
326
+ */
327
+ function astToJavaScript(ast, dataVar = 'data', userVar = 'user') {
328
+ if (!ast) return 'true';
329
+
330
+ switch (ast.type) {
331
+ case 'OR':
332
+ return '(' + ast.operands.map(op => astToJavaScript(op, dataVar, userVar)).join(' || ') + ')';
333
+
334
+ case 'AND':
335
+ return '(' + ast.operands.map(op => astToJavaScript(op, dataVar, userVar)).join(' && ') + ')';
336
+
337
+ case 'NOT':
338
+ return '!(' + astToJavaScript(ast.operand, dataVar, userVar) + ')';
339
+
340
+ case 'COMPARISON':
341
+ return convertComparisonToJS(ast, dataVar, userVar);
342
+
343
+ case 'COLUMN':
344
+ return valueToJS(ast, dataVar, userVar);
345
+
346
+ case 'BOOLEAN':
347
+ return ast.value.toString();
348
+
349
+ default:
350
+ return 'true';
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Convert comparison to JavaScript
356
+ */
357
+ function convertComparisonToJS(ast, dataVar, userVar) {
358
+ const left = valueToJS(ast.left, dataVar, userVar);
359
+ const right = ast.right ? valueToJS(ast.right, dataVar, userVar) : null;
360
+
361
+ switch (ast.operator.toUpperCase()) {
362
+ case '=':
363
+ return `${left} === ${right}`;
364
+ case '!=':
365
+ case '<>':
366
+ return `${left} !== ${right}`;
367
+ case '<':
368
+ return `${left} < ${right}`;
369
+ case '>':
370
+ return `${left} > ${right}`;
371
+ case '<=':
372
+ return `${left} <= ${right}`;
373
+ case '>=':
374
+ return `${left} >= ${right}`;
375
+ case 'IN':
376
+ if (ast.right.type === 'ARRAY') {
377
+ const items = ast.right.items.map(item => valueToJS(item, dataVar, userVar)).join(', ');
378
+ return `[${items}].includes(${left})`;
379
+ }
380
+ return `${right}.includes(${left})`;
381
+ case 'NOT IN':
382
+ if (ast.right.type === 'ARRAY') {
383
+ const items = ast.right.items.map(item => valueToJS(item, dataVar, userVar)).join(', ');
384
+ return `![${items}].includes(${left})`;
385
+ }
386
+ return `!${right}.includes(${left})`;
387
+ case 'IS NULL':
388
+ return `${left} == null`;
389
+ case 'IS NOT NULL':
390
+ return `${left} != null`;
391
+ case 'LIKE':
392
+ case 'ILIKE':
393
+ // Convert SQL LIKE to JavaScript regex
394
+ return `${left}?.toString().match(/${sqlLikeToRegex(ast.right.value)}/${ast.operator === 'ILIKE' ? 'i' : ''}) != null`;
395
+ default:
396
+ return 'true';
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Convert value to JavaScript
402
+ */
403
+ function valueToJS(ast, dataVar, userVar) {
404
+ if (!ast) return 'null';
405
+
406
+ switch (ast.type) {
407
+ case 'COLUMN':
408
+ // Map PostgreSQL special columns
409
+ const column = ast.column.toLowerCase();
410
+ if (column === 'current_user' || column === 'session_user' || column === 'user') {
411
+ return `${userVar}?.id`;
412
+ }
413
+ // Regular column reference
414
+ if (ast.table) {
415
+ return `${dataVar}?.${ast.column}`;
416
+ }
417
+ return `${dataVar}?.${ast.column}`;
418
+
419
+ case 'STRING':
420
+ return `'${ast.value.replace(/'/g, "\\'")}'`;
421
+
422
+ case 'NUMBER':
423
+ return ast.value.toString();
424
+
425
+ case 'BOOLEAN':
426
+ return ast.value.toString();
427
+
428
+ case 'NULL':
429
+ return 'null';
430
+
431
+ case 'FUNCTION':
432
+ return convertFunctionToJS(ast, dataVar, userVar);
433
+
434
+ case 'ARRAY':
435
+ return '[' + ast.items.map(item => valueToJS(item, dataVar, userVar)).join(', ') + ']';
436
+
437
+ case 'SUBQUERY':
438
+ // Subqueries can't be directly converted to JavaScript
439
+ return '[]';
440
+
441
+ default:
442
+ return 'null';
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Convert PostgreSQL function to JavaScript
448
+ */
449
+ function convertFunctionToJS(ast, dataVar, userVar) {
450
+ const funcName = ast.name.toLowerCase();
451
+
452
+ switch (funcName) {
453
+ case 'current_user':
454
+ case 'session_user':
455
+ return `${userVar}?.id`;
456
+ case 'current_user_id':
457
+ // Custom function: current_user_id() -> user?.id
458
+ return `${userVar}?.id`;
459
+ case 'current_user_role':
460
+ // Custom function: current_user_role() -> user?.role
461
+ return `${userVar}?.role`;
462
+ case 'current_setting':
463
+ // current_setting('app.current_user_id') -> user?.id
464
+ return `${userVar}?.id`;
465
+ case 'auth':
466
+ // auth.uid() -> user?.id (Supabase style)
467
+ if (ast.args[0]?.value === 'uid') {
468
+ return `${userVar}?.id`;
469
+ }
470
+ return `${userVar}?.id`;
471
+ default:
472
+ return 'null';
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Convert SQL LIKE pattern to regex
478
+ */
479
+ function sqlLikeToRegex(pattern) {
480
+ return pattern
481
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
482
+ .replace(/%/g, '.*') // % becomes .*
483
+ .replace(/_/g, '.'); // _ becomes .
484
+ }
485
+
486
+ /**
487
+ * Convert AST to Prisma filter object
488
+ * @param {Object} ast - Abstract syntax tree
489
+ * @param {string} userVar - Variable name for user object
490
+ * @returns {string} - JavaScript code that returns Prisma filter
491
+ */
492
+ function astToPrismaFilter(ast, userVar = 'user') {
493
+ if (!ast) return '{}';
494
+
495
+ switch (ast.type) {
496
+ case 'OR':
497
+ const orFilters = ast.operands.map(op => astToPrismaFilter(op, userVar));
498
+ return `{ OR: [${orFilters.join(', ')}] }`;
499
+
500
+ case 'AND':
501
+ const andFilters = ast.operands.map(op => astToPrismaFilter(op, userVar));
502
+ return `{ AND: [${andFilters.join(', ')}] }`;
503
+
504
+ case 'NOT':
505
+ return `{ NOT: ${astToPrismaFilter(ast.operand, userVar)} }`;
506
+
507
+ case 'COMPARISON':
508
+ return convertComparisonToPrisma(ast, userVar);
509
+
510
+ default:
511
+ return '{}';
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Convert comparison to Prisma filter
517
+ */
518
+ function convertComparisonToPrisma(ast, userVar) {
519
+ if (ast.left.type !== 'COLUMN') return '{}';
520
+
521
+ const column = ast.left.column;
522
+ const rightValue = valueToPrisma(ast.right, userVar);
523
+
524
+ switch (ast.operator.toUpperCase()) {
525
+ case '=':
526
+ return `{ ${column}: ${rightValue} }`;
527
+ case '!=':
528
+ case '<>':
529
+ return `{ ${column}: { not: ${rightValue} } }`;
530
+ case '<':
531
+ return `{ ${column}: { lt: ${rightValue} } }`;
532
+ case '>':
533
+ return `{ ${column}: { gt: ${rightValue} } }`;
534
+ case '<=':
535
+ return `{ ${column}: { lte: ${rightValue} } }`;
536
+ case '>=':
537
+ return `{ ${column}: { gte: ${rightValue} } }`;
538
+ case 'IN':
539
+ if (ast.right.type === 'ARRAY') {
540
+ const items = ast.right.items.map(item => valueToPrisma(item, userVar)).join(', ');
541
+ return `{ ${column}: { in: [${items}] } }`;
542
+ }
543
+ return `{ ${column}: { in: ${rightValue} } }`;
544
+ case 'NOT IN':
545
+ if (ast.right.type === 'ARRAY') {
546
+ const items = ast.right.items.map(item => valueToPrisma(item, userVar)).join(', ');
547
+ return `{ ${column}: { notIn: [${items}] } }`;
548
+ }
549
+ return `{ ${column}: { notIn: ${rightValue} } }`;
550
+ case 'IS NULL':
551
+ return `{ ${column}: null }`;
552
+ case 'IS NOT NULL':
553
+ return `{ ${column}: { not: null } }`;
554
+ case 'LIKE':
555
+ case 'ILIKE':
556
+ return `{ ${column}: { contains: ${rightValue}, mode: '${ast.operator === 'ILIKE' ? 'insensitive' : 'default'}' } }`;
557
+ default:
558
+ return '{}';
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Convert value to Prisma-compatible value
564
+ */
565
+ function valueToPrisma(ast, userVar) {
566
+ if (!ast) return 'null';
567
+
568
+ switch (ast.type) {
569
+ case 'COLUMN':
570
+ const column = ast.column.toLowerCase();
571
+ if (column === 'current_user' || column === 'session_user' || column === 'user') {
572
+ return `${userVar}?.id`;
573
+ }
574
+ return `${userVar}?.${ast.column}`;
575
+
576
+ case 'STRING':
577
+ return `'${ast.value.replace(/'/g, "\\'")}'`;
578
+
579
+ case 'NUMBER':
580
+ return ast.value.toString();
581
+
582
+ case 'BOOLEAN':
583
+ return ast.value.toString();
584
+
585
+ case 'NULL':
586
+ return 'null';
587
+
588
+ case 'FUNCTION':
589
+ const funcName = ast.name.toLowerCase();
590
+ if (funcName === 'current_user' || funcName === 'session_user' || funcName === 'current_user_id') {
591
+ return `${userVar}?.id`;
592
+ }
593
+ if (funcName === 'current_user_role') {
594
+ return `${userVar}?.role`;
595
+ }
596
+ return `${userVar}?.id`;
597
+
598
+ case 'SUBQUERY':
599
+ // Subqueries can't be directly converted to Prisma
600
+ return '[]';
601
+
602
+ default:
603
+ return 'null';
604
+ }
605
+ }
606
+
607
+ module.exports = {
608
+ parseSqlExpression,
609
+ astToJavaScript,
610
+ astToPrismaFilter
611
+ };