dzql 0.6.32 → 0.6.33

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.6.32",
3
+ "version": "0.6.33",
4
4
  "description": "Database-first real-time framework with TypeScript support",
5
5
  "repository": {
6
6
  "type": "git",
@@ -88,34 +88,74 @@ BEGIN
88
88
  v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
89
89
 
90
90
  -- Build WHERE clause from filters
91
+ -- Note: We avoid ::TEXT casts to preserve index usage on numeric/date columns
92
+ -- The %L format specifier handles proper escaping for all types
91
93
  FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
92
94
  LOOP
93
95
  -- Handle simple value (exact match)
94
- IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
95
- v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_filter #>> '{}');
96
- ELSE
96
+ IF jsonb_typeof(v_filter) = 'number' THEN
97
+ -- Numeric: compare without casting
98
+ v_where_clause := v_where_clause || format(' AND %I = %s', v_field, v_filter);
99
+ ELSIF jsonb_typeof(v_filter) = 'boolean' THEN
100
+ -- Boolean: compare directly
101
+ v_where_clause := v_where_clause || format(' AND %I = %s', v_field, v_filter);
102
+ ELSIF jsonb_typeof(v_filter) = 'string' THEN
103
+ -- String: use proper quoting
104
+ v_where_clause := v_where_clause || format(' AND %I = %L', v_field, v_filter #>> '{}');
105
+ ELSIF jsonb_typeof(v_filter) = 'object' THEN
97
106
  -- Handle operator-based filters
98
107
  FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
99
108
  LOOP
100
109
  CASE v_operator
101
110
  WHEN 'eq' THEN
102
- v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_value #>> '{}');
111
+ IF jsonb_typeof(v_value) = 'number' THEN
112
+ v_where_clause := v_where_clause || format(' AND %I = %s', v_field, v_value);
113
+ ELSE
114
+ v_where_clause := v_where_clause || format(' AND %I = %L', v_field, v_value #>> '{}');
115
+ END IF;
103
116
  WHEN 'ne' THEN
104
- v_where_clause := v_where_clause || format(' AND %I::TEXT != %L', v_field, v_value #>> '{}');
117
+ IF jsonb_typeof(v_value) = 'number' THEN
118
+ v_where_clause := v_where_clause || format(' AND %I != %s', v_field, v_value);
119
+ ELSE
120
+ v_where_clause := v_where_clause || format(' AND %I != %L', v_field, v_value #>> '{}');
121
+ END IF;
105
122
  WHEN 'gt' THEN
106
- v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
123
+ IF jsonb_typeof(v_value) = 'number' THEN
124
+ v_where_clause := v_where_clause || format(' AND %I > %s', v_field, v_value);
125
+ ELSE
126
+ v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
127
+ END IF;
107
128
  WHEN 'gte' THEN
108
- v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
129
+ IF jsonb_typeof(v_value) = 'number' THEN
130
+ v_where_clause := v_where_clause || format(' AND %I >= %s', v_field, v_value);
131
+ ELSE
132
+ v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
133
+ END IF;
109
134
  WHEN 'lt' THEN
110
- v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
135
+ IF jsonb_typeof(v_value) = 'number' THEN
136
+ v_where_clause := v_where_clause || format(' AND %I < %s', v_field, v_value);
137
+ ELSE
138
+ v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
139
+ END IF;
111
140
  WHEN 'lte' THEN
112
- v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
141
+ IF jsonb_typeof(v_value) = 'number' THEN
142
+ v_where_clause := v_where_clause || format(' AND %I <= %s', v_field, v_value);
143
+ ELSE
144
+ v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
145
+ END IF;
113
146
  WHEN 'in' THEN
114
- v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
115
- (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
147
+ -- For IN, we need to handle arrays - cast elements appropriately
148
+ IF jsonb_typeof(v_value->0) = 'number' THEN
149
+ v_where_clause := v_where_clause || format(' AND %I = ANY(ARRAY(SELECT (jsonb_array_elements(%L))::int))', v_field, v_value);
150
+ ELSE
151
+ v_where_clause := v_where_clause || format(' AND %I = ANY(ARRAY(SELECT jsonb_array_elements_text(%L)))', v_field, v_value);
152
+ END IF;
116
153
  WHEN 'not_in' THEN
117
- v_where_clause := v_where_clause || format(' AND %I::TEXT != ALL(%L)', v_field,
118
- (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
154
+ IF jsonb_typeof(v_value->0) = 'number' THEN
155
+ v_where_clause := v_where_clause || format(' AND %I != ALL(ARRAY(SELECT (jsonb_array_elements(%L))::int))', v_field, v_value);
156
+ ELSE
157
+ v_where_clause := v_where_clause || format(' AND %I != ALL(ARRAY(SELECT jsonb_array_elements_text(%L)))', v_field, v_value);
158
+ END IF;
119
159
  WHEN 'like' THEN
120
160
  v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
121
161
  WHEN 'ilike' THEN
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { SubscribableIR, IncludeIR, EntityIR } from "../../shared/ir.js";
12
+ import { parsePath } from "../compiler/paths.js";
12
13
 
13
14
  export function generateSubscribableSQL(name: string, sub: SubscribableIR, entities: Record<string, EntityIR> = {}): string {
14
15
  // Validate: if root.key is set, it must exist in params or start with '@'
@@ -144,6 +145,7 @@ $$;`;
144
145
  /**
145
146
  * Compile permission for subscribable can_subscribe function.
146
147
  * Uses RECORD dot notation since v_root is a RECORD, not JSONB.
148
+ * Supports unlimited traversal depth using the shared path parser.
147
149
  *
148
150
  * @param entityName - The root entity name
149
151
  * @param rule - The permission rule (e.g., "@org_id->acts_for[org_id=$]{active}.user_id")
@@ -156,69 +158,79 @@ function compileSubscribePermission(entityName: string, rule: string, rootKey: s
156
158
  return field === rootKey ? 'id' : field;
157
159
  };
158
160
 
159
- // Case 1: Simple Field Check (@org_id)
160
- // Implies: EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = v_root.id AND acts_for.user_id = p_user_id)
161
- if (rule.match(/^@[a-zA-Z0-9_]+$/)) {
162
- const field = rule.substring(1);
163
- const rootField = resolveField(field);
164
- // For simple field checks, we check acts_for membership
165
- // The acts_for join uses the original field name (org_id), but v_root uses resolved field (id)
166
- return `EXISTS (SELECT 1 FROM acts_for WHERE acts_for.${field} = v_root.${rootField} AND acts_for.user_id = p_user_id AND acts_for.active)`;
161
+ // Parse the path using the shared parser
162
+ const parsed = parsePath(rule);
163
+
164
+ if (!parsed.isValid) {
165
+ console.warn(`[Compiler] Warning: Invalid permission path '${rule}': ${parsed.error}`);
166
+ return 'FALSE';
167
167
  }
168
168
 
169
- // Case 2: Graph Traversal (@field->table[filter]{condition}.user_id)
170
- if (rule.includes('->')) {
171
- const parts = rule.split('->');
172
- const startField = parts[0].substring(1); // Remove @
173
- const rootField = resolveField(startField);
169
+ // TRUE/FALSE literals
170
+ if (parsed.hops.length === 0) {
171
+ return rule === 'TRUE' || rule === 'true' ? 'TRUE' : 'FALSE';
172
+ }
174
173
 
175
- // Multi-level paths not fully supported
176
- if (parts.length > 2) {
177
- console.warn(`[Compiler] Warning: Multi-level permission path '${rule}' not fully supported.`);
178
- return 'FALSE';
179
- }
174
+ // Simple field reference: @org_id implies checking acts_for membership
175
+ if (parsed.hops.length === 1 && parsed.hops[0].type === 'field') {
176
+ const field = parsed.hops[0].field!;
177
+ const rootField = resolveField(field);
178
+ return `EXISTS (SELECT 1 FROM acts_for WHERE acts_for.${field} = v_root.${rootField} AND acts_for.user_id = p_user_id AND acts_for.active)`;
179
+ }
180
180
 
181
- const targetPart = parts[1];
181
+ // Traversal path - build EXISTS with nested subqueries for unlimited hops
182
+ // Start with the field from v_root
183
+ const startField = parsed.startField!;
184
+ const rootField = resolveField(startField);
182
185
 
183
- const tableMatch = targetPart.match(/^([a-zA-Z0-9_]+)/);
184
- if (!tableMatch) return 'FALSE';
185
- const targetTable = tableMatch[1];
186
+ // Build value expression starting from v_root
187
+ let valueExpr = `v_root.${rootField}`;
186
188
 
187
- // Extract filter: [org_id=$]
188
- const filterMatch = targetPart.match(/\[([a-zA-Z0-9_]+)=\$\]/);
189
- const joinField = filterMatch ? filterMatch[1] : null;
189
+ // Process intermediate hops (all but first field and last final)
190
+ for (let i = 1; i < parsed.hops.length - 1; i++) {
191
+ const hop = parsed.hops[i];
192
+ if (hop.type === 'table' && hop.table && hop.outputField) {
193
+ valueExpr = `(SELECT ${hop.outputField} FROM ${hop.table} WHERE id = ${valueExpr})`;
194
+ }
195
+ }
190
196
 
191
- // Extract condition: {active}
192
- const condMatch = targetPart.match(/\{([^}]+)\}/);
193
- const condition = condMatch ? condMatch[1] : null;
197
+ // Final hop - build EXISTS check
198
+ const finalHop = parsed.hops[parsed.hops.length - 1];
199
+ if (finalHop.type !== 'final' || !finalHop.table) {
200
+ return 'FALSE';
201
+ }
194
202
 
195
- let sql = `EXISTS (SELECT 1 FROM ${targetTable} WHERE `;
203
+ const conditions: string[] = [];
196
204
 
197
- // Join Clause - use RECORD dot notation
198
- // The target table uses its own column name (e.g., acts_for.org_id)
199
- // The root uses the resolved field (e.g., v_root.id when startField matches rootKey)
200
- if (joinField) {
201
- sql += `${targetTable}.${joinField} = v_root.${rootField}`;
202
- } else {
203
- // Implicit join: table.id = v_root.field
204
- sql += `${targetTable}.id = v_root.${rootField}`;
205
- }
205
+ // Join condition
206
+ if (finalHop.filter && finalHop.filter.value === '$') {
207
+ conditions.push(`${finalHop.table}.${finalHop.filter.field} = ${valueExpr}`);
208
+ } else {
209
+ conditions.push(`${finalHop.table}.id = ${valueExpr}`);
210
+ }
206
211
 
207
- // User Check
208
- if (rule.endsWith('.user_id')) {
209
- sql += ` AND ${targetTable}.user_id = p_user_id`;
210
- }
212
+ // User check
213
+ if (finalHop.outputField === 'user_id') {
214
+ conditions.push(`${finalHop.table}.user_id = p_user_id`);
215
+ } else if (finalHop.outputField) {
216
+ conditions.push(`${finalHop.table}.${finalHop.outputField} = p_user_id`);
217
+ }
211
218
 
212
- // Condition
213
- if (condition) {
214
- sql += ` AND ${targetTable}.${condition}`;
219
+ // Additional conditions from {active} or {role=admin}
220
+ for (const cond of finalHop.conditions || []) {
221
+ if (cond.type === 'boolean') {
222
+ conditions.push(`${finalHop.table}.${cond.field} = true`);
223
+ } else if (cond.type === 'equality') {
224
+ if (cond.value === 'NULL') {
225
+ conditions.push(`${finalHop.table}.${cond.field} IS NULL`);
226
+ } else {
227
+ const value = cond.value!.match(/^\d+$/) ? cond.value : `'${cond.value}'`;
228
+ conditions.push(`${finalHop.table}.${cond.field} = ${value}`);
229
+ }
215
230
  }
216
-
217
- sql += `)`;
218
- return sql;
219
231
  }
220
232
 
221
- return 'FALSE'; // Default deny
233
+ return `EXISTS (SELECT 1 FROM ${finalHop.table} WHERE ${conditions.join(' AND ')})`;
222
234
  }
223
235
 
224
236
  /** Column info from EntityIR */