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/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
|
+
};
|