spice-couchbase 2.0.48 → 2.0.50

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 CHANGED
@@ -275,11 +275,36 @@ module.exports = class Database {
275
275
  })();
276
276
  }
277
277
 
278
+ primaryIndexExists(scope_name, col_name) {
279
+ var _this6 = this;
280
+
281
+ return _asyncToGenerator(function* () {
282
+ try {
283
+ var response = yield _this6.cluster.query("SELECT RAW name\n FROM system:indexes\n WHERE bucket_id = $bucket\n AND scope_id = $scope\n AND keyspace_id = $collection\n AND is_primary = true\n LIMIT 1", {
284
+ namedParameters: {
285
+ bucket: _this6.db_config.bucket,
286
+ scope: scope_name,
287
+ collection: col_name
288
+ },
289
+ scanConsistency: QueryScanConsistency.RequestPlus
290
+ });
291
+ return Array.isArray(response && response.rows) && response.rows.length > 0;
292
+ } catch (e) {
293
+ console.log("Primary index lookup failed:", e);
294
+ return false;
295
+ }
296
+ })();
297
+ }
298
+
278
299
  createPrimaryIndex(scope_name, col_name) {
279
300
  var _this6 = this;
280
301
 
281
302
  return _asyncToGenerator(function* () {
282
303
  try {
304
+ if (yield _this6.primaryIndexExists(scope_name, col_name)) {
305
+ return;
306
+ }
307
+
283
308
  var response = yield _this6.query("CREATE PRIMARY INDEX ON default:`".concat(_this6.db_config.bucket, "`.`").concat(scope_name, "`.`").concat(col_name, "`"));
284
309
  return response;
285
310
  } catch (e) {
@@ -872,4 +897,4 @@ module.exports = class Database {
872
897
  })();
873
898
  }
874
899
 
875
- };
900
+ };
package/build/utility.js CHANGED
@@ -181,6 +181,16 @@ function extractGroupByFields(query) {
181
181
 
182
182
 
183
183
  function extractOrderByFields(query) {
184
+ return extractOrderByIndexFields(query).map(field => field.name);
185
+ }
186
+ /**
187
+ * Extracts ORDER BY fields with sort direction for index key generation
188
+ * @param {string} query - The N1QL query
189
+ * @returns {{name: string, direction: string}[]} - Array of fields with normalized direction
190
+ */
191
+
192
+
193
+ function extractOrderByIndexFields(query) {
184
194
  var fields = []; // Find the ORDER BY clause
185
195
 
186
196
  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);
@@ -194,8 +204,10 @@ function extractOrderByFields(query) {
194
204
  var parts = orderByClause.split(",");
195
205
 
196
206
  for (var part of parts) {
197
- var trimmedPart = part.trim(); // Remove ASC/DESC suffix
207
+ var trimmedPart = part.trim(); // Remove ASC/DESC suffix while preserving DESC for index key generation.
198
208
 
209
+ var directionMatch = trimmedPart.match(/\s+(ASC|DESC)\s*$/i);
210
+ var direction = directionMatch && directionMatch[1].toUpperCase() === "DESC" ? "DESC" : "";
199
211
  var withoutDirection = trimmedPart.replace(/\s+(ASC|DESC)\s*$/i, "").trim(); // Handle collection.field or `collection`.`field` syntax
200
212
 
201
213
  var fieldMatch = withoutDirection.match(/(?:`?\w+`?\s*\.\s*)?`?(\w+)`?$/);
@@ -204,13 +216,41 @@ function extractOrderByFields(query) {
204
216
  var fieldName = fieldMatch[1]; // Skip if it's a keyword or id
205
217
 
206
218
  if (!["id", "asc", "desc"].includes(fieldName.toLowerCase())) {
207
- fields.push(fieldName);
219
+ fields.push({
220
+ name: fieldName,
221
+ direction
222
+ });
208
223
  }
209
224
  }
210
225
  }
211
226
 
212
227
  return fields;
213
228
  }
229
+
230
+ function buildOrderDirectionMap(orderByIndexFields) {
231
+ var directionMap = new Map();
232
+
233
+ for (var {
234
+ name,
235
+ direction
236
+ } of orderByIndexFields) {
237
+ if (direction === "DESC") {
238
+ directionMap.set(name.toLowerCase(), direction);
239
+ }
240
+ }
241
+
242
+ return directionMap;
243
+ }
244
+
245
+ function formatIndexField(field, orderDirectionMap) {
246
+ var direction = orderDirectionMap.get(field.toLowerCase());
247
+ return "`".concat(field, "`").concat(direction ? " ".concat(direction) : "");
248
+ }
249
+
250
+ function formatIndexKey(field, orderDirectionMap) {
251
+ var direction = orderDirectionMap.get(field.toLowerCase());
252
+ return "".concat(field).concat(direction ? " ".concat(direction) : "");
253
+ }
214
254
  /**
215
255
  * Splits SELECT clause by commas, respecting parentheses
216
256
  * @param {string} clause - The SELECT clause content
@@ -277,9 +317,11 @@ function buildCoveringIndex(query, options) {
277
317
 
278
318
  var whereFields = extractWhereFields(query); // Extract fields from GROUP BY clause
279
319
 
280
- var groupByFields = extractGroupByFields(query); // Extract fields from ORDER BY clause
320
+ var groupByFields = extractGroupByFields(query); // Extract fields from ORDER BY clause, preserving DESC sort direction
281
321
 
282
- var orderByFields = extractOrderByFields(query); // Remove 'deleted' from whereFields as it will be added first with INCLUDE MISSING
322
+ var orderByIndexFields = extractOrderByIndexFields(query);
323
+ var orderDirectionMap = buildOrderDirectionMap(orderByIndexFields);
324
+ var orderByFields = orderByIndexFields.map(field => field.name); // Remove 'deleted' from whereFields as it will be added first with INCLUDE MISSING
283
325
 
284
326
  var filteredWhereFields = whereFields.filter(f => f.toLowerCase() !== "deleted"); // Track fields already added to avoid duplicates
285
327
 
@@ -326,10 +368,12 @@ function buildCoveringIndex(query, options) {
326
368
  } // Build the field list with deleted INCLUDE MISSING first
327
369
 
328
370
 
329
- var fieldList = ["`deleted` INCLUDE MISSING", ...allFields.map(f => "`".concat(f, "`"))].join(", "); // Generate unique index name based on bucket, scope, collection, and fields
371
+ var fieldList = ["`deleted` INCLUDE MISSING", ...allFields.map(f => formatIndexField(f, orderDirectionMap))].join(", "); // Generate unique index name based on bucket, scope, collection, and fields
330
372
  // This ensures multiple queries covered by the same index get the same hash
331
373
 
332
- var indexKey = "".concat(bucket, "|").concat(scope, "|").concat(collection, "|").concat(allFields.sort().join(","));
374
+ var indexFieldsKey = allFields.map(field => formatIndexKey(field, orderDirectionMap)).sort().join(",");
375
+ var orderFieldsKey = orderByIndexFields.map(field => formatIndexKey(field.name, orderDirectionMap)).join(",");
376
+ var indexKey = "".concat(bucket, "|").concat(scope, "|").concat(collection, "|fields:").concat(indexFieldsKey, "|order:").concat(orderFieldsKey);
333
377
  var hash = generateHash(indexKey);
334
378
  var indexName = "adv_covered_".concat(hash); // Build the CREATE INDEX statement
335
379
 
@@ -343,5 +387,6 @@ module.exports = {
343
387
  extractSelectFields,
344
388
  extractGroupByFields,
345
389
  extractOrderByFields,
390
+ extractOrderByIndexFields,
346
391
  generateHash
347
- };
392
+ };
@@ -598,6 +598,24 @@ test("same index name for different queries with same fields", () => {
598
598
 
599
599
  assert.strictEqual(name1, name2);
600
600
  });
601
+ test("different index name for different ORDER BY direction", () => {
602
+ var query1 = "SELECT name FROM users WHERE status = 'active' ORDER BY created_at";
603
+ var query2 = "SELECT name FROM users WHERE status = 'active' ORDER BY created_at DESC";
604
+ var result1 = buildCoveringIndex(query1, defaultOptions);
605
+ var result2 = buildCoveringIndex(query2, defaultOptions);
606
+ var name1 = result1.match(/CREATE INDEX `(adv_covered_\w+)`/)[1];
607
+ var name2 = result2.match(/CREATE INDEX `(adv_covered_\w+)`/)[1];
608
+ assert.notStrictEqual(name1, name2);
609
+ });
610
+ test("different index name for different multiple ORDER BY field order", () => {
611
+ var query1 = "SELECT email FROM users WHERE status = 'active' ORDER BY last_name ASC, created_at DESC";
612
+ var query2 = "SELECT email FROM users WHERE status = 'active' ORDER BY created_at DESC, last_name ASC";
613
+ var result1 = buildCoveringIndex(query1, defaultOptions);
614
+ var result2 = buildCoveringIndex(query2, defaultOptions);
615
+ var name1 = result1.match(/CREATE INDEX `(adv_covered_\w+)`/)[1];
616
+ var name2 = result2.match(/CREATE INDEX `(adv_covered_\w+)`/)[1];
617
+ assert.notStrictEqual(name1, name2);
618
+ });
601
619
  test("different index name for queries with different fields", () => {
602
620
  var query1 = "SELECT name FROM users WHERE status = 'active'";
603
621
  var query2 = "SELECT email FROM users WHERE type = 'admin'";
@@ -659,7 +677,7 @@ test("includes ORDER BY fields in index", () => {
659
677
  assert.ok(result !== null);
660
678
  assert.ok(result.includes("`deleted` INCLUDE MISSING"));
661
679
  assert.ok(result.includes("`status`"));
662
- assert.ok(result.includes("`created_at`"));
680
+ assert.ok(result.includes("`created_at` DESC"));
663
681
  assert.ok(result.includes("`name`"));
664
682
  assert.ok(result.includes("`email`"));
665
683
  });
@@ -681,6 +699,7 @@ test("deduplicates ORDER BY field if already in WHERE", () => {
681
699
  var firstCreatedAt = result.indexOf("`created_at`");
682
700
  var lastCreatedAt = result.lastIndexOf("`created_at`");
683
701
  assert.strictEqual(firstCreatedAt, lastCreatedAt);
702
+ assert.ok(result.includes("`created_at` DESC"));
684
703
  });
685
704
  test("deduplicates ORDER BY field if already in SELECT", () => {
686
705
  var query = "SELECT name, created_at FROM users WHERE status = 'active' ORDER BY created_at DESC";
@@ -688,6 +707,7 @@ test("deduplicates ORDER BY field if already in SELECT", () => {
688
707
 
689
708
  var matches = result.match(/`created_at`/g);
690
709
  assert.strictEqual(matches.length, 1);
710
+ assert.ok(result.includes("`created_at` DESC"));
691
711
  });
692
712
  test("handles multiple ORDER BY fields", () => {
693
713
  var query = "SELECT email FROM users WHERE status = 'active' ORDER BY last_name ASC, first_name ASC";
@@ -701,13 +721,20 @@ test("handles multiple ORDER BY fields", () => {
701
721
  assert.ok(lastNameIndex < emailIndex);
702
722
  assert.ok(firstNameIndex < emailIndex);
703
723
  });
724
+ test("handles mixed ORDER BY directions in index", () => {
725
+ var query = "SELECT email FROM users WHERE status = 'active' ORDER BY last_name ASC, created_at DESC";
726
+ var result = buildCoveringIndex(query, defaultOptions);
727
+ assert.ok(result.includes("`last_name`"));
728
+ assert.ok(!result.includes("`last_name` ASC"));
729
+ assert.ok(result.includes("`created_at` DESC"));
730
+ });
704
731
  test("handles realistic query with ORDER BY", () => {
705
732
  var query = "SELECT first_name, last_name, email, status \n FROM users \n WHERE (deleted = false OR deleted IS MISSING) AND status = 'active' \n ORDER BY created_at DESC \n LIMIT 50 OFFSET 0";
706
733
  var result = buildCoveringIndex(query, defaultOptions);
707
734
  assert.ok(result !== null);
708
735
  assert.ok(result.includes("`deleted` INCLUDE MISSING"));
709
736
  assert.ok(result.includes("`status`"));
710
- assert.ok(result.includes("`created_at`"));
737
+ assert.ok(result.includes("`created_at` DESC"));
711
738
  assert.ok(result.includes("`first_name`"));
712
739
  assert.ok(result.includes("`last_name`"));
713
740
  assert.ok(result.includes("`email`")); // Verify order: status (WHERE) -> created_at (ORDER BY) -> SELECT fields
@@ -804,4 +831,4 @@ test("field order: WHERE -> GROUP BY -> ORDER BY -> SELECT", () => {
804
831
  console.log("\n========================================");
805
832
  console.log("Tests: ".concat(passed + failed, " | Passed: ").concat(passed, " | Failed: ").concat(failed));
806
833
  console.log("========================================\n");
807
- process.exit(failed > 0 ? 1 : 0);
834
+ process.exit(failed > 0 ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spice-couchbase",
3
- "version": "2.0.48",
3
+ "version": "2.0.50",
4
4
  "description": "",
5
5
  "main": "build/index.js",
6
6
  "scripts": {
package/src/index.js CHANGED
@@ -250,8 +250,37 @@ module.exports = class Database {
250
250
  } catch (e) {}
251
251
  }
252
252
 
253
+ async primaryIndexExists(scope_name, col_name) {
254
+ try {
255
+ let response = await this.cluster.query(
256
+ `SELECT RAW name
257
+ FROM system:indexes
258
+ WHERE bucket_id = $bucket
259
+ AND scope_id = $scope
260
+ AND keyspace_id = $collection
261
+ AND is_primary = true
262
+ LIMIT 1`,
263
+ {
264
+ namedParameters: {
265
+ bucket: this.db_config.bucket,
266
+ scope: scope_name,
267
+ collection: col_name,
268
+ },
269
+ scanConsistency: QueryScanConsistency.RequestPlus,
270
+ },
271
+ );
272
+ return Array.isArray(response?.rows) && response.rows.length > 0;
273
+ } catch (e) {
274
+ console.log("Primary index lookup failed:", e);
275
+ return false;
276
+ }
277
+ }
278
+
253
279
  async createPrimaryIndex(scope_name, col_name) {
254
280
  try {
281
+ if (await this.primaryIndexExists(scope_name, col_name)) {
282
+ return;
283
+ }
255
284
  let response = await this.query(
256
285
  `CREATE PRIMARY INDEX ON default:\`${this.db_config.bucket}\`.\`${scope_name}\`.\`${col_name}\``,
257
286
  );
package/src/utility.js CHANGED
@@ -190,6 +190,15 @@ function extractGroupByFields(query) {
190
190
  * @returns {string[]} - Array of field names found in ORDER BY clause
191
191
  */
192
192
  function extractOrderByFields(query) {
193
+ return extractOrderByIndexFields(query).map((field) => field.name);
194
+ }
195
+
196
+ /**
197
+ * Extracts ORDER BY fields with sort direction for index key generation
198
+ * @param {string} query - The N1QL query
199
+ * @returns {{name: string, direction: string}[]} - Array of fields with normalized direction
200
+ */
201
+ function extractOrderByIndexFields(query) {
193
202
  const fields = [];
194
203
 
195
204
  // Find the ORDER BY clause
@@ -208,7 +217,12 @@ function extractOrderByFields(query) {
208
217
  for (const part of parts) {
209
218
  const trimmedPart = part.trim();
210
219
 
211
- // Remove ASC/DESC suffix
220
+ // Remove ASC/DESC suffix while preserving DESC for index key generation.
221
+ const directionMatch = trimmedPart.match(/\s+(ASC|DESC)\s*$/i);
222
+ const direction =
223
+ directionMatch && directionMatch[1].toUpperCase() === "DESC" ?
224
+ "DESC"
225
+ : "";
212
226
  const withoutDirection = trimmedPart
213
227
  .replace(/\s+(ASC|DESC)\s*$/i, "")
214
228
  .trim();
@@ -219,7 +233,7 @@ function extractOrderByFields(query) {
219
233
  const fieldName = fieldMatch[1];
220
234
  // Skip if it's a keyword or id
221
235
  if (!["id", "asc", "desc"].includes(fieldName.toLowerCase())) {
222
- fields.push(fieldName);
236
+ fields.push({ name: fieldName, direction });
223
237
  }
224
238
  }
225
239
  }
@@ -227,6 +241,26 @@ function extractOrderByFields(query) {
227
241
  return fields;
228
242
  }
229
243
 
244
+ function buildOrderDirectionMap(orderByIndexFields) {
245
+ const directionMap = new Map();
246
+ for (const { name, direction } of orderByIndexFields) {
247
+ if (direction === "DESC") {
248
+ directionMap.set(name.toLowerCase(), direction);
249
+ }
250
+ }
251
+ return directionMap;
252
+ }
253
+
254
+ function formatIndexField(field, orderDirectionMap) {
255
+ const direction = orderDirectionMap.get(field.toLowerCase());
256
+ return `\`${field}\`${direction ? ` ${direction}` : ""}`;
257
+ }
258
+
259
+ function formatIndexKey(field, orderDirectionMap) {
260
+ const direction = orderDirectionMap.get(field.toLowerCase());
261
+ return `${field}${direction ? ` ${direction}` : ""}`;
262
+ }
263
+
230
264
  /**
231
265
  * Splits SELECT clause by commas, respecting parentheses
232
266
  * @param {string} clause - The SELECT clause content
@@ -288,8 +322,10 @@ function buildCoveringIndex(query, options) {
288
322
  // Extract fields from GROUP BY clause
289
323
  const groupByFields = extractGroupByFields(query);
290
324
 
291
- // Extract fields from ORDER BY clause
292
- const orderByFields = extractOrderByFields(query);
325
+ // Extract fields from ORDER BY clause, preserving DESC sort direction
326
+ const orderByIndexFields = extractOrderByIndexFields(query);
327
+ const orderDirectionMap = buildOrderDirectionMap(orderByIndexFields);
328
+ const orderByFields = orderByIndexFields.map((field) => field.name);
293
329
 
294
330
  // Remove 'deleted' from whereFields as it will be added first with INCLUDE MISSING
295
331
  const filteredWhereFields = whereFields.filter(
@@ -345,12 +381,19 @@ function buildCoveringIndex(query, options) {
345
381
  // Build the field list with deleted INCLUDE MISSING first
346
382
  const fieldList = [
347
383
  "`deleted` INCLUDE MISSING",
348
- ...allFields.map((f) => `\`${f}\``),
384
+ ...allFields.map((f) => formatIndexField(f, orderDirectionMap)),
349
385
  ].join(", ");
350
386
 
351
387
  // Generate unique index name based on bucket, scope, collection, and fields
352
388
  // This ensures multiple queries covered by the same index get the same hash
353
- const indexKey = `${bucket}|${scope}|${collection}|${allFields.sort().join(",")}`;
389
+ const indexFieldsKey = allFields
390
+ .map((field) => formatIndexKey(field, orderDirectionMap))
391
+ .sort()
392
+ .join(",");
393
+ const orderFieldsKey = orderByIndexFields
394
+ .map((field) => formatIndexKey(field.name, orderDirectionMap))
395
+ .join(",");
396
+ const indexKey = `${bucket}|${scope}|${collection}|fields:${indexFieldsKey}|order:${orderFieldsKey}`;
354
397
  const hash = generateHash(indexKey);
355
398
  const indexName = `adv_covered_${hash}`;
356
399
 
@@ -366,5 +409,6 @@ module.exports = {
366
409
  extractSelectFields,
367
410
  extractGroupByFields,
368
411
  extractOrderByFields,
412
+ extractOrderByIndexFields,
369
413
  generateHash,
370
414
  };
@@ -722,6 +722,34 @@ test("same index name for different queries with same fields", () => {
722
722
  assert.strictEqual(name1, name2);
723
723
  });
724
724
 
725
+ test("different index name for different ORDER BY direction", () => {
726
+ const query1 = "SELECT name FROM users WHERE status = 'active' ORDER BY created_at";
727
+ const query2 = "SELECT name FROM users WHERE status = 'active' ORDER BY created_at DESC";
728
+
729
+ const result1 = buildCoveringIndex(query1, defaultOptions);
730
+ const result2 = buildCoveringIndex(query2, defaultOptions);
731
+
732
+ const name1 = result1.match(/CREATE INDEX `(adv_covered_\w+)`/)[1];
733
+ const name2 = result2.match(/CREATE INDEX `(adv_covered_\w+)`/)[1];
734
+
735
+ assert.notStrictEqual(name1, name2);
736
+ });
737
+
738
+ test("different index name for different multiple ORDER BY field order", () => {
739
+ const query1 =
740
+ "SELECT email FROM users WHERE status = 'active' ORDER BY last_name ASC, created_at DESC";
741
+ const query2 =
742
+ "SELECT email FROM users WHERE status = 'active' ORDER BY created_at DESC, last_name ASC";
743
+
744
+ const result1 = buildCoveringIndex(query1, defaultOptions);
745
+ const result2 = buildCoveringIndex(query2, defaultOptions);
746
+
747
+ const name1 = result1.match(/CREATE INDEX `(adv_covered_\w+)`/)[1];
748
+ const name2 = result2.match(/CREATE INDEX `(adv_covered_\w+)`/)[1];
749
+
750
+ assert.notStrictEqual(name1, name2);
751
+ });
752
+
725
753
  test("different index name for queries with different fields", () => {
726
754
  const query1 = "SELECT name FROM users WHERE status = 'active'";
727
755
  const query2 = "SELECT email FROM users WHERE type = 'admin'";
@@ -814,7 +842,7 @@ test("includes ORDER BY fields in index", () => {
814
842
  assert.ok(result !== null);
815
843
  assert.ok(result.includes("`deleted` INCLUDE MISSING"));
816
844
  assert.ok(result.includes("`status`"));
817
- assert.ok(result.includes("`created_at`"));
845
+ assert.ok(result.includes("`created_at` DESC"));
818
846
  assert.ok(result.includes("`name`"));
819
847
  assert.ok(result.includes("`email`"));
820
848
  });
@@ -841,6 +869,7 @@ test("deduplicates ORDER BY field if already in WHERE", () => {
841
869
  const firstCreatedAt = result.indexOf("`created_at`");
842
870
  const lastCreatedAt = result.lastIndexOf("`created_at`");
843
871
  assert.strictEqual(firstCreatedAt, lastCreatedAt);
872
+ assert.ok(result.includes("`created_at` DESC"));
844
873
  });
845
874
 
846
875
  test("deduplicates ORDER BY field if already in SELECT", () => {
@@ -850,6 +879,7 @@ test("deduplicates ORDER BY field if already in SELECT", () => {
850
879
  // created_at should only appear once (in ORDER BY position, before SELECT fields)
851
880
  const matches = result.match(/`created_at`/g);
852
881
  assert.strictEqual(matches.length, 1);
882
+ assert.ok(result.includes("`created_at` DESC"));
853
883
  });
854
884
 
855
885
  test("handles multiple ORDER BY fields", () => {
@@ -868,6 +898,15 @@ test("handles multiple ORDER BY fields", () => {
868
898
  assert.ok(firstNameIndex < emailIndex);
869
899
  });
870
900
 
901
+ test("handles mixed ORDER BY directions in index", () => {
902
+ const query = "SELECT email FROM users WHERE status = 'active' ORDER BY last_name ASC, created_at DESC";
903
+ const result = buildCoveringIndex(query, defaultOptions);
904
+
905
+ assert.ok(result.includes("`last_name`"));
906
+ assert.ok(!result.includes("`last_name` ASC"));
907
+ assert.ok(result.includes("`created_at` DESC"));
908
+ });
909
+
871
910
  test("handles realistic query with ORDER BY", () => {
872
911
  const query = `SELECT first_name, last_name, email, status
873
912
  FROM users
@@ -879,7 +918,7 @@ test("handles realistic query with ORDER BY", () => {
879
918
  assert.ok(result !== null);
880
919
  assert.ok(result.includes("`deleted` INCLUDE MISSING"));
881
920
  assert.ok(result.includes("`status`"));
882
- assert.ok(result.includes("`created_at`"));
921
+ assert.ok(result.includes("`created_at` DESC"));
883
922
  assert.ok(result.includes("`first_name`"));
884
923
  assert.ok(result.includes("`last_name`"));
885
924
  assert.ok(result.includes("`email`"));