spice-couchbase 2.0.61 → 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.
@@ -94,9 +94,10 @@ function createStatements(scope) {
94
94
  return statement.startsWith("CREATE INDEX ");
95
95
  });
96
96
  } // Isolate optimizer state and time without connecting to Couchbase or sleeping.
97
+ // Each load is a fresh module: its own seen-set, cooldown and policy.
97
98
 
98
99
 
99
- function createDatabase(durationMs) {
100
+ function loadDriver(durationMs) {
100
101
  var env = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
101
102
  var tick = BigInt(0);
102
103
  var module = {
@@ -117,7 +118,11 @@ function createDatabase(durationMs) {
117
118
  }
118
119
  }
119
120
  });
120
- var db = Object.create(module.exports.prototype);
121
+ return module.exports;
122
+ }
123
+
124
+ function instanceOf(Database) {
125
+ var db = Object.create(Database.prototype);
121
126
  db.bucket = "saturn";
122
127
  db.scope_name = "test";
123
128
  db.collection_name = "users";
@@ -137,6 +142,14 @@ function createDatabase(durationMs) {
137
142
  }()
138
143
  };
139
144
  return db;
145
+ } // Opted in wholesale: per-resource resolution has its own tests further down.
146
+
147
+
148
+ function createDatabase(durationMs) {
149
+ var env = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
150
+ var db = instanceOf(loadDriver(durationMs, env));
151
+ db.auto_optimize = true;
152
+ return db;
140
153
  }
141
154
 
142
155
  test("auto-optimization only starts above the default 3-second threshold",
@@ -373,4 +386,193 @@ _asyncToGenerator(function* () {
373
386
  collection: "_default"
374
387
  }));
375
388
  assert.match(createStatements(scope)[0].statement, /`saturn`\.`_default`\.`_default`/);
389
+ })); // ---- Per-resource opt-in for auto cover indexing ----
390
+ // Nothing decided at instance level: resolution falls through to the call,
391
+ // the application policy and the static lists.
392
+
393
+ function createUnconfiguredDatabase() {
394
+ var env = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
395
+ return instanceOf(loadDriver(3001, env));
396
+ }
397
+
398
+ function optimizationsFor(_x2, _x3) {
399
+ return _optimizationsFor.apply(this, arguments);
400
+ }
401
+
402
+ function _optimizationsFor() {
403
+ _optimizationsFor = _asyncToGenerator(function* (db, statement) {
404
+ var optimizations = 0;
405
+ db.optimize =
406
+ /*#__PURE__*/
407
+ _asyncToGenerator(function* () {
408
+ optimizations += 1;
409
+ });
410
+
411
+ for (var _len = arguments.length, queryArgs = new Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) {
412
+ queryArgs[_key - 2] = arguments[_key];
413
+ }
414
+
415
+ yield db.query(statement, ...queryArgs);
416
+ return optimizations;
417
+ });
418
+ return _optimizationsFor.apply(this, arguments);
419
+ }
420
+
421
+ var slowLookup = "SELECT name FROM users WHERE status = 'active'";
422
+ var EVERY_RESOURCE = {
423
+ SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "*"
424
+ };
425
+ test("no resource is auto-indexed unless something opts it in",
426
+ /*#__PURE__*/
427
+ _asyncToGenerator(function* () {
428
+ assert.equal((yield optimizationsFor(createUnconfiguredDatabase(), slowLookup)), 0);
429
+ assert.equal((yield optimizationsFor(createUnconfiguredDatabase({
430
+ SPICE_COUCHBASE_OPTIMIZE_RESOURCES: ""
431
+ }), slowLookup)), 0);
432
+ }));
433
+ test("SPICE_COUCHBASE_OPTIMIZE_RESOURCES enables the instance's collection, case-insensitively",
434
+ /*#__PURE__*/
435
+ _asyncToGenerator(function* () {
436
+ var env = {
437
+ SPICE_COUCHBASE_OPTIMIZE_RESOURCES: " Users ,audit_log,, `sessions` "
438
+ };
439
+ assert.equal((yield optimizationsFor(createUnconfiguredDatabase(env), slowLookup)), 1);
440
+ var sessions = createUnconfiguredDatabase(env);
441
+ sessions.collection_name = "sessions";
442
+ assert.equal((yield optimizationsFor(sessions, "SELECT id FROM sessions")), 1);
443
+ var orders = createUnconfiguredDatabase(env);
444
+ orders.collection_name = "orders";
445
+ assert.equal((yield optimizationsFor(orders, "SELECT total FROM orders WHERE status = 'paid'")), 0);
446
+ }));
447
+ test("a wildcard restores auto indexing for every resource",
448
+ /*#__PURE__*/
449
+ _asyncToGenerator(function* () {
450
+ var db = createUnconfiguredDatabase(EVERY_RESOURCE);
451
+ db.collection_name = "anything";
452
+ assert.equal((yield optimizationsFor(db, "SELECT a FROM anything WHERE b = 1")), 1);
453
+ }));
454
+ test("legacy bucket mode resolves the resource from the type literal the driver adds",
455
+ /*#__PURE__*/
456
+ _asyncToGenerator(function* () {
457
+ var env = {
458
+ SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "audit_log"
459
+ };
460
+
461
+ for (var [type, expected] of [["audit_log", 1], ["users", 0]]) {
462
+ var db = createUnconfiguredDatabase(env);
463
+ db.collection_name = "_default";
464
+ db.scope_name = "_default";
465
+ assert.equal((yield optimizationsFor(db, "SELECT actor FROM `saturn` WHERE type = '".concat(type, "' AND deleted = false"))), expected, type);
466
+ }
467
+ }));
468
+ test("a fully qualified or aliased FROM keyspace matches on its last segment",
469
+ /*#__PURE__*/
470
+ _asyncToGenerator(function* () {
471
+ var env = {
472
+ SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "logs"
473
+ };
474
+
475
+ for (var statement of ["SELECT level FROM `default`:`saturn`.`app`.`logs` WHERE level = 'error'", "SELECT l.level FROM saturn.app.logs AS l WHERE l.level = 'error'", "SELECT u.name FROM users AS u JOIN `logs` AS l ON l.user_id = META(u).id"]) {
476
+ var db = createUnconfiguredDatabase(env);
477
+ db.collection_name = "_default";
478
+ assert.equal((yield optimizationsFor(db, statement)), 1, statement);
479
+ }
480
+ }));
481
+ test("connection or constructor lists work without the environment variable",
482
+ /*#__PURE__*/
483
+ _asyncToGenerator(function* () {
484
+ var db = createUnconfiguredDatabase();
485
+ db.enabled_resources = new Set(["users"]);
486
+ assert.equal((yield optimizationsFor(db, slowLookup)), 1);
487
+ }));
488
+ test("an explicit instance setting overrides every resource list",
489
+ /*#__PURE__*/
490
+ _asyncToGenerator(function* () {
491
+ var on = createUnconfiguredDatabase();
492
+ on.auto_optimize = true;
493
+ assert.equal((yield optimizationsFor(on, slowLookup)), 1);
494
+ var off = createUnconfiguredDatabase(EVERY_RESOURCE);
495
+ off.auto_optimize = false;
496
+ assert.equal((yield optimizationsFor(off, slowLookup)), 0);
497
+ }));
498
+ test("a single call can opt in or out through query options",
499
+ /*#__PURE__*/
500
+ _asyncToGenerator(function* () {
501
+ assert.equal((yield optimizationsFor(createUnconfiguredDatabase(), slowLookup, {
502
+ auto_optimize: true
503
+ })), 1);
504
+ assert.equal((yield optimizationsFor(createUnconfiguredDatabase(EVERY_RESOURCE), slowLookup, {
505
+ auto_optimize: false
506
+ })), 0);
507
+ assert.equal((yield optimizationsFor(createUnconfiguredDatabase(EVERY_RESOURCE), slowLookup, "cluster", {
508
+ optimize: false
509
+ })), 0); // The scan-consistency shorthand is not an opt-in or opt-out.
510
+
511
+ assert.equal((yield optimizationsFor(createUnconfiguredDatabase(EVERY_RESOURCE), slowLookup, "cluster", true)), 1);
512
+ assert.equal((yield optimizationsFor(createUnconfiguredDatabase(), slowLookup, "cluster", true)), 0);
513
+ }));
514
+ test("an application policy decides per resource and sees the statement context",
515
+ /*#__PURE__*/
516
+ _asyncToGenerator(function* () {
517
+ var Database = loadDriver(3001);
518
+ var seen = [];
519
+ Database.setAutoOptimizePolicy((resource, context) => {
520
+ seen.push([resource, context]);
521
+ return resource === "users";
522
+ });
523
+ assert.equal(Database.getAutoOptimizePolicy().length, 2);
524
+ var users = instanceOf(Database);
525
+ users.connection_name = "default";
526
+ assert.equal((yield optimizationsFor(users, slowLookup)), 1); // Objects cross the vm boundary, so compare by value rather than prototype.
527
+
528
+ assert.equal(JSON.stringify(seen[0]), JSON.stringify(["users", {
529
+ query: slowLookup,
530
+ connection: "default",
531
+ collection: "users"
532
+ }]));
533
+ var orders = instanceOf(Database);
534
+ orders.collection_name = "orders";
535
+ assert.equal((yield optimizationsFor(orders, "SELECT total FROM orders WHERE status = 'paid'")), 0);
536
+ }));
537
+ test("a policy that returns undefined defers to the static lists",
538
+ /*#__PURE__*/
539
+ _asyncToGenerator(function* () {
540
+ for (var [env, expected] of [[{
541
+ SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "users"
542
+ }, 1], [{}, 0]]) {
543
+ var Database = loadDriver(3001, env);
544
+ Database.setAutoOptimizePolicy(() => undefined);
545
+ assert.equal((yield optimizationsFor(instanceOf(Database), slowLookup)), expected);
546
+ }
547
+ }));
548
+ test("an explicit policy refusal wins over the wildcard, and a throwing policy counts as a refusal",
549
+ /*#__PURE__*/
550
+ _asyncToGenerator(function* () {
551
+ for (var policy of [() => false, () => {
552
+ throw new Error("settings unavailable");
553
+ }]) {
554
+ var Database = loadDriver(3001, EVERY_RESOURCE);
555
+ Database.setAutoOptimizePolicy(policy);
556
+ assert.equal((yield optimizationsFor(instanceOf(Database), slowLookup)), 0);
557
+ }
558
+ }));
559
+ test("the policy can be cleared and must be a function",
560
+ /*#__PURE__*/
561
+ _asyncToGenerator(function* () {
562
+ var Database = loadDriver(3001, EVERY_RESOURCE);
563
+ Database.setAutoOptimizePolicy(() => false);
564
+ Database.setAutoOptimizePolicy(null);
565
+ assert.equal(Database.getAutoOptimizePolicy(), null);
566
+ assert.equal((yield optimizationsFor(instanceOf(Database), slowLookup)), 1);
567
+ assert.throws(() => Database.setAutoOptimizePolicy("users"), {
568
+ name: "TypeError"
569
+ });
570
+ }));
571
+ test("a declined statement is not remembered as already optimised",
572
+ /*#__PURE__*/
573
+ _asyncToGenerator(function* () {
574
+ var db = createUnconfiguredDatabase();
575
+ assert.equal((yield optimizationsFor(db, slowLookup)), 0);
576
+ db.auto_optimize = true;
577
+ assert.equal((yield optimizationsFor(db, slowLookup)), 1);
376
578
  }));
package/build/index.js CHANGED
@@ -24,6 +24,11 @@ var {
24
24
  QueryScanConsistency
25
25
  } = require("couchbase");
26
26
 
27
+ var {
28
+ parseResourceList,
29
+ extractStatementResources
30
+ } = require("./utility");
31
+
27
32
  if (!Promise.allSettled) {
28
33
  Promise.allSettled = promises => Promise.all(promises.map((promise, i) => promise.then(value => ({
29
34
  status: "fulfilled",
@@ -52,7 +57,35 @@ var optimizeState = {
52
57
  inFlight: 0,
53
58
  seen: new Set(),
54
59
  cooldownUntil: 0
55
- };
60
+ }; // Auto cover indexing is opt-in per resource. A resource is the collection a
61
+ // Database instance serves (scoped deployments) or the `type = '...'` literal
62
+ // legacy bucket mode adds to every statement; raw statements also count their
63
+ // FROM / JOIN keyspaces. Names are case-insensitive. A slow statement is
64
+ // optimised when, in order of precedence:
65
+ // 1. the call says so: query_options.auto_optimize (true / false)
66
+ // 2. the instance says so: constructor option or connection config
67
+ // `auto_optimize` (true / false)
68
+ // 3. the application policy: Database.setAutoOptimizePolicy(fn), where
69
+ // fn(resource, context) returns true / false, or
70
+ // undefined to defer to the static lists
71
+ // 4. a static list names a resource it touches: comma-separated
72
+ // SPICE_COUCHBASE_OPTIMIZE_RESOURCES (`*` = every resource, which is the
73
+ // pre-2.0.62 behaviour), or connection config / constructor option
74
+ // `auto_optimize_resources`.
75
+
76
+ var OPTIMIZE_ENABLED_RESOURCES = parseResourceList(process.env.SPICE_COUCHBASE_OPTIMIZE_RESOURCES);
77
+ var autoOptimizePolicy = null;
78
+
79
+ function requestedAutoOptimize(query_options) {
80
+ var _query_options$auto_o;
81
+
82
+ if (!query_options || typeof query_options !== "object" || Array.isArray(query_options)) {
83
+ return undefined;
84
+ }
85
+
86
+ var value = (_query_options$auto_o = query_options.auto_optimize) !== null && _query_options$auto_o !== void 0 ? _query_options$auto_o : query_options.optimize;
87
+ return typeof value === "boolean" ? value : undefined;
88
+ }
56
89
 
57
90
  function shouldOptimize(query) {
58
91
  if (!AUTO_OPTIMIZE_ENABLED) return false;
@@ -124,7 +157,9 @@ module.exports = class Database {
124
157
  collection,
125
158
  scope,
126
159
  query_scope = "cluster",
127
- auto_create_collection
160
+ auto_create_collection,
161
+ auto_optimize,
162
+ auto_optimize_resources
128
163
  } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
129
164
 
130
165
  if (connection) {
@@ -163,8 +198,93 @@ module.exports = class Database {
163
198
  this.upsertCollection();
164
199
  }
165
200
 
166
- this.use_bucket_name = spice.config.database.connections[this.connection_name].scope == undefined;
201
+ this.use_bucket_name = spice.config.database.connections[this.connection_name].scope == undefined; // Auto cover indexing. See OPTIMIZE_ENABLED_RESOURCES for precedence.
202
+
203
+ var configured_auto_optimize = auto_optimize !== null && auto_optimize !== void 0 ? auto_optimize : this.db_config.auto_optimize;
204
+ this.auto_optimize = typeof configured_auto_optimize === "boolean" ? configured_auto_optimize : undefined;
205
+ this.enabled_resources = parseResourceList([...parseResourceList(this.db_config.auto_optimize_resources), ...parseResourceList(auto_optimize_resources)]);
206
+ }
207
+ }
208
+ /**
209
+ * Lets the application decide per resource whether slow statements may
210
+ * create covering indexes, e.g. from a per-resource setting an operator
211
+ * toggles at runtime. `policy(resource, { query, connection, collection })`
212
+ * returns true or false, or undefined to defer to the static lists. It is
213
+ * synchronous because it runs on the query path. Pass null to clear.
214
+ */
215
+
216
+
217
+ static setAutoOptimizePolicy(policy) {
218
+ if (policy !== null && policy !== undefined && typeof policy !== "function") {
219
+ throw new TypeError("Auto-optimize policy must be a function or null");
220
+ }
221
+
222
+ autoOptimizePolicy = policy || null;
223
+ }
224
+
225
+ static getAutoOptimizePolicy() {
226
+ return autoOptimizePolicy;
227
+ }
228
+ /**
229
+ * Resources a statement touches, plus this instance's collection: that is
230
+ * the keyspace `optimize()` would create the index on.
231
+ */
232
+
233
+
234
+ statementResources(query) {
235
+ var resources = extractStatementResources(query);
236
+
237
+ if (this.collection_name && this.collection_name !== "_default") {
238
+ resources.add(String(this.collection_name).toLowerCase());
239
+ }
240
+
241
+ return resources;
242
+ }
243
+ /**
244
+ * True when a slow statement may create a covering index. See
245
+ * OPTIMIZE_ENABLED_RESOURCES for the order of precedence.
246
+ */
247
+
248
+
249
+ shouldAutoOptimize(query) {
250
+ var query_options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
251
+ var requested = requestedAutoOptimize(query_options);
252
+ if (requested !== undefined) return requested;
253
+ if (typeof this.auto_optimize === "boolean") return this.auto_optimize;
254
+ var resources = this.statementResources(query);
255
+
256
+ if (typeof autoOptimizePolicy === "function" && resources.size > 0) {
257
+ var deferred = false;
258
+
259
+ for (var resource of resources) {
260
+ var verdict = void 0;
261
+
262
+ try {
263
+ verdict = autoOptimizePolicy(resource, {
264
+ query,
265
+ connection: this.connection_name,
266
+ collection: this.collection_name
267
+ });
268
+ } catch (e) {
269
+ // A broken policy must not create indexes nobody asked for.
270
+ verdict = false;
271
+ }
272
+
273
+ if (verdict === true) return true;
274
+ if (verdict === undefined || verdict === null) deferred = true;
275
+ }
276
+
277
+ if (!deferred) return false;
167
278
  }
279
+
280
+ var enabled = new Set([...OPTIMIZE_ENABLED_RESOURCES, ...(this.enabled_resources || [])]);
281
+ if (enabled.has("*")) return true;
282
+
283
+ for (var _resource of resources) {
284
+ if (enabled.has(_resource)) return true;
285
+ }
286
+
287
+ return false;
168
288
  }
169
289
 
170
290
  setGlobalConnectionObject(collection) {
@@ -993,7 +1113,7 @@ module.exports = class Database {
993
1113
  var durationInMilliseconds = (endTime - startTime) / BigInt(1000000); // Optimise slow statements in the background, subject to the guard rails
994
1114
  // declared at the top of this file. Never awaited, never allowed to reject.
995
1115
 
996
- if (durationInMilliseconds > SLOW_QUERY_MS && shouldOptimize(sanitizedQuery)) {
1116
+ if (durationInMilliseconds > SLOW_QUERY_MS && _this20.shouldAutoOptimize(sanitizedQuery, query_options) && shouldOptimize(sanitizedQuery)) {
997
1117
  _this20.optimize(sanitizedQuery, exec_scope).catch(() => {});
998
1118
  }
999
1119
 
package/build/utility.js CHANGED
@@ -435,11 +435,76 @@ function indexSatisfiesCoveringDefinition(index, candidate) {
435
435
  var existingFields = new Set(existingKeys.map(key => key.field));
436
436
  return requiredKeys.slice(candidate.accessKeyCount).every(key => existingFields.has(key.field));
437
437
  }
438
+ /**
439
+ * Normalizes an exemption list into a Set of lower-case resource names.
440
+ * Accepts an array, a Set, or a comma-separated string (the env var form).
441
+ * Surrounding whitespace and backticks are ignored; empty entries are dropped.
442
+ * @param {string|string[]|Set<string>} value
443
+ * @returns {Set<string>}
444
+ */
445
+
446
+
447
+ function parseResourceList(value) {
448
+ var names = new Set();
449
+ var items = [];
450
+
451
+ if (Array.isArray(value) || value instanceof Set) {
452
+ items = [...value];
453
+ } else if (typeof value === "string") {
454
+ items = value.split(",");
455
+ }
456
+
457
+ for (var item of items) {
458
+ if (typeof item !== "string") continue;
459
+ var name = item.trim().replace(/^`+|`+$/g, "").trim().toLowerCase();
460
+ if (name) names.add(name);
461
+ }
462
+
463
+ return names;
464
+ }
465
+ /**
466
+ * Extracts the resource names a statement touches, lower-cased:
467
+ * - the last path segment of every FROM / JOIN keyspace, unquoted, so
468
+ * `default`:`bucket`.`scope`.`users` and users both yield "users";
469
+ * - every `type = '...'` literal, which is how legacy bucket-mode
470
+ * deployments distinguish resources that share one keyspace.
471
+ * Only simple identifiers are recognised; subqueries and expressions are skipped.
472
+ * @param {string} query
473
+ * @returns {Set<string>}
474
+ */
475
+
476
+
477
+ function extractStatementResources(query) {
478
+ var names = new Set();
479
+ if (typeof query !== "string") return names;
480
+ var identifier = "(?:`(?:[^`]|``)+`|[A-Za-z_][\\w$-]*)";
481
+ var keyspacePattern = new RegExp("\\b(?:FROM|JOIN)\\s+(".concat(identifier, "(?:\\s*[.:]\\s*").concat(identifier, ")*)"), "gi");
482
+ var segmentPattern = new RegExp(identifier, "g");
483
+
484
+ for (var match of query.matchAll(keyspacePattern)) {
485
+ var segments = match[1].match(segmentPattern) || [];
486
+ var last = (segments[segments.length - 1] || "").trim();
487
+ var name = last.startsWith("`") ? last.slice(1, -1).replace(/``/g, "`") : last;
488
+ if (name) names.add(name.toLowerCase());
489
+ }
490
+
491
+ var typePattern = /(?:`type`|\btype)\s*=\s*'((?:[^']|'')*)'/gi;
492
+
493
+ for (var _match of query.matchAll(typePattern)) {
494
+ var _name = _match[1].replace(/''/g, "'").trim();
495
+
496
+ if (_name) names.add(_name.toLowerCase());
497
+ }
498
+
499
+ return names;
500
+ }
438
501
 
439
502
  module.exports = {
440
503
  buildCoveringIndex,
441
504
  buildCoveringIndexDefinition,
442
505
  indexSatisfiesCoveringDefinition,
506
+ parseResourceList,
507
+ extractStatementResources,
443
508
  extractWhereFields,
444
509
  extractSelectFields,
445
510
  extractGroupByFields,
@@ -8,7 +8,9 @@ var {
8
8
  extractSelectFields,
9
9
  extractGroupByFields,
10
10
  extractOrderByFields,
11
- generateHash
11
+ generateHash,
12
+ parseResourceList,
13
+ extractStatementResources
12
14
  } = require("./utility"); // Test utilities
13
15
 
14
16
 
@@ -825,6 +827,52 @@ test("field order: WHERE -> GROUP BY -> ORDER BY -> SELECT", () => {
825
827
 
826
828
  assert.ok(createdAtIndex < emailIndex); // ORDER BY before SELECT
827
829
  }); // ============================================
830
+ // Tests for parseResourceList / extractStatementResources
831
+ // ============================================
832
+
833
+ console.log("\n--- parseResourceList tests ---");
834
+ test("parses a comma-separated string, trimming, unquoting and lower-casing", () => {
835
+ var result = parseResourceList(" Users, `Audit_Log` ,, sessions ");
836
+ assert.deepStrictEqual([...result].sort(), ["audit_log", "sessions", "users"]);
837
+ });
838
+ test("accepts arrays and Sets and ignores non-strings", () => {
839
+ assert.deepStrictEqual([...parseResourceList(["Users", 42, null, " logs "])].sort(), ["logs", "users"]);
840
+ assert.deepStrictEqual([...parseResourceList(new Set(["A", "b"]))].sort(), ["a", "b"]);
841
+ });
842
+ test("returns an empty set for undefined, empty or unsupported input", () => {
843
+ assert.strictEqual(parseResourceList(undefined).size, 0);
844
+ assert.strictEqual(parseResourceList("").size, 0);
845
+ assert.strictEqual(parseResourceList(", ,").size, 0);
846
+ assert.strictEqual(parseResourceList({
847
+ users: true
848
+ }).size, 0);
849
+ });
850
+ console.log("\n--- extractStatementResources tests ---");
851
+ test("extracts a bare FROM keyspace", () => {
852
+ assert.deepStrictEqual([...extractStatementResources("SELECT name FROM users WHERE x = 1")], ["users"]);
853
+ });
854
+ test("extracts the last segment of a qualified or quoted keyspace", () => {
855
+ assert.deepStrictEqual([...extractStatementResources("SELECT name FROM `default`:`saturn`.`app`.`Users` AS u")], ["users"]);
856
+ assert.deepStrictEqual([...extractStatementResources("SELECT name FROM saturn.app.logs")], ["logs"]);
857
+ });
858
+ test("extracts JOIN keyspaces alongside FROM", () => {
859
+ var result = extractStatementResources("SELECT u.name FROM users AS u JOIN `orders` AS o ON o.user_id = META(u).id");
860
+ assert.deepStrictEqual([...result].sort(), ["orders", "users"]);
861
+ });
862
+ test("extracts type literals used by legacy bucket mode", () => {
863
+ var result = extractStatementResources("SELECT actor FROM `saturn` WHERE type = 'Audit_Log' AND deleted = false");
864
+ assert.deepStrictEqual([...result].sort(), ["audit_log", "saturn"]);
865
+ assert.ok(extractStatementResources("SELECT a FROM b WHERE `type`='x'").has("x"));
866
+ assert.ok(extractStatementResources("SELECT a FROM b WHERE type = 'it''s'").has("it's"));
867
+ });
868
+ test("does not treat META() arguments, subqueries or column names as keyspaces", () => {
869
+ var result = extractStatementResources("SELECT META(`users`).id AS id, from_date FROM (SELECT * FROM users) AS sub WHERE status = 'from'");
870
+ assert.deepStrictEqual([...result], ["users"]);
871
+ });
872
+ test("handles non-string input", () => {
873
+ assert.strictEqual(extractStatementResources(undefined).size, 0);
874
+ assert.strictEqual(extractStatementResources(null).size, 0);
875
+ }); // ============================================
828
876
  // Summary
829
877
  // ============================================
830
878
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spice-couchbase",
3
- "version": "2.0.61",
3
+ "version": "2.0.62",
4
4
  "description": "",
5
5
  "main": "build/index.js",
6
6
  "scripts": {
@@ -37,7 +37,8 @@ function createStatements(scope) {
37
37
  }
38
38
 
39
39
  // Isolate optimizer state and time without connecting to Couchbase or sleeping.
40
- function createDatabase(durationMs, env = {}) {
40
+ // Each load is a fresh module: its own seen-set, cooldown and policy.
41
+ function loadDriver(durationMs, env = {}) {
41
42
  let tick = BigInt(0);
42
43
  const module = { exports: {} };
43
44
  vm.runInNewContext(fs.readFileSync(path.join(__dirname, "index.js"), "utf8"), {
@@ -55,7 +56,11 @@ function createDatabase(durationMs, env = {}) {
55
56
  },
56
57
  },
57
58
  });
58
- const db = Object.create(module.exports.prototype);
59
+ return module.exports;
60
+ }
61
+
62
+ function instanceOf(Database) {
63
+ const db = Object.create(Database.prototype);
59
64
  db.bucket = "saturn";
60
65
  db.scope_name = "test";
61
66
  db.collection_name = "users";
@@ -63,6 +68,13 @@ function createDatabase(durationMs, env = {}) {
63
68
  return db;
64
69
  }
65
70
 
71
+ // Opted in wholesale: per-resource resolution has its own tests further down.
72
+ function createDatabase(durationMs, env = {}) {
73
+ const db = instanceOf(loadDriver(durationMs, env));
74
+ db.auto_optimize = true;
75
+ return db;
76
+ }
77
+
66
78
  test("auto-optimization only starts above the default 3-second threshold", async () => {
67
79
  for (const duration of [1001, 2999, 3000, 3001]) {
68
80
  const db = createDatabase(duration);
@@ -235,3 +247,147 @@ test("a default collection uses default names in its inventory lookup", async ()
235
247
  }));
236
248
  assert.match(createStatements(scope)[0].statement, /`saturn`\.`_default`\.`_default`/);
237
249
  });
250
+
251
+ // ---- Per-resource opt-in for auto cover indexing ----
252
+
253
+ // Nothing decided at instance level: resolution falls through to the call,
254
+ // the application policy and the static lists.
255
+ function createUnconfiguredDatabase(env = {}) {
256
+ return instanceOf(loadDriver(3001, env));
257
+ }
258
+
259
+ async function optimizationsFor(db, statement, ...queryArgs) {
260
+ let optimizations = 0;
261
+ db.optimize = async () => { optimizations += 1; };
262
+ await db.query(statement, ...queryArgs);
263
+ return optimizations;
264
+ }
265
+
266
+ const slowLookup = "SELECT name FROM users WHERE status = 'active'";
267
+ const EVERY_RESOURCE = { SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "*" };
268
+
269
+ test("no resource is auto-indexed unless something opts it in", async () => {
270
+ assert.equal(await optimizationsFor(createUnconfiguredDatabase(), slowLookup), 0);
271
+ assert.equal(await optimizationsFor(
272
+ createUnconfiguredDatabase({ SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "" }), slowLookup), 0);
273
+ });
274
+
275
+ test("SPICE_COUCHBASE_OPTIMIZE_RESOURCES enables the instance's collection, case-insensitively", async () => {
276
+ const env = { SPICE_COUCHBASE_OPTIMIZE_RESOURCES: " Users ,audit_log,, `sessions` " };
277
+ assert.equal(await optimizationsFor(createUnconfiguredDatabase(env), slowLookup), 1);
278
+
279
+ const sessions = createUnconfiguredDatabase(env);
280
+ sessions.collection_name = "sessions";
281
+ assert.equal(await optimizationsFor(sessions, "SELECT id FROM sessions"), 1);
282
+
283
+ const orders = createUnconfiguredDatabase(env);
284
+ orders.collection_name = "orders";
285
+ assert.equal(await optimizationsFor(orders, "SELECT total FROM orders WHERE status = 'paid'"), 0);
286
+ });
287
+
288
+ test("a wildcard restores auto indexing for every resource", async () => {
289
+ const db = createUnconfiguredDatabase(EVERY_RESOURCE);
290
+ db.collection_name = "anything";
291
+ assert.equal(await optimizationsFor(db, "SELECT a FROM anything WHERE b = 1"), 1);
292
+ });
293
+
294
+ test("legacy bucket mode resolves the resource from the type literal the driver adds", async () => {
295
+ const env = { SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "audit_log" };
296
+ for (const [type, expected] of [["audit_log", 1], ["users", 0]]) {
297
+ const db = createUnconfiguredDatabase(env);
298
+ db.collection_name = "_default";
299
+ db.scope_name = "_default";
300
+ assert.equal(await optimizationsFor(db,
301
+ `SELECT actor FROM \`saturn\` WHERE type = '${type}' AND deleted = false`), expected, type);
302
+ }
303
+ });
304
+
305
+ test("a fully qualified or aliased FROM keyspace matches on its last segment", async () => {
306
+ const env = { SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "logs" };
307
+ for (const statement of [
308
+ "SELECT level FROM `default`:`saturn`.`app`.`logs` WHERE level = 'error'",
309
+ "SELECT l.level FROM saturn.app.logs AS l WHERE l.level = 'error'",
310
+ "SELECT u.name FROM users AS u JOIN `logs` AS l ON l.user_id = META(u).id",
311
+ ]) {
312
+ const db = createUnconfiguredDatabase(env);
313
+ db.collection_name = "_default";
314
+ assert.equal(await optimizationsFor(db, statement), 1, statement);
315
+ }
316
+ });
317
+
318
+ test("connection or constructor lists work without the environment variable", async () => {
319
+ const db = createUnconfiguredDatabase();
320
+ db.enabled_resources = new Set(["users"]);
321
+ assert.equal(await optimizationsFor(db, slowLookup), 1);
322
+ });
323
+
324
+ test("an explicit instance setting overrides every resource list", async () => {
325
+ const on = createUnconfiguredDatabase();
326
+ on.auto_optimize = true;
327
+ assert.equal(await optimizationsFor(on, slowLookup), 1);
328
+
329
+ const off = createUnconfiguredDatabase(EVERY_RESOURCE);
330
+ off.auto_optimize = false;
331
+ assert.equal(await optimizationsFor(off, slowLookup), 0);
332
+ });
333
+
334
+ test("a single call can opt in or out through query options", async () => {
335
+ assert.equal(await optimizationsFor(createUnconfiguredDatabase(), slowLookup, { auto_optimize: true }), 1);
336
+ assert.equal(await optimizationsFor(createUnconfiguredDatabase(EVERY_RESOURCE), slowLookup, { auto_optimize: false }), 0);
337
+ assert.equal(await optimizationsFor(createUnconfiguredDatabase(EVERY_RESOURCE), slowLookup, "cluster", { optimize: false }), 0);
338
+ // The scan-consistency shorthand is not an opt-in or opt-out.
339
+ assert.equal(await optimizationsFor(createUnconfiguredDatabase(EVERY_RESOURCE), slowLookup, "cluster", true), 1);
340
+ assert.equal(await optimizationsFor(createUnconfiguredDatabase(), slowLookup, "cluster", true), 0);
341
+ });
342
+
343
+ test("an application policy decides per resource and sees the statement context", async () => {
344
+ const Database = loadDriver(3001);
345
+ const seen = [];
346
+ Database.setAutoOptimizePolicy((resource, context) => {
347
+ seen.push([resource, context]);
348
+ return resource === "users";
349
+ });
350
+ assert.equal(Database.getAutoOptimizePolicy().length, 2);
351
+
352
+ const users = instanceOf(Database);
353
+ users.connection_name = "default";
354
+ assert.equal(await optimizationsFor(users, slowLookup), 1);
355
+ // Objects cross the vm boundary, so compare by value rather than prototype.
356
+ assert.equal(JSON.stringify(seen[0]), JSON.stringify(["users", { query: slowLookup, connection: "default", collection: "users" }]));
357
+
358
+ const orders = instanceOf(Database);
359
+ orders.collection_name = "orders";
360
+ assert.equal(await optimizationsFor(orders, "SELECT total FROM orders WHERE status = 'paid'"), 0);
361
+ });
362
+
363
+ test("a policy that returns undefined defers to the static lists", async () => {
364
+ for (const [env, expected] of [[{ SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "users" }, 1], [{}, 0]]) {
365
+ const Database = loadDriver(3001, env);
366
+ Database.setAutoOptimizePolicy(() => undefined);
367
+ assert.equal(await optimizationsFor(instanceOf(Database), slowLookup), expected);
368
+ }
369
+ });
370
+
371
+ test("an explicit policy refusal wins over the wildcard, and a throwing policy counts as a refusal", async () => {
372
+ for (const policy of [() => false, () => { throw new Error("settings unavailable"); }]) {
373
+ const Database = loadDriver(3001, EVERY_RESOURCE);
374
+ Database.setAutoOptimizePolicy(policy);
375
+ assert.equal(await optimizationsFor(instanceOf(Database), slowLookup), 0);
376
+ }
377
+ });
378
+
379
+ test("the policy can be cleared and must be a function", async () => {
380
+ const Database = loadDriver(3001, EVERY_RESOURCE);
381
+ Database.setAutoOptimizePolicy(() => false);
382
+ Database.setAutoOptimizePolicy(null);
383
+ assert.equal(Database.getAutoOptimizePolicy(), null);
384
+ assert.equal(await optimizationsFor(instanceOf(Database), slowLookup), 1);
385
+ assert.throws(() => Database.setAutoOptimizePolicy("users"), { name: "TypeError" });
386
+ });
387
+
388
+ test("a declined statement is not remembered as already optimised", async () => {
389
+ const db = createUnconfiguredDatabase();
390
+ assert.equal(await optimizationsFor(db, slowLookup), 0);
391
+ db.auto_optimize = true;
392
+ assert.equal(await optimizationsFor(db, slowLookup), 1);
393
+ });
package/src/index.js CHANGED
@@ -4,6 +4,7 @@ let _ = require("lodash");
4
4
  let couchbase = require("couchbase");
5
5
  const { ConflictResolutionType } = require("couchbase");
6
6
  const { QueryScanConsistency } = require("couchbase");
7
+ const { parseResourceList, extractStatementResources } = require("./utility");
7
8
 
8
9
  if (!Promise.allSettled) {
9
10
  Promise.allSettled = (promises) =>
@@ -50,6 +51,37 @@ const optimizeState = {
50
51
  seen: new Set(),
51
52
  cooldownUntil: 0,
52
53
  };
54
+ // Auto cover indexing is opt-in per resource. A resource is the collection a
55
+ // Database instance serves (scoped deployments) or the `type = '...'` literal
56
+ // legacy bucket mode adds to every statement; raw statements also count their
57
+ // FROM / JOIN keyspaces. Names are case-insensitive. A slow statement is
58
+ // optimised when, in order of precedence:
59
+ // 1. the call says so: query_options.auto_optimize (true / false)
60
+ // 2. the instance says so: constructor option or connection config
61
+ // `auto_optimize` (true / false)
62
+ // 3. the application policy: Database.setAutoOptimizePolicy(fn), where
63
+ // fn(resource, context) returns true / false, or
64
+ // undefined to defer to the static lists
65
+ // 4. a static list names a resource it touches: comma-separated
66
+ // SPICE_COUCHBASE_OPTIMIZE_RESOURCES (`*` = every resource, which is the
67
+ // pre-2.0.62 behaviour), or connection config / constructor option
68
+ // `auto_optimize_resources`.
69
+ const OPTIMIZE_ENABLED_RESOURCES = parseResourceList(
70
+ process.env.SPICE_COUCHBASE_OPTIMIZE_RESOURCES,
71
+ );
72
+ let autoOptimizePolicy = null;
73
+
74
+ function requestedAutoOptimize(query_options) {
75
+ if (
76
+ !query_options ||
77
+ typeof query_options !== "object" ||
78
+ Array.isArray(query_options)
79
+ ) {
80
+ return undefined;
81
+ }
82
+ const value = query_options.auto_optimize ?? query_options.optimize;
83
+ return typeof value === "boolean" ? value : undefined;
84
+ }
53
85
 
54
86
  function shouldOptimize(query) {
55
87
  if (!AUTO_OPTIMIZE_ENABLED) return false;
@@ -126,6 +158,8 @@ module.exports = class Database {
126
158
  scope,
127
159
  query_scope = "cluster",
128
160
  auto_create_collection,
161
+ auto_optimize,
162
+ auto_optimize_resources,
129
163
  } = {},
130
164
  ) {
131
165
  if (connection) {
@@ -181,7 +215,94 @@ module.exports = class Database {
181
215
  this.use_bucket_name =
182
216
  spice.config.database.connections[this.connection_name].scope ==
183
217
  undefined;
218
+
219
+ // Auto cover indexing. See OPTIMIZE_ENABLED_RESOURCES for precedence.
220
+ const configured_auto_optimize =
221
+ auto_optimize ?? this.db_config.auto_optimize;
222
+ this.auto_optimize =
223
+ typeof configured_auto_optimize === "boolean"
224
+ ? configured_auto_optimize
225
+ : undefined;
226
+ this.enabled_resources = parseResourceList([
227
+ ...parseResourceList(this.db_config.auto_optimize_resources),
228
+ ...parseResourceList(auto_optimize_resources),
229
+ ]);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Lets the application decide per resource whether slow statements may
235
+ * create covering indexes, e.g. from a per-resource setting an operator
236
+ * toggles at runtime. `policy(resource, { query, connection, collection })`
237
+ * returns true or false, or undefined to defer to the static lists. It is
238
+ * synchronous because it runs on the query path. Pass null to clear.
239
+ */
240
+ static setAutoOptimizePolicy(policy) {
241
+ if (
242
+ policy !== null &&
243
+ policy !== undefined &&
244
+ typeof policy !== "function"
245
+ ) {
246
+ throw new TypeError("Auto-optimize policy must be a function or null");
247
+ }
248
+ autoOptimizePolicy = policy || null;
249
+ }
250
+
251
+ static getAutoOptimizePolicy() {
252
+ return autoOptimizePolicy;
253
+ }
254
+
255
+ /**
256
+ * Resources a statement touches, plus this instance's collection: that is
257
+ * the keyspace `optimize()` would create the index on.
258
+ */
259
+ statementResources(query) {
260
+ const resources = extractStatementResources(query);
261
+ if (this.collection_name && this.collection_name !== "_default") {
262
+ resources.add(String(this.collection_name).toLowerCase());
184
263
  }
264
+ return resources;
265
+ }
266
+
267
+ /**
268
+ * True when a slow statement may create a covering index. See
269
+ * OPTIMIZE_ENABLED_RESOURCES for the order of precedence.
270
+ */
271
+ shouldAutoOptimize(query, query_options = {}) {
272
+ const requested = requestedAutoOptimize(query_options);
273
+ if (requested !== undefined) return requested;
274
+ if (typeof this.auto_optimize === "boolean") return this.auto_optimize;
275
+
276
+ const resources = this.statementResources(query);
277
+ if (typeof autoOptimizePolicy === "function" && resources.size > 0) {
278
+ let deferred = false;
279
+ for (const resource of resources) {
280
+ let verdict;
281
+ try {
282
+ verdict = autoOptimizePolicy(resource, {
283
+ query,
284
+ connection: this.connection_name,
285
+ collection: this.collection_name,
286
+ });
287
+ } catch (e) {
288
+ // A broken policy must not create indexes nobody asked for.
289
+ verdict = false;
290
+ }
291
+ if (verdict === true) return true;
292
+ if (verdict === undefined || verdict === null) deferred = true;
293
+ }
294
+ if (!deferred) return false;
295
+ }
296
+
297
+ const enabled = new Set([
298
+ ...OPTIMIZE_ENABLED_RESOURCES,
299
+ ...(this.enabled_resources || []),
300
+ ]);
301
+ if (enabled.has("*")) return true;
302
+ for (const resource of resources) {
303
+ if (enabled.has(resource)) return true;
304
+ }
305
+ return false;
185
306
  }
186
307
 
187
308
  setGlobalConnectionObject(collection) {
@@ -956,6 +1077,7 @@ module.exports = class Database {
956
1077
  // declared at the top of this file. Never awaited, never allowed to reject.
957
1078
  if (
958
1079
  durationInMilliseconds > SLOW_QUERY_MS &&
1080
+ this.shouldAutoOptimize(sanitizedQuery, query_options) &&
959
1081
  shouldOptimize(sanitizedQuery)
960
1082
  ) {
961
1083
  this.optimize(sanitizedQuery, exec_scope).catch(() => {});
package/src/utility.js CHANGED
@@ -458,10 +458,73 @@ function indexSatisfiesCoveringDefinition(index, candidate) {
458
458
  return requiredKeys.slice(candidate.accessKeyCount).every((key) => existingFields.has(key.field));
459
459
  }
460
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
+ }
518
+
519
+ return names;
520
+ }
521
+
461
522
  module.exports = {
462
523
  buildCoveringIndex,
463
524
  buildCoveringIndexDefinition,
464
525
  indexSatisfiesCoveringDefinition,
526
+ parseResourceList,
527
+ extractStatementResources,
465
528
  extractWhereFields,
466
529
  extractSelectFields,
467
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
@@ -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
  // ============================================