dzql 0.1.2 → 0.1.4

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,299 @@
1
+ /**
2
+ * Entity Definition Parser
3
+ * Parses entity registration calls and extracts configuration
4
+ */
5
+
6
+ export class EntityParser {
7
+ /**
8
+ * Parse a dzql.register_entity() call from SQL
9
+ * @param {string} sql - SQL containing register_entity call
10
+ * @returns {Object} Parsed entity configuration
11
+ */
12
+ parseFromSQL(sql) {
13
+ // Extract the register_entity call
14
+ const registerMatch = sql.match(/dzql\.register_entity\s*\(([\s\S]*?)\);/i);
15
+ if (!registerMatch) {
16
+ throw new Error('No register_entity call found in SQL');
17
+ }
18
+
19
+ const params = this._parseParameters(registerMatch[1]);
20
+
21
+ return this._buildEntityConfig(params);
22
+ }
23
+
24
+ /**
25
+ * Parse parameters from register_entity call
26
+ * @private
27
+ */
28
+ _parseParameters(paramsString) {
29
+ // Split by commas that are not inside quotes, parentheses, or brackets
30
+ const params = [];
31
+ let currentParam = '';
32
+ let depth = 0;
33
+ let inString = false;
34
+ let stringChar = null;
35
+
36
+ for (let i = 0; i < paramsString.length; i++) {
37
+ const char = paramsString[i];
38
+ const prevChar = i > 0 ? paramsString[i - 1] : '';
39
+
40
+ if ((char === "'" || char === '"') && prevChar !== '\\') {
41
+ if (!inString) {
42
+ inString = true;
43
+ stringChar = char;
44
+ } else if (char === stringChar) {
45
+ inString = false;
46
+ stringChar = null;
47
+ }
48
+ }
49
+
50
+ if (!inString) {
51
+ if (char === '(' || char === '{' || char === '[') depth++;
52
+ if (char === ')' || char === '}' || char === ']') depth--;
53
+
54
+ if (char === ',' && depth === 0) {
55
+ params.push(currentParam.trim());
56
+ currentParam = '';
57
+ continue;
58
+ }
59
+ }
60
+
61
+ currentParam += char;
62
+ }
63
+
64
+ if (currentParam.trim()) {
65
+ params.push(currentParam.trim());
66
+ }
67
+
68
+ return params;
69
+ }
70
+
71
+ /**
72
+ * Build entity configuration from parsed parameters
73
+ * @private
74
+ */
75
+ _buildEntityConfig(params) {
76
+ const config = {
77
+ tableName: this._cleanString(params[0]),
78
+ labelField: this._cleanString(params[1]),
79
+ searchableFields: this._parseArray(params[2]),
80
+ fkIncludes: params[3] ? this._parseJSON(params[3]) : {},
81
+ softDelete: params[4] ? this._parseBoolean(params[4]) : false,
82
+ temporalFields: params[5] ? this._parseJSON(params[5]) : {},
83
+ notificationPaths: params[6] ? this._parseJSON(params[6]) : {},
84
+ permissionPaths: params[7] ? this._parseJSON(params[7]) : {},
85
+ graphRules: params[8] ? this._parseJSON(params[8]) : {}
86
+ };
87
+
88
+ return config;
89
+ }
90
+
91
+ /**
92
+ * Clean a string parameter (remove quotes)
93
+ * @private
94
+ */
95
+ _cleanString(str) {
96
+ if (!str) return '';
97
+ // Remove outer quotes, SQL comments, then any remaining quotes and whitespace
98
+ let cleaned = str.replace(/^['"]|['"]$/g, ''); // Remove outer quotes
99
+ cleaned = cleaned.replace(/--[^\n]*/g, ''); // Remove SQL comments
100
+ cleaned = cleaned.replace(/['"\s]+$/g, ''); // Remove trailing quotes/whitespace
101
+ return cleaned.trim();
102
+ }
103
+
104
+ /**
105
+ * Parse an array parameter
106
+ * @private
107
+ */
108
+ _parseArray(str) {
109
+ if (!str || str === 'array[]::text[]') return [];
110
+
111
+ // Handle array['item1', 'item2'] format
112
+ // Use greedy match to get everything up to the last ] before optional ::type
113
+ const match = str.match(/array\[(.*)\](?:::.*)?$/i);
114
+ if (!match) return [];
115
+
116
+ // Split on commas that are not inside brackets or quotes
117
+ const items = [];
118
+ let current = '';
119
+ let depth = 0;
120
+ let inString = false;
121
+ let stringChar = null;
122
+
123
+ for (let i = 0; i < match[1].length; i++) {
124
+ const char = match[1][i];
125
+ const prev = i > 0 ? match[1][i - 1] : '';
126
+
127
+ if ((char === "'" || char === '"') && prev !== '\\') {
128
+ if (!inString) {
129
+ inString = true;
130
+ stringChar = char;
131
+ } else if (char === stringChar) {
132
+ inString = false;
133
+ stringChar = null;
134
+ }
135
+ }
136
+
137
+ if (!inString) {
138
+ if (char === '[') depth++;
139
+ if (char === ']') depth--;
140
+
141
+ if (char === ',' && depth === 0) {
142
+ items.push(current.trim().replace(/^['"]|['"]$/g, ''));
143
+ current = '';
144
+ continue;
145
+ }
146
+ }
147
+
148
+ current += char;
149
+ }
150
+
151
+ if (current.trim()) {
152
+ items.push(current.trim().replace(/^['"]|['"]$/g, ''));
153
+ }
154
+
155
+ return items.filter(item => item.length > 0);
156
+ }
157
+
158
+ /**
159
+ * Parse a JSONB parameter
160
+ * @private
161
+ */
162
+ _parseJSON(str) {
163
+ if (!str || str === '{}' || str === "'{}'") return {};
164
+
165
+ // Handle jsonb_build_object(...) calls
166
+ if (str.includes('jsonb_build_object')) {
167
+ return this._parseJSONBuildObject(str);
168
+ }
169
+
170
+ // Handle JSON string literals
171
+ if (str.startsWith("'") && str.endsWith("'")) {
172
+ try {
173
+ return JSON.parse(str.slice(1, -1).replace(/''/g, "'"));
174
+ } catch (e) {
175
+ console.warn('Failed to parse JSON:', str, e);
176
+ return {};
177
+ }
178
+ }
179
+
180
+ return {};
181
+ }
182
+
183
+ /**
184
+ * Parse jsonb_build_object(...) calls recursively
185
+ * @private
186
+ */
187
+ _parseJSONBuildObject(str) {
188
+ // Extract the content between jsonb_build_object( and )
189
+ const match = str.match(/jsonb_build_object\s*\(([\s\S]*)\)/i);
190
+ if (!match) return {};
191
+
192
+ const content = match[1];
193
+ const params = this._parseParameters(content);
194
+ const result = {};
195
+
196
+ // Process key-value pairs
197
+ for (let i = 0; i < params.length; i += 2) {
198
+ if (i + 1 >= params.length) break;
199
+
200
+ const key = this._cleanString(params[i]);
201
+ const value = params[i + 1];
202
+
203
+ // Handle nested jsonb_build_object
204
+ if (value.includes('jsonb_build_object')) {
205
+ result[key] = this._parseJSONBuildObject(value);
206
+ }
207
+ // Handle jsonb_build_array
208
+ else if (value.includes('jsonb_build_array')) {
209
+ result[key] = this._parseJSONBuildArray(value);
210
+ }
211
+ // Handle array literal
212
+ else if (value.startsWith('array[')) {
213
+ result[key] = this._parseArray(value);
214
+ }
215
+ // Handle simple values
216
+ else {
217
+ const cleanValue = this._cleanString(value);
218
+ result[key] = cleanValue;
219
+ }
220
+ }
221
+
222
+ return result;
223
+ }
224
+
225
+ /**
226
+ * Parse jsonb_build_array(...) calls
227
+ * @private
228
+ */
229
+ _parseJSONBuildArray(str) {
230
+ const match = str.match(/jsonb_build_array\s*\(([\s\S]*)\)/i);
231
+ if (!match) return [];
232
+
233
+ const content = match[1];
234
+ const params = this._parseParameters(content);
235
+
236
+ return params.map(param => {
237
+ if (param.includes('jsonb_build_object')) {
238
+ return this._parseJSONBuildObject(param);
239
+ }
240
+ return this._cleanString(param);
241
+ });
242
+ }
243
+
244
+ /**
245
+ * Parse boolean value
246
+ * @private
247
+ */
248
+ _parseBoolean(str) {
249
+ const cleaned = str.trim().toLowerCase();
250
+ return cleaned === 'true' || cleaned === 't';
251
+ }
252
+
253
+ /**
254
+ * Parse entity definition from JS object (for programmatic use)
255
+ * @param {Object} entity - Entity definition object
256
+ * @returns {Object} Normalized entity configuration
257
+ */
258
+ parseFromObject(entity) {
259
+ return {
260
+ tableName: entity.tableName || entity.table,
261
+ labelField: entity.labelField || 'name',
262
+ searchableFields: entity.searchableFields || [],
263
+ fkIncludes: entity.fkIncludes || {},
264
+ softDelete: entity.softDelete || false,
265
+ temporalFields: entity.temporalFields || {},
266
+ notificationPaths: entity.notificationPaths || {},
267
+ permissionPaths: entity.permissionPaths || {},
268
+ graphRules: entity.graphRules || {}
269
+ };
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Parse all entities from a SQL file
275
+ * @param {string} sql - SQL file content
276
+ * @returns {Array} Array of parsed entity configurations
277
+ */
278
+ export function parseEntitiesFromSQL(sql) {
279
+ const parser = new EntityParser();
280
+ const entities = [];
281
+
282
+ // Find all register_entity calls
283
+ const registerCalls = sql.match(/dzql\.register_entity\s*\([\s\S]*?\);/gi);
284
+
285
+ if (!registerCalls) {
286
+ return entities;
287
+ }
288
+
289
+ for (const call of registerCalls) {
290
+ try {
291
+ const entity = parser.parseFromSQL(call);
292
+ entities.push(entity);
293
+ } catch (error) {
294
+ console.warn('Failed to parse entity:', error.message);
295
+ }
296
+ }
297
+
298
+ return entities;
299
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Permission/Notification Path Parser
3
+ * Parses DZQL path DSL into AST for code generation
4
+ *
5
+ * Path Grammar:
6
+ * - Direct field: @field_name
7
+ * - FK traversal: @field->table.target_field
8
+ * - Conditional: @field->table[condition]{temporal}.target_field
9
+ * - Complex: field1.field2->table[filter].target
10
+ */
11
+
12
+ export class PathParser {
13
+ /**
14
+ * Parse a permission/notification path into an AST
15
+ * @param {string} path - Path string to parse
16
+ * @returns {Object} AST representation
17
+ */
18
+ parse(path) {
19
+ if (!path || path.trim() === '') {
20
+ return { type: 'empty' };
21
+ }
22
+
23
+ // Direct field reference: @owner_id
24
+ if (path.match(/^@\w+$/)) {
25
+ return {
26
+ type: 'direct_field',
27
+ field: path.substring(1)
28
+ };
29
+ }
30
+
31
+ // Complex path with traversal
32
+ if (path.includes('->')) {
33
+ return this._parseTraversalPath(path);
34
+ }
35
+
36
+ // Field path with dot notation: field1.field2
37
+ if (path.includes('.') && !path.includes('->')) {
38
+ return this._parseDotPath(path);
39
+ }
40
+
41
+ // Unknown format
42
+ return {
43
+ type: 'unknown',
44
+ path
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Parse a traversal path (contains ->)
50
+ * @private
51
+ */
52
+ _parseTraversalPath(path) {
53
+ const steps = [];
54
+ const parts = path.split('->');
55
+
56
+ for (let i = 0; i < parts.length; i++) {
57
+ const part = parts[i].trim();
58
+
59
+ if (i === 0) {
60
+ // First part: source field(s)
61
+ steps.push(this._parseSourceField(part));
62
+ } else if (i === parts.length - 1) {
63
+ // Last part: target field
64
+ steps.push(this._parseTargetField(part));
65
+ } else {
66
+ // Middle part: table with optional condition
67
+ steps.push(this._parseTableReference(part));
68
+ }
69
+ }
70
+
71
+ return {
72
+ type: 'traversal',
73
+ steps
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Parse source field (can be @field or field.subfield)
79
+ * @private
80
+ */
81
+ _parseSourceField(part) {
82
+ if (part.startsWith('@')) {
83
+ return {
84
+ type: 'field_ref',
85
+ field: part.substring(1)
86
+ };
87
+ }
88
+
89
+ if (part.includes('.')) {
90
+ const fields = part.split('.');
91
+ return {
92
+ type: 'dot_path',
93
+ fields
94
+ };
95
+ }
96
+
97
+ return {
98
+ type: 'field_ref',
99
+ field: part
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Parse table reference with optional filter and temporal marker
105
+ * Example: acts_for[org_id=$,role='admin']{active}
106
+ * @private
107
+ */
108
+ _parseTableReference(part) {
109
+ const result = {
110
+ type: 'table_ref',
111
+ table: null,
112
+ filter: null,
113
+ temporal: false,
114
+ targetField: null
115
+ };
116
+
117
+ // Extract temporal marker {active}
118
+ if (part.includes('{active}')) {
119
+ result.temporal = true;
120
+ part = part.replace('{active}', '');
121
+ }
122
+
123
+ // Extract filter [condition]
124
+ const filterMatch = part.match(/([a-z_]+)\[(.*?)\](\.(.+))?/i);
125
+ if (filterMatch) {
126
+ result.table = filterMatch[1];
127
+ result.filter = this._parseFilter(filterMatch[2]);
128
+ result.targetField = filterMatch[4] || null;
129
+ return result;
130
+ }
131
+
132
+ // Simple table.field reference
133
+ const dotMatch = part.match(/([a-z_]+)\.(.+)/i);
134
+ if (dotMatch) {
135
+ result.table = dotMatch[1];
136
+ result.targetField = dotMatch[2];
137
+ return result;
138
+ }
139
+
140
+ // Just table name
141
+ result.table = part;
142
+ return result;
143
+ }
144
+
145
+ /**
146
+ * Parse filter conditions
147
+ * Example: org_id=$,role='admin'
148
+ * @private
149
+ */
150
+ _parseFilter(filterStr) {
151
+ const conditions = [];
152
+ const parts = filterStr.split(',');
153
+
154
+ for (const part of parts) {
155
+ const trimmed = part.trim();
156
+
157
+ // Handle various comparison operators
158
+ if (trimmed.includes('=')) {
159
+ const [field, value] = trimmed.split('=').map(s => s.trim());
160
+ conditions.push({
161
+ field,
162
+ operator: '=',
163
+ value: value === '$' ? { type: 'param' } : this._parseValue(value)
164
+ });
165
+ }
166
+ }
167
+
168
+ return conditions;
169
+ }
170
+
171
+ /**
172
+ * Parse a value (string literal, number, param reference)
173
+ * @private
174
+ */
175
+ _parseValue(value) {
176
+ // String literal
177
+ if (value.startsWith("'") && value.endsWith("'")) {
178
+ return {
179
+ type: 'literal',
180
+ value: value.slice(1, -1)
181
+ };
182
+ }
183
+
184
+ // Number
185
+ if (/^\d+$/.test(value)) {
186
+ return {
187
+ type: 'number',
188
+ value: parseInt(value)
189
+ };
190
+ }
191
+
192
+ // Field reference
193
+ if (value.startsWith('@')) {
194
+ return {
195
+ type: 'field',
196
+ value: value.substring(1)
197
+ };
198
+ }
199
+
200
+ return {
201
+ type: 'literal',
202
+ value
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Parse target field (last part of path)
208
+ * Example: user_id or users.user_id
209
+ * @private
210
+ */
211
+ _parseTargetField(part) {
212
+ // Check for temporal marker before removing it
213
+ const hasTemporal = part.includes('{active}');
214
+
215
+ // Remove temporal marker if present
216
+ part = part.replace('{active}', '');
217
+
218
+ // Check for table[filter].field pattern
219
+ const filterMatch = part.match(/([a-z_]+)\[(.*?)\]\.(.+)/i);
220
+ if (filterMatch) {
221
+ return {
222
+ type: 'table_ref',
223
+ table: filterMatch[1],
224
+ filter: this._parseFilter(filterMatch[2]),
225
+ temporal: hasTemporal,
226
+ targetField: filterMatch[3]
227
+ };
228
+ }
229
+
230
+ // Simple field reference
231
+ if (!part.includes('.')) {
232
+ return {
233
+ type: 'field_ref',
234
+ field: part
235
+ };
236
+ }
237
+
238
+ // Dot notation
239
+ const fields = part.split('.');
240
+ return {
241
+ type: 'dot_path',
242
+ fields
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Parse dot path (field.subfield.subsubfield)
248
+ * @private
249
+ */
250
+ _parseDotPath(path) {
251
+ const fields = path.split('.').map(f => f.trim());
252
+ return {
253
+ type: 'dot_path',
254
+ fields
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Parse multiple paths (used in permission arrays)
260
+ * @param {Array<string>} paths - Array of path strings
261
+ * @returns {Array<Object>} Array of ASTs
262
+ */
263
+ parseMultiple(paths) {
264
+ if (!Array.isArray(paths)) {
265
+ return [];
266
+ }
267
+
268
+ return paths.map(path => this.parse(path));
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Utility function to parse a single path
274
+ * @param {string} path - Path to parse
275
+ * @returns {Object} AST
276
+ */
277
+ export function parsePath(path) {
278
+ const parser = new PathParser();
279
+ return parser.parse(path);
280
+ }
281
+
282
+ /**
283
+ * Utility function to parse multiple paths
284
+ * @param {Array<string>} paths - Paths to parse
285
+ * @returns {Array<Object>} ASTs
286
+ */
287
+ export function parsePaths(paths) {
288
+ const parser = new PathParser();
289
+ return parser.parseMultiple(paths);
290
+ }
@@ -476,9 +476,26 @@ BEGIN
476
476
  l_temp_value int;
477
477
  l_segment_table text;
478
478
  l_segment_record jsonb;
479
+ l_replacement_value text;
479
480
  BEGIN
480
481
  FOR l_j IN 0..jsonb_array_length(l_current_result) - 1 LOOP
481
- l_temp_segment := replace(l_continuation_parts[l_i], '$', (l_current_result->>l_j)::text);
482
+ l_replacement_value := (l_current_result->>l_j)::text;
483
+
484
+ -- Check if $ appears in a condition context table[field=$]
485
+ -- If so, we need to quote it properly for text values
486
+ IF l_continuation_parts[l_i] ~ '\[.*\$.*\]' THEN
487
+ -- Try to parse as integer first
488
+ BEGIN
489
+ -- If it's an integer, use it directly
490
+ l_temp_segment := replace(l_continuation_parts[l_i], '$', l_replacement_value::int::text);
491
+ EXCEPTION WHEN OTHERS THEN
492
+ -- Not an integer, quote it as a literal
493
+ l_temp_segment := replace(l_continuation_parts[l_i], '$', quote_literal(l_replacement_value));
494
+ END;
495
+ ELSE
496
+ -- Not in a condition, use raw value
497
+ l_temp_segment := replace(l_continuation_parts[l_i], '$', l_replacement_value);
498
+ END IF;
482
499
 
483
500
  -- Handle table.field syntax in continuation segments
484
501
  IF l_temp_segment ~ '^[a-z_]+\.[a-z_]+' THEN
@@ -498,7 +515,27 @@ BEGIN
498
515
  l_current_result := to_jsonb(l_current_ids);
499
516
  ELSE
500
517
  -- Single value result
501
- l_current_segment := replace(l_current_segment, '$', l_current_result::text);
518
+ DECLARE
519
+ l_replacement_value text;
520
+ BEGIN
521
+ l_replacement_value := l_current_result::text;
522
+
523
+ -- Check if $ appears in a condition context table[field=$]
524
+ -- If so, we need to quote it properly for text values
525
+ IF l_current_segment ~ '\[.*\$.*\]' THEN
526
+ -- Try to parse as integer first
527
+ BEGIN
528
+ -- If it's an integer, use it directly
529
+ l_current_segment := replace(l_current_segment, '$', l_replacement_value::int::text);
530
+ EXCEPTION WHEN OTHERS THEN
531
+ -- Not an integer, quote it as a literal
532
+ l_current_segment := replace(l_current_segment, '$', quote_literal(l_replacement_value));
533
+ END;
534
+ ELSE
535
+ -- Not in a condition, use raw value
536
+ l_current_segment := replace(l_current_segment, '$', l_replacement_value);
537
+ END IF;
538
+ END;
502
539
 
503
540
  -- Handle table.field syntax in continuation segments
504
541
  IF l_current_segment ~ '^[a-z_]+\.[a-z_]+' THEN
@@ -345,18 +345,20 @@ BEGIN
345
345
  p_entity);
346
346
  EXECUTE l_sql_stmt INTO l_result;
347
347
 
348
- -- Execute graph rules for update
349
- l_graph_rules_result := dzql.execute_graph_rules(
350
- p_entity,
351
- 'update',
352
- l_existing_record,
353
- l_result,
354
- p_user_id
355
- );
356
348
 
357
349
  ELSE
358
350
  -- INSERT: Use provided values, let database handle defaults
359
351
 
352
+ -- Auto-inject user_id if table has a user_id column and it's not provided
353
+ IF EXISTS (
354
+ SELECT 1 FROM information_schema.columns
355
+ WHERE table_schema = 'public'
356
+ AND table_name = p_entity
357
+ AND column_name = 'user_id'
358
+ ) AND (l_args_json->>'user_id' IS NULL) THEN
359
+ l_args_json := l_args_json || jsonb_build_object('user_id', p_user_id);
360
+ END IF;
361
+
360
362
  -- Check create permission on new values
361
363
  l_operation := 'create';
362
364
  l_permission_record := l_args_json;
@@ -387,14 +389,6 @@ BEGIN
387
389
  p_entity);
388
390
  EXECUTE l_sql_stmt INTO l_result;
389
391
 
390
- -- Execute graph rules for insert
391
- l_graph_rules_result := dzql.execute_graph_rules(
392
- p_entity,
393
- 'insert',
394
- NULL,
395
- l_result,
396
- p_user_id
397
- );
398
392
  END IF;
399
393
 
400
394
  -- Execute graph rules for the appropriate operation
@@ -342,6 +342,13 @@ BEGIN
342
342
  END IF;
343
343
  END IF;
344
344
 
345
+ -- Add permission check to WHERE clause
346
+ IF l_where_clause = '' OR l_where_clause = 'WHERE' THEN
347
+ l_where_clause := format('WHERE dzql.check_permission(%L, ''view'', %L, to_jsonb(t.*))', p_user_id, p_entity);
348
+ ELSE
349
+ l_where_clause := l_where_clause || format(' AND dzql.check_permission(%L, ''view'', %L, to_jsonb(t.*))', p_user_id, p_entity);
350
+ END IF;
351
+
345
352
  -- Build base SQL
346
353
  l_base_sql := format('FROM %I t %s', p_entity, l_where_clause);
347
354