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/build/index.auto-optimize.test.js +578 -0
- package/build/index.js +153 -13
- package/build/utility.js +156 -48
- package/build/utility.test.js +53 -5
- package/package.json +2 -1
- package/src/index.auto-optimize.test.js +393 -0
- package/src/index.js +165 -8
- package/src/utility.js +165 -62
- package/src/utility.test.js +73 -4
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
|
|
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
|
|
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
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
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
|
-
|
|
357
|
-
if (addedFields.has(lower) || lower === "deleted") {
|
|
340
|
+
if (addedFields.has(f) || f === "deleted") {
|
|
358
341
|
return false;
|
|
359
342
|
}
|
|
360
|
-
addedFields.add(
|
|
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
|
-
|
|
367
|
-
if (addedFields.has(lower) || lower === "deleted") {
|
|
349
|
+
if (addedFields.has(f) || f === "deleted") {
|
|
368
350
|
return false;
|
|
369
351
|
}
|
|
370
|
-
addedFields.add(
|
|
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 =
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
//
|
|
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
|
|
381
|
+
const keys = [
|
|
399
382
|
"`deleted` INCLUDE MISSING",
|
|
400
383
|
...allFields.map((f) => formatIndexField(f, orderDirectionMap)),
|
|
401
|
-
]
|
|
402
|
-
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
const
|
|
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 = `
|
|
390
|
+
const indexName = `adv_covered_${hash}`;
|
|
416
391
|
|
|
417
392
|
// Build the CREATE INDEX statement
|
|
418
|
-
const
|
|
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
|
|
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,
|
package/src/utility.test.js
CHANGED
|
@@ -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("
|
|
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.
|
|
607
|
+
assert.ok(result.includes("`test`.`_default`.`users`"));
|
|
606
608
|
});
|
|
607
609
|
|
|
608
|
-
test("
|
|
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.
|
|
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
|
// ============================================
|