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