spice-couchbase 2.0.44 → 2.0.46

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/src/utility.js ADDED
@@ -0,0 +1,368 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+
5
+ /**
6
+ * Generates a short hash from a string for unique index naming
7
+ * @param {string} str - The string to hash
8
+ * @returns {string} - A short hash string
9
+ */
10
+ function generateHash(str) {
11
+ return crypto.createHash("md5").update(str).digest("hex").substring(0, 8);
12
+ }
13
+
14
+ /**
15
+ * Extracts field names from WHERE clause of a query
16
+ * @param {string} query - The N1QL query
17
+ * @returns {string[]} - Array of field names found in WHERE clause
18
+ */
19
+ function extractWhereFields(query) {
20
+ const fields = new Set();
21
+
22
+ // Find the WHERE clause
23
+ const whereMatch = query.match(
24
+ /\bWHERE\b(.+?)(?:\bORDER\b|\bLIMIT\b|\bOFFSET\b|\bGROUP\b|\bHAVING\b|$)/is,
25
+ );
26
+ if (!whereMatch) {
27
+ return [];
28
+ }
29
+
30
+ const whereClause = whereMatch[1];
31
+
32
+ // Pattern to match field names in comparisons
33
+ // Matches: field = value, field > value, field < value, field >= value, field <= value
34
+ // Also matches: field IN (...), field LIKE ..., field IS NULL, field IS MISSING, field IS NOT NULL
35
+ // Handles backtick-quoted fields like `field_name`
36
+ const fieldPattern =
37
+ /`?(\w+)`?\s*(?:=|!=|<>|>=|<=|>|<|\bIN\b|\bLIKE\b|\bIS\b|\bBETWEEN\b)/gi;
38
+
39
+ let match;
40
+ while ((match = fieldPattern.exec(whereClause)) !== null) {
41
+ const fieldName = match[1].toLowerCase();
42
+ // Skip common SQL keywords that might be matched
43
+ if (
44
+ !["and", "or", "not", "null", "missing", "true", "false"].includes(
45
+ fieldName,
46
+ )
47
+ ) {
48
+ fields.add(match[1]); // Keep original case
49
+ }
50
+ }
51
+
52
+ // Also look for fields in function calls like LOWER(field), UPPER(field)
53
+ const functionPattern =
54
+ /(?:LOWER|UPPER|TRIM|LENGTH|SUBSTR)\s*\(\s*`?(\w+)`?/gi;
55
+ while ((match = functionPattern.exec(whereClause)) !== null) {
56
+ fields.add(match[1]);
57
+ }
58
+
59
+ // Extract array fields from ANY/EVERY ... IN array_field SATISFIES ... END
60
+ // Pattern: ANY/EVERY variable IN array_field SATISFIES ... END
61
+ const anyEveryPattern =
62
+ /\b(?:ANY|EVERY)\s+\w+\s+IN\s+`?(\w+)`?\s+SATISFIES/gi;
63
+ while ((match = anyEveryPattern.exec(whereClause)) !== null) {
64
+ fields.add(match[1]);
65
+ }
66
+
67
+ // Also extract nested property access within SATISFIES: v.property = value
68
+ // This captures fields accessed through array iteration variables
69
+ const satisfiesPattern = /SATISFIES\s+(.+?)\s+END/gi;
70
+ while ((match = satisfiesPattern.exec(whereClause)) !== null) {
71
+ const satisfiesClause = match[1];
72
+ // Look for comparisons like: v.property = value, v.field > 10
73
+ const nestedFieldPattern =
74
+ /\w+\.`?(\w+)`?\s*(?:=|!=|<>|>=|<=|>|<|\bIN\b|\bLIKE\b|\bIS\b)/gi;
75
+ let nestedMatch;
76
+ while ((nestedMatch = nestedFieldPattern.exec(satisfiesClause)) !== null) {
77
+ // These are nested properties, we might want to track them separately
78
+ // For now, we'll note them but the array field is what matters for indexing
79
+ }
80
+ }
81
+
82
+ return Array.from(fields);
83
+ }
84
+
85
+ /**
86
+ * Extracts field names from SELECT clause of a query
87
+ * @param {string} query - The N1QL query
88
+ * @returns {string[]|null} - Array of field names, or null if SELECT *
89
+ */
90
+ function extractSelectFields(query) {
91
+ // Find the SELECT clause (between SELECT and FROM)
92
+ const selectMatch = query.match(/\bSELECT\b(.+?)\bFROM\b/is);
93
+ if (!selectMatch) {
94
+ return null;
95
+ }
96
+
97
+ const selectClause = selectMatch[1].trim();
98
+
99
+ // Check for SELECT * patterns
100
+ if (
101
+ /^\s*\*\s*$/.test(selectClause) ||
102
+ /\(\s*\w+\s*\)\s*\.\s*\*/.test(selectClause)
103
+ ) {
104
+ return null; // Cannot determine fields for SELECT *
105
+ }
106
+
107
+ const fields = new Set();
108
+
109
+ // Split by comma, handling nested parentheses
110
+ const parts = splitSelectClause(selectClause);
111
+
112
+ for (const part of parts) {
113
+ const trimmedPart = part.trim();
114
+
115
+ // Skip if it's a * pattern
116
+ if (trimmedPart === "*" || trimmedPart.endsWith(".*")) {
117
+ return null;
118
+ }
119
+
120
+ // Handle AS alias - get the source field
121
+ const asMatch = trimmedPart.match(/^(.+?)\s+AS\s+\w+$/i);
122
+ const fieldExpr = asMatch ? asMatch[1].trim() : trimmedPart;
123
+
124
+ // Extract field from META().id pattern - skip these
125
+ if (/META\s*\(/i.test(fieldExpr)) {
126
+ continue;
127
+ }
128
+
129
+ // Extract field from function calls like COUNT(field), SUM(field)
130
+ const funcMatch = fieldExpr.match(/^\w+\s*\(\s*`?(\w+)`?\s*\)$/);
131
+ if (funcMatch && funcMatch[1].toLowerCase() !== "id") {
132
+ fields.add(funcMatch[1]);
133
+ continue;
134
+ }
135
+
136
+ // Handle simple field reference: `field` or collection.field or `collection`.`field`
137
+ const simpleFieldMatch = fieldExpr.match(/(?:`?\w+`?\s*\.\s*)?`?(\w+)`?$/);
138
+ if (simpleFieldMatch) {
139
+ const fieldName = simpleFieldMatch[1];
140
+ if (fieldName.toLowerCase() !== "id") {
141
+ fields.add(fieldName);
142
+ }
143
+ }
144
+ }
145
+
146
+ return Array.from(fields);
147
+ }
148
+
149
+ /**
150
+ * Extracts field names from GROUP BY clause of a query
151
+ * @param {string} query - The N1QL query
152
+ * @returns {string[]} - Array of field names found in GROUP BY clause
153
+ */
154
+ function extractGroupByFields(query) {
155
+ const fields = [];
156
+
157
+ // Find the GROUP BY clause
158
+ const groupByMatch = query.match(
159
+ /\bGROUP\s+BY\b(.+?)(?:\bHAVING\b|\bORDER\b|\bLIMIT\b|\bOFFSET\b|$)/is,
160
+ );
161
+ if (!groupByMatch) {
162
+ return [];
163
+ }
164
+
165
+ const groupByClause = groupByMatch[1];
166
+
167
+ // Split by comma and extract field names
168
+ const parts = groupByClause.split(",");
169
+
170
+ for (const part of parts) {
171
+ const trimmedPart = part.trim();
172
+
173
+ // Handle collection.field or `collection`.`field` syntax
174
+ const fieldMatch = trimmedPart.match(/(?:`?\w+`?\s*\.\s*)?`?(\w+)`?$/);
175
+ if (fieldMatch) {
176
+ const fieldName = fieldMatch[1];
177
+ // Skip if it's a keyword or id
178
+ if (!["id"].includes(fieldName.toLowerCase())) {
179
+ fields.push(fieldName);
180
+ }
181
+ }
182
+ }
183
+
184
+ return fields;
185
+ }
186
+
187
+ /**
188
+ * Extracts field names from ORDER BY clause of a query
189
+ * @param {string} query - The N1QL query
190
+ * @returns {string[]} - Array of field names found in ORDER BY clause
191
+ */
192
+ function extractOrderByFields(query) {
193
+ const fields = [];
194
+
195
+ // Find the ORDER BY clause
196
+ const orderByMatch = query.match(
197
+ /\bORDER\s+BY\b(.+?)(?:\bLIMIT\b|\bOFFSET\b|$)/is,
198
+ );
199
+ if (!orderByMatch) {
200
+ return [];
201
+ }
202
+
203
+ const orderByClause = orderByMatch[1];
204
+
205
+ // Split by comma and extract field names
206
+ const parts = orderByClause.split(",");
207
+
208
+ for (const part of parts) {
209
+ const trimmedPart = part.trim();
210
+
211
+ // Remove ASC/DESC suffix
212
+ const withoutDirection = trimmedPart
213
+ .replace(/\s+(ASC|DESC)\s*$/i, "")
214
+ .trim();
215
+
216
+ // Handle collection.field or `collection`.`field` syntax
217
+ const fieldMatch = withoutDirection.match(/(?:`?\w+`?\s*\.\s*)?`?(\w+)`?$/);
218
+ if (fieldMatch) {
219
+ const fieldName = fieldMatch[1];
220
+ // Skip if it's a keyword or id
221
+ if (!["id", "asc", "desc"].includes(fieldName.toLowerCase())) {
222
+ fields.push(fieldName);
223
+ }
224
+ }
225
+ }
226
+
227
+ return fields;
228
+ }
229
+
230
+ /**
231
+ * Splits SELECT clause by commas, respecting parentheses
232
+ * @param {string} clause - The SELECT clause content
233
+ * @returns {string[]} - Array of individual select expressions
234
+ */
235
+ function splitSelectClause(clause) {
236
+ const parts = [];
237
+ let current = "";
238
+ let depth = 0;
239
+
240
+ for (const char of clause) {
241
+ if (char === "(") {
242
+ depth++;
243
+ current += char;
244
+ } else if (char === ")") {
245
+ depth--;
246
+ current += char;
247
+ } else if (char === "," && depth === 0) {
248
+ parts.push(current);
249
+ current = "";
250
+ } else {
251
+ current += char;
252
+ }
253
+ }
254
+
255
+ if (current.trim()) {
256
+ parts.push(current);
257
+ }
258
+
259
+ return parts;
260
+ }
261
+
262
+ /**
263
+ * Builds a covering index CREATE statement from a query
264
+ * @param {string} query - The N1QL query to analyze
265
+ * @param {Object} options - Configuration options
266
+ * @param {string} options.bucket - The bucket name
267
+ * @param {string} options.scope - The scope name
268
+ * @param {string} options.collection - The collection name
269
+ * @returns {string|null} - The CREATE INDEX statement, or null if cannot build
270
+ */
271
+ function buildCoveringIndex(query, options) {
272
+ const { bucket, scope, collection } = options;
273
+
274
+ if (!bucket || !scope || !collection) {
275
+ return null;
276
+ }
277
+
278
+ // Extract fields from SELECT clause
279
+ const selectFields = extractSelectFields(query);
280
+ if (selectFields === null) {
281
+ // SELECT * query - cannot create covering index
282
+ return null;
283
+ }
284
+
285
+ // Extract fields from WHERE clause
286
+ const whereFields = extractWhereFields(query);
287
+
288
+ // Extract fields from GROUP BY clause
289
+ const groupByFields = extractGroupByFields(query);
290
+
291
+ // Extract fields from ORDER BY clause
292
+ const orderByFields = extractOrderByFields(query);
293
+
294
+ // Remove 'deleted' from whereFields as it will be added first with INCLUDE MISSING
295
+ const filteredWhereFields = whereFields.filter(
296
+ (f) => f.toLowerCase() !== "deleted",
297
+ );
298
+
299
+ // Track fields already added to avoid duplicates
300
+ const addedFields = new Set(filteredWhereFields.map((f) => f.toLowerCase()));
301
+
302
+ // Filter GROUP BY fields (remove duplicates from WHERE, remove deleted)
303
+ const filteredGroupByFields = groupByFields.filter((f) => {
304
+ const lower = f.toLowerCase();
305
+ if (addedFields.has(lower) || lower === "deleted") {
306
+ return false;
307
+ }
308
+ addedFields.add(lower);
309
+ return true;
310
+ });
311
+
312
+ // Filter ORDER BY fields (remove duplicates from WHERE and GROUP BY, remove deleted)
313
+ const filteredOrderByFields = orderByFields.filter((f) => {
314
+ const lower = f.toLowerCase();
315
+ if (addedFields.has(lower) || lower === "deleted") {
316
+ return false;
317
+ }
318
+ addedFields.add(lower);
319
+ return true;
320
+ });
321
+
322
+ // Filter SELECT fields (remove duplicates from WHERE, GROUP BY, and ORDER BY, remove deleted)
323
+ const selectFieldsFiltered = selectFields.filter((f) => {
324
+ const lower = f.toLowerCase();
325
+ if (addedFields.has(lower) || lower === "deleted") {
326
+ return false;
327
+ }
328
+ addedFields.add(lower);
329
+ return true;
330
+ });
331
+
332
+ // All fields for the index: WHERE -> GROUP BY -> ORDER BY -> SELECT
333
+ const allFields = [
334
+ ...filteredWhereFields,
335
+ ...filteredGroupByFields,
336
+ ...filteredOrderByFields,
337
+ ...selectFieldsFiltered,
338
+ ];
339
+
340
+ if (allFields.length === 0) {
341
+ // No fields to index besides deleted
342
+ return null;
343
+ }
344
+
345
+ // Generate unique index name based on query hash
346
+ const hash = generateHash(query);
347
+ const indexName = `adv_covered_${hash}`;
348
+
349
+ // Build the field list with deleted INCLUDE MISSING first
350
+ const fieldList = [
351
+ "`deleted` INCLUDE MISSING",
352
+ ...allFields.map((f) => `\`${f}\``),
353
+ ].join(", ");
354
+
355
+ // Build the CREATE INDEX statement
356
+ const indexStatement = `CREATE INDEX \`${indexName}\` ON \`default\`:\`${bucket}\`.\`${scope}\`.\`${collection}\`(${fieldList})`;
357
+
358
+ return indexStatement;
359
+ }
360
+
361
+ module.exports = {
362
+ buildCoveringIndex,
363
+ extractWhereFields,
364
+ extractSelectFields,
365
+ extractGroupByFields,
366
+ extractOrderByFields,
367
+ generateHash,
368
+ };