spice-couchbase 2.0.60 → 2.0.62

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 CHANGED
@@ -245,22 +245,17 @@ function buildOrderDirectionMap(orderByIndexFields) {
245
245
  const directionMap = new Map();
246
246
  for (const { name, direction } of orderByIndexFields) {
247
247
  if (direction === "DESC") {
248
- directionMap.set(name.toLowerCase(), direction);
248
+ directionMap.set(name, direction);
249
249
  }
250
250
  }
251
251
  return directionMap;
252
252
  }
253
253
 
254
254
  function formatIndexField(field, orderDirectionMap) {
255
- const direction = orderDirectionMap.get(field.toLowerCase());
255
+ const direction = orderDirectionMap.get(field);
256
256
  return `\`${field}\`${direction ? ` ${direction}` : ""}`;
257
257
  }
258
258
 
259
- function formatIndexKey(field, orderDirectionMap) {
260
- const direction = orderDirectionMap.get(field.toLowerCase());
261
- return `${field}${direction ? ` ${direction}` : ""}`;
262
- }
263
-
264
259
  /**
265
260
  * Splits SELECT clause by commas, respecting parentheses
266
261
  * @param {string} clause - The SELECT clause content
@@ -293,23 +288,8 @@ function splitSelectClause(clause) {
293
288
  return parts;
294
289
  }
295
290
 
296
- function hasQualifiedWildcardProjection(query) {
297
- const selectMatch = query.match(/\bSELECT\b(.+?)\bFROM\b/is);
298
- if (!selectMatch) {
299
- return false;
300
- }
301
-
302
- return splitSelectClause(selectMatch[1].trim()).some((part) => {
303
- const trimmedPart = part.trim();
304
- return (
305
- /^\(\s*`?\w+`?\s*\)\s*\.\s*\*$/.test(trimmedPart) ||
306
- /^`?\w+`?\s*\.\s*\*$/.test(trimmedPart)
307
- );
308
- });
309
- }
310
-
311
291
  /**
312
- * Builds a covering or fallback non-covering index CREATE statement from a query
292
+ * Builds a covering index CREATE statement from a query
313
293
  * @param {string} query - The N1QL query to analyze
314
294
  * @param {Object} options - Configuration options
315
295
  * @param {string} options.bucket - The bucket name
@@ -318,6 +298,12 @@ function hasQualifiedWildcardProjection(query) {
318
298
  * @returns {string|null} - The CREATE INDEX statement, or null if cannot build
319
299
  */
320
300
  function buildCoveringIndex(query, options) {
301
+ return buildCoveringIndexDefinition(query, options)?.statement ?? null;
302
+ }
303
+
304
+ // Keep the access keys separate from projection-only keys so an older, wider
305
+ // index can satisfy the same request without depending on its generated name.
306
+ function buildCoveringIndexDefinition(query, options) {
321
307
  const { bucket, scope = "_default", collection = "_default" } = options;
322
308
 
323
309
  if (!bucket) {
@@ -326,9 +312,9 @@ function buildCoveringIndex(query, options) {
326
312
 
327
313
  // Extract fields from SELECT clause
328
314
  const selectFields = extractSelectFields(query);
329
- const isNonCoveringFallback =
330
- selectFields === null && hasQualifiedWildcardProjection(query);
331
- if (selectFields === null && !isNonCoveringFallback) {
315
+ if (selectFields === null) {
316
+ // Full-document projections cannot be covered by the extracted fields.
317
+ // Skip them instead of creating a non-covering fallback index.
332
318
  return null;
333
319
  }
334
320
 
@@ -344,44 +330,41 @@ function buildCoveringIndex(query, options) {
344
330
  const orderByFields = orderByIndexFields.map((field) => field.name);
345
331
 
346
332
  // Remove 'deleted' from whereFields as it will be added first with INCLUDE MISSING
347
- const filteredWhereFields = whereFields.filter(
348
- (f) => f.toLowerCase() !== "deleted",
349
- );
333
+ const filteredWhereFields = whereFields.filter((f) => f !== "deleted");
350
334
 
351
335
  // Track fields already added to avoid duplicates
352
- const addedFields = new Set(filteredWhereFields.map((f) => f.toLowerCase()));
336
+ const addedFields = new Set(filteredWhereFields);
353
337
 
354
338
  // Filter GROUP BY fields (remove duplicates from WHERE, remove deleted)
355
339
  const filteredGroupByFields = groupByFields.filter((f) => {
356
- const lower = f.toLowerCase();
357
- if (addedFields.has(lower) || lower === "deleted") {
340
+ if (addedFields.has(f) || f === "deleted") {
358
341
  return false;
359
342
  }
360
- addedFields.add(lower);
343
+ addedFields.add(f);
361
344
  return true;
362
345
  });
363
346
 
364
347
  // Filter ORDER BY fields (remove duplicates from WHERE and GROUP BY, remove deleted)
365
348
  const filteredOrderByFields = orderByFields.filter((f) => {
366
- const lower = f.toLowerCase();
367
- if (addedFields.has(lower) || lower === "deleted") {
349
+ if (addedFields.has(f) || f === "deleted") {
368
350
  return false;
369
351
  }
370
- addedFields.add(lower);
352
+ addedFields.add(f);
371
353
  return true;
372
354
  });
373
355
 
374
356
  // Filter SELECT fields (remove duplicates from WHERE, GROUP BY, and ORDER BY, remove deleted)
375
- const selectFieldsFiltered = (selectFields || []).filter((f) => {
376
- const lower = f.toLowerCase();
377
- if (addedFields.has(lower) || lower === "deleted") {
378
- return false;
379
- }
380
- addedFields.add(lower);
381
- return true;
382
- });
357
+ const selectFieldsFiltered = selectFields
358
+ .filter((f) => {
359
+ if (addedFields.has(f) || f === "deleted") {
360
+ return false;
361
+ }
362
+ addedFields.add(f);
363
+ return true;
364
+ })
365
+ .sort();
383
366
 
384
- // All fields for the index: WHERE -> GROUP BY -> ORDER BY -> SELECT
367
+ // Projection order has no effect on coverage. Keep access-key order intact.
385
368
  const allFields = [
386
369
  ...filteredWhereFields,
387
370
  ...filteredGroupByFields,
@@ -395,33 +378,153 @@ function buildCoveringIndex(query, options) {
395
378
  }
396
379
 
397
380
  // Build the field list with deleted INCLUDE MISSING first
398
- const fieldList = [
381
+ const keys = [
399
382
  "`deleted` INCLUDE MISSING",
400
383
  ...allFields.map((f) => formatIndexField(f, orderDirectionMap)),
401
- ].join(", ");
402
-
403
- // Generate unique index name based on bucket, scope, collection, and fields
404
- // This ensures multiple queries covered by the same index get the same hash
405
- const indexFieldsKey = allFields
406
- .map((field) => formatIndexKey(field, orderDirectionMap))
407
- .sort()
408
- .join(",");
409
- const orderFieldsKey = orderByIndexFields
410
- .map((field) => formatIndexKey(field.name, orderDirectionMap))
411
- .join(",");
412
- const indexMode = isNonCoveringFallback ? "noncovered" : "covered";
413
- const indexKey = `${bucket}|${scope}|${collection}|mode:${indexMode}|fields:${indexFieldsKey}|order:${orderFieldsKey}`;
384
+ ];
385
+
386
+ // Hash the actual ordered definition, not query syntax. In particular, an
387
+ // explicit ORDER BY ... ASC must not rename an otherwise identical index.
388
+ const indexKey = JSON.stringify([bucket, scope, collection, keys]);
414
389
  const hash = generateHash(indexKey);
415
- const indexName = `adv_${indexMode}_${hash}`;
390
+ const indexName = `adv_covered_${hash}`;
416
391
 
417
392
  // Build the CREATE INDEX statement
418
- const indexStatement = `CREATE INDEX \`${indexName}\` ON \`default\`:\`${bucket}\`.\`${scope}\`.\`${collection}\`(${fieldList})`;
393
+ const quote = (identifier) => `\`${identifier.replace(/`/g, "``")}\``;
394
+ const indexStatement = `CREATE INDEX \`${indexName}\` ON \`default\`:${quote(bucket)}.${quote(scope)}.${quote(collection)}(${keys.join(", ")})`;
395
+
396
+ return {
397
+ name: indexName,
398
+ statement: indexStatement,
399
+ keys,
400
+ accessKeyCount:
401
+ 1 + filteredWhereFields.length + filteredGroupByFields.length + filteredOrderByFields.length,
402
+ bucket,
403
+ scope,
404
+ collection,
405
+ };
406
+ }
407
+
408
+ // Only compare simple scalar keys. Do not treat expressions, array indexes,
409
+ // partial indexes, or case-distinct document fields as equivalent.
410
+ function normalizeIndexKey(key) {
411
+ if (typeof key !== "string") return null;
412
+ const match = key.trim().match(
413
+ /^(\(*\s*(?:`(?:[^`]|``)+`|[A-Za-z_]\w*)\s*\)*)(.*?)$/s,
414
+ );
415
+ if (!match) return null;
416
+ let expression = match[1].trim();
417
+ while (expression.startsWith("(") && expression.endsWith(")")) {
418
+ expression = expression.slice(1, -1).trim();
419
+ }
420
+ const identifier = expression.match(/^(?:`((?:[^`]|``)+)`|([A-Za-z_]\w*))$/);
421
+ if (!identifier) return null;
422
+ const attributes = match[2].trim().toUpperCase();
423
+ if (!/^(?:(?:ASC|DESC)(?:\s+INCLUDE\s+MISSING)?|INCLUDE\s+MISSING(?:\s+(?:ASC|DESC))?)?$/.test(attributes)) {
424
+ return null;
425
+ }
426
+ return {
427
+ field: identifier[1] !== undefined
428
+ ? identifier[1].replace(/``/g, "`")
429
+ : identifier[2],
430
+ direction: /\bDESC\b/.test(attributes) ? "DESC" : "ASC",
431
+ includeMissing: /\bINCLUDE\s+MISSING\b/.test(attributes),
432
+ };
433
+ }
434
+
435
+ // Call with indexes from the candidate's exact bucket/scope/collection. State
436
+ // is deliberately not filtered: a matching deferred/building index is already
437
+ // committed work, even though EXPLAIN cannot use it yet.
438
+ function indexSatisfiesCoveringDefinition(index, candidate) {
439
+ if (
440
+ index.is_primary || index.condition || index.using !== "gsi" ||
441
+ !Array.isArray(index.index_key)
442
+ ) {
443
+ return false;
444
+ }
445
+ const existingKeys = index.index_key.map(normalizeIndexKey);
446
+ const requiredKeys = candidate.keys.map(normalizeIndexKey);
447
+ if (existingKeys.some((key) => !key) || requiredKeys.some((key) => !key)) return false;
448
+ for (let i = 0; i < candidate.accessKeyCount; i++) {
449
+ const existing = existingKeys[i];
450
+ const required = requiredKeys[i];
451
+ if (
452
+ !existing || existing.field !== required.field ||
453
+ existing.direction !== required.direction ||
454
+ existing.includeMissing !== required.includeMissing
455
+ ) return false;
456
+ }
457
+ const existingFields = new Set(existingKeys.map((key) => key.field));
458
+ return requiredKeys.slice(candidate.accessKeyCount).every((key) => existingFields.has(key.field));
459
+ }
460
+
461
+ /**
462
+ * Normalizes an exemption list into a Set of lower-case resource names.
463
+ * Accepts an array, a Set, or a comma-separated string (the env var form).
464
+ * Surrounding whitespace and backticks are ignored; empty entries are dropped.
465
+ * @param {string|string[]|Set<string>} value
466
+ * @returns {Set<string>}
467
+ */
468
+ function parseResourceList(value) {
469
+ const names = new Set();
470
+ let items = [];
471
+ if (Array.isArray(value) || value instanceof Set) {
472
+ items = [...value];
473
+ } else if (typeof value === "string") {
474
+ items = value.split(",");
475
+ }
476
+ for (const item of items) {
477
+ if (typeof item !== "string") continue;
478
+ const name = item.trim().replace(/^`+|`+$/g, "").trim().toLowerCase();
479
+ if (name) names.add(name);
480
+ }
481
+ return names;
482
+ }
483
+
484
+ /**
485
+ * Extracts the resource names a statement touches, lower-cased:
486
+ * - the last path segment of every FROM / JOIN keyspace, unquoted, so
487
+ * `default`:`bucket`.`scope`.`users` and users both yield "users";
488
+ * - every `type = '...'` literal, which is how legacy bucket-mode
489
+ * deployments distinguish resources that share one keyspace.
490
+ * Only simple identifiers are recognised; subqueries and expressions are skipped.
491
+ * @param {string} query
492
+ * @returns {Set<string>}
493
+ */
494
+ function extractStatementResources(query) {
495
+ const names = new Set();
496
+ if (typeof query !== "string") return names;
497
+
498
+ const identifier = "(?:`(?:[^`]|``)+`|[A-Za-z_][\\w$-]*)";
499
+ const keyspacePattern = new RegExp(
500
+ `\\b(?:FROM|JOIN)\\s+(${identifier}(?:\\s*[.:]\\s*${identifier})*)`,
501
+ "gi",
502
+ );
503
+ const segmentPattern = new RegExp(identifier, "g");
504
+ for (const match of query.matchAll(keyspacePattern)) {
505
+ const segments = match[1].match(segmentPattern) || [];
506
+ const last = (segments[segments.length - 1] || "").trim();
507
+ const name = last.startsWith("`")
508
+ ? last.slice(1, -1).replace(/``/g, "`")
509
+ : last;
510
+ if (name) names.add(name.toLowerCase());
511
+ }
512
+
513
+ const typePattern = /(?:`type`|\btype)\s*=\s*'((?:[^']|'')*)'/gi;
514
+ for (const match of query.matchAll(typePattern)) {
515
+ const name = match[1].replace(/''/g, "'").trim();
516
+ if (name) names.add(name.toLowerCase());
517
+ }
419
518
 
420
- return indexStatement;
519
+ return names;
421
520
  }
422
521
 
423
522
  module.exports = {
424
523
  buildCoveringIndex,
524
+ buildCoveringIndexDefinition,
525
+ indexSatisfiesCoveringDefinition,
526
+ parseResourceList,
527
+ extractStatementResources,
425
528
  extractWhereFields,
426
529
  extractSelectFields,
427
530
  extractGroupByFields,
@@ -8,6 +8,8 @@ const {
8
8
  extractGroupByFields,
9
9
  extractOrderByFields,
10
10
  generateHash,
11
+ parseResourceList,
12
+ extractStatementResources,
11
13
  } = require("./utility");
12
14
 
13
15
  // Test utilities
@@ -599,16 +601,16 @@ test("returns null when missing bucket option", () => {
599
601
  assert.strictEqual(result, null);
600
602
  });
601
603
 
602
- test("returns null when missing scope option", () => {
604
+ test("uses the default scope when scope option is missing", () => {
603
605
  const query = "SELECT name FROM users WHERE status = 'active'";
604
606
  const result = buildCoveringIndex(query, { bucket: "test", collection: "users" });
605
- assert.strictEqual(result, null);
607
+ assert.ok(result.includes("`test`.`_default`.`users`"));
606
608
  });
607
609
 
608
- test("returns null when missing collection option", () => {
610
+ test("uses the default collection when collection option is missing", () => {
609
611
  const query = "SELECT name FROM users WHERE status = 'active'";
610
612
  const result = buildCoveringIndex(query, { bucket: "test", scope: "scope" });
611
- assert.strictEqual(result, null);
613
+ assert.ok(result.includes("`test`.`scope`.`_default`"));
612
614
  });
613
615
 
614
616
  test("generates unique index name from query hash", () => {
@@ -1046,6 +1048,73 @@ test("field order: WHERE -> GROUP BY -> ORDER BY -> SELECT", () => {
1046
1048
  assert.ok(createdAtIndex < emailIndex); // ORDER BY before SELECT
1047
1049
  });
1048
1050
 
1051
+ // ============================================
1052
+ // Tests for parseResourceList / extractStatementResources
1053
+ // ============================================
1054
+ console.log("\n--- parseResourceList tests ---");
1055
+
1056
+ test("parses a comma-separated string, trimming, unquoting and lower-casing", () => {
1057
+ const result = parseResourceList(" Users, `Audit_Log` ,, sessions ");
1058
+ assert.deepStrictEqual([...result].sort(), ["audit_log", "sessions", "users"]);
1059
+ });
1060
+
1061
+ test("accepts arrays and Sets and ignores non-strings", () => {
1062
+ assert.deepStrictEqual([...parseResourceList(["Users", 42, null, " logs "])].sort(), ["logs", "users"]);
1063
+ assert.deepStrictEqual([...parseResourceList(new Set(["A", "b"]))].sort(), ["a", "b"]);
1064
+ });
1065
+
1066
+ test("returns an empty set for undefined, empty or unsupported input", () => {
1067
+ assert.strictEqual(parseResourceList(undefined).size, 0);
1068
+ assert.strictEqual(parseResourceList("").size, 0);
1069
+ assert.strictEqual(parseResourceList(", ,").size, 0);
1070
+ assert.strictEqual(parseResourceList({ users: true }).size, 0);
1071
+ });
1072
+
1073
+ console.log("\n--- extractStatementResources tests ---");
1074
+
1075
+ test("extracts a bare FROM keyspace", () => {
1076
+ assert.deepStrictEqual([...extractStatementResources("SELECT name FROM users WHERE x = 1")], ["users"]);
1077
+ });
1078
+
1079
+ test("extracts the last segment of a qualified or quoted keyspace", () => {
1080
+ assert.deepStrictEqual(
1081
+ [...extractStatementResources("SELECT name FROM `default`:`saturn`.`app`.`Users` AS u")],
1082
+ ["users"],
1083
+ );
1084
+ assert.deepStrictEqual(
1085
+ [...extractStatementResources("SELECT name FROM saturn.app.logs")],
1086
+ ["logs"],
1087
+ );
1088
+ });
1089
+
1090
+ test("extracts JOIN keyspaces alongside FROM", () => {
1091
+ const result = extractStatementResources(
1092
+ "SELECT u.name FROM users AS u JOIN `orders` AS o ON o.user_id = META(u).id",
1093
+ );
1094
+ assert.deepStrictEqual([...result].sort(), ["orders", "users"]);
1095
+ });
1096
+
1097
+ test("extracts type literals used by legacy bucket mode", () => {
1098
+ const result = extractStatementResources(
1099
+ "SELECT actor FROM `saturn` WHERE type = 'Audit_Log' AND deleted = false",
1100
+ );
1101
+ assert.deepStrictEqual([...result].sort(), ["audit_log", "saturn"]);
1102
+ assert.ok(extractStatementResources("SELECT a FROM b WHERE `type`='x'").has("x"));
1103
+ assert.ok(extractStatementResources("SELECT a FROM b WHERE type = 'it''s'").has("it's"));
1104
+ });
1105
+
1106
+ test("does not treat META() arguments, subqueries or column names as keyspaces", () => {
1107
+ const result = extractStatementResources(
1108
+ "SELECT META(`users`).id AS id, from_date FROM (SELECT * FROM users) AS sub WHERE status = 'from'",
1109
+ );
1110
+ assert.deepStrictEqual([...result], ["users"]);
1111
+ });
1112
+
1113
+ test("handles non-string input", () => {
1114
+ assert.strictEqual(extractStatementResources(undefined).size, 0);
1115
+ assert.strictEqual(extractStatementResources(null).size, 0);
1116
+ });
1117
+
1049
1118
  // ============================================
1050
1119
  // Summary
1051
1120
  // ============================================