spice-couchbase 2.0.45 → 2.0.47

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,10 +1,10 @@
1
1
  {
2
2
  "name": "spice-couchbase",
3
- "version": "2.0.45",
3
+ "version": "2.0.47",
4
4
  "description": "",
5
5
  "main": "build/index.js",
6
6
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
7
+ "test": "node src/utility.test.js"
8
8
  },
9
9
  "author": "Chad Fraser <chad@sonover.com> (http://sonover.com)",
10
10
  "license": "ISC",
package/src/index.js CHANGED
@@ -19,8 +19,8 @@ if (!Promise.allSettled) {
19
19
  .catch((reason) => ({
20
20
  status: "rejected",
21
21
  reason,
22
- }))
23
- )
22
+ })),
23
+ ),
24
24
  );
25
25
  }
26
26
 
@@ -36,7 +36,7 @@ module.exports = class Database {
36
36
  scope,
37
37
  query_scope = "cluster",
38
38
  auto_create_collection,
39
- } = {}
39
+ } = {},
40
40
  ) {
41
41
  if (connection) {
42
42
  this.connection_name = connection;
@@ -151,7 +151,7 @@ module.exports = class Database {
151
151
  { name: "scope_admin", bucket: this.db_config.bucket },
152
152
  { name: "data_reader", bucket: this.db_config.bucket },
153
153
  ],
154
- }
154
+ },
155
155
  );
156
156
  } catch (e) {
157
157
  console.log(e);
@@ -162,7 +162,7 @@ module.exports = class Database {
162
162
  return (
163
163
  (this.db_config.api_base ||
164
164
  `http://${_.last(
165
- _.first(this.db_config.nodes.split(",")).split("://")
165
+ _.first(this.db_config.nodes.split(",")).split("://"),
166
166
  )}`) +
167
167
  ":" +
168
168
  default_port
@@ -179,7 +179,7 @@ module.exports = class Database {
179
179
  if (!this.checkGlobelConnectionObject(this.collection_name)) {
180
180
  let collection_exists = await this.collectionExists(
181
181
  this.collection_name,
182
- this.scope_name
182
+ this.scope_name,
183
183
  );
184
184
  if (!collection_exists) {
185
185
  await this.createCollection(this.collection_name, this.scope_name);
@@ -205,7 +205,7 @@ module.exports = class Database {
205
205
  username: that.db_config.username,
206
206
  password: that.db_config.password,
207
207
  },
208
- }
208
+ },
209
209
  );
210
210
  let scope = _.find(all_scopes.data.scopes, { name: scope_name });
211
211
  let collectionSet = new Set();
@@ -248,7 +248,7 @@ module.exports = class Database {
248
248
  async createPrimaryIndex(scope_name, col_name) {
249
249
  try {
250
250
  let response = await this.query(
251
- `CREATE PRIMARY INDEX ON default:\`${this.db_config.bucket}\`.\`${scope_name}\`.\`${col_name}\``
251
+ `CREATE PRIMARY INDEX ON default:\`${this.db_config.bucket}\`.\`${scope_name}\`.\`${col_name}\``,
252
252
  );
253
253
  return response;
254
254
  } catch (e) {
@@ -271,7 +271,8 @@ module.exports = class Database {
271
271
  async get(doc_id, { columns = [] } = {}) {
272
272
  try {
273
273
  const useLookupIn = columns.length > 0;
274
- const collection = this.use_bucket_name ? this.connection : this.collection;
274
+ const collection =
275
+ this.use_bucket_name ? this.connection : this.collection;
275
276
 
276
277
  if (!useLookupIn) {
277
278
  let response = await collection.get(doc_id);
@@ -315,7 +316,7 @@ module.exports = class Database {
315
316
  document,
316
317
  {
317
318
  expiry: expiry,
318
- }
319
+ },
319
320
  );
320
321
  } catch (e) {
321
322
  console.log(e.message, e, doc_id);
@@ -326,7 +327,7 @@ module.exports = class Database {
326
327
  async touch(doc_id, expiry = 0) {
327
328
  await (this.use_bucket_name ? this.connection : this.collection).touch(
328
329
  doc_id,
329
- expiry
330
+ expiry,
330
331
  );
331
332
  return await this.get(doc_id);
332
333
  }
@@ -365,7 +366,8 @@ module.exports = class Database {
365
366
  }
366
367
  if (_.isArray(keys) && keys.length > 0) {
367
368
  const useLookupIn = columns.length > 0;
368
- const collection = this.use_bucket_name ? this.connection : this.collection;
369
+ const collection =
370
+ this.use_bucket_name ? this.connection : this.collection;
369
371
 
370
372
  if (!useLookupIn) {
371
373
  // Regular get - no batching needed
@@ -373,7 +375,7 @@ module.exports = class Database {
373
375
  let returned_all = await Promise.allSettled(functions);
374
376
 
375
377
  let responses = _.flatten(
376
- _.map(returned_all, (returned_obj) => returned_obj?.value)
378
+ _.map(returned_all, (returned_obj) => returned_obj?.value),
377
379
  );
378
380
 
379
381
  let final_responses = _.compact(
@@ -388,7 +390,7 @@ module.exports = class Database {
388
390
  }
389
391
  return value;
390
392
  }
391
- })
393
+ }),
392
394
  );
393
395
 
394
396
  return final_responses;
@@ -440,7 +442,7 @@ module.exports = class Database {
440
442
  return Object.keys(result.value).length > 1 ? result.value : null;
441
443
  }
442
444
  return null;
443
- })
445
+ }),
444
446
  );
445
447
 
446
448
  return final_responses;
@@ -508,7 +510,7 @@ module.exports = class Database {
508
510
  order_by,
509
511
  do_count = "false",
510
512
  query_scope,
511
- join = ""
513
+ join = "",
512
514
  ) {
513
515
  const is_legacy =
514
516
  this.scope_name !== "_default" && this.collection_name !== "_default";
@@ -603,7 +605,7 @@ module.exports = class Database {
603
605
  let full_query = search_query + " AND type:" + type;
604
606
  var query = that.SearchQuery.new(
605
607
  that.full_text_index || full_text_index,
606
- that.SearchQuery.queryString(full_query)
608
+ that.SearchQuery.queryString(full_query),
607
609
  );
608
610
 
609
611
  if (!limit) {
@@ -659,22 +661,69 @@ module.exports = class Database {
659
661
  return response.rows;
660
662
  }
661
663
 
664
+ /**
665
+ * Recursively checks if the query plan contains a Fetch operator
666
+ * @param {Object} plan - The EXPLAIN query plan
667
+ * @returns {boolean} - True if Fetch operator is found
668
+ */
669
+ hasFetchOperator(plan) {
670
+ if (!plan || typeof plan !== "object") {
671
+ return false;
672
+ }
673
+
674
+ // Check if this node is a Fetch operator
675
+ if (plan["#operator"] === "Fetch") {
676
+ return true;
677
+ }
678
+
679
+ // Recursively check all child properties
680
+ for (const key of Object.keys(plan)) {
681
+ const value = plan[key];
682
+ if (Array.isArray(value)) {
683
+ for (const item of value) {
684
+ if (this.hasFetchOperator(item)) {
685
+ return true;
686
+ }
687
+ }
688
+ } else if (typeof value === "object" && value !== null) {
689
+ if (this.hasFetchOperator(value)) {
690
+ return true;
691
+ }
692
+ }
693
+ }
694
+
695
+ return false;
696
+ }
697
+
662
698
  async optimize(query, exec_scope) {
663
699
  try {
664
- const options = {
665
- adhoc: true,
666
- };
667
- let advise_query = `ADVISE ${query}`;
668
- let response = await exec_scope.query(advise_query, options);
669
-
670
- let best_index = _.last(
671
- _.first(response?.rows || [])?.advice?.adviseinfo?.recommended_indexes
672
- ?.indexes || []
673
- )?.index_statement;
674
- if (best_index) {
675
- await exec_scope.query(best_index);
700
+ const { buildCoveringIndex } = require("./utility");
701
+
702
+ // 1. Run EXPLAIN to get the query plan
703
+ const explainQuery = `EXPLAIN ${query}`;
704
+ const explainResponse = await exec_scope.query(explainQuery);
705
+
706
+ // 2. Check if query plan contains Fetch operator (not covered)
707
+ const plan = explainResponse?.rows?.[0];
708
+ if (!this.hasFetchOperator(plan)) {
709
+ // Query is already covered, no need to create index
710
+ return;
676
711
  }
677
- } catch (e) {}
712
+
713
+ // 3. Build covering index from the query
714
+ const indexStatement = buildCoveringIndex(query, {
715
+ bucket: this.bucket,
716
+ scope: this.scope_name,
717
+ collection: this.collection_name,
718
+ });
719
+
720
+ // 4. Execute the index creation if we got a valid statement
721
+ if (indexStatement) {
722
+ const response = await exec_scope.query(indexStatement);
723
+ }
724
+ } catch (e) {
725
+ // Silently handle errors (index may already exist, etc.)
726
+ }
678
727
  }
679
728
 
680
729
  async query(query, query_scope = "cluster") {
package/src/utility.js ADDED
@@ -0,0 +1,370 @@
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
+ // Build the field list with deleted INCLUDE MISSING first
346
+ const fieldList = [
347
+ "`deleted` INCLUDE MISSING",
348
+ ...allFields.map((f) => `\`${f}\``),
349
+ ].join(", ");
350
+
351
+ // Generate unique index name based on bucket, scope, collection, and fields
352
+ // This ensures multiple queries covered by the same index get the same hash
353
+ const indexKey = `${bucket}|${scope}|${collection}|${allFields.sort().join(",")}`;
354
+ const hash = generateHash(indexKey);
355
+ const indexName = `adv_covered_${hash}`;
356
+
357
+ // Build the CREATE INDEX statement
358
+ const indexStatement = `CREATE INDEX \`${indexName}\` ON \`default\`:\`${bucket}\`.\`${scope}\`.\`${collection}\`(${fieldList})`;
359
+
360
+ return indexStatement;
361
+ }
362
+
363
+ module.exports = {
364
+ buildCoveringIndex,
365
+ extractWhereFields,
366
+ extractSelectFields,
367
+ extractGroupByFields,
368
+ extractOrderByFields,
369
+ generateHash,
370
+ };