spice-couchbase 2.0.47 → 2.0.49

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
@@ -115,10 +115,16 @@ module.exports = class Database {
115
115
  var _this = this;
116
116
 
117
117
  return _asyncToGenerator(function* () {
118
- var cluster = yield couchbase.connect(config.nodes, {
118
+ var connectOptions = {
119
119
  username: config.username,
120
120
  password: config.password
121
- });
121
+ };
122
+
123
+ if (config.configProfile) {
124
+ connectOptions.configProfile = config.configProfile;
125
+ }
126
+
127
+ var cluster = yield couchbase.connect(config.nodes, connectOptions);
122
128
  var bucket = cluster.bucket(config.bucket);
123
129
  var collection;
124
130
  var scope_name = _this.scope_name || config.scope || "_default";
@@ -0,0 +1,327 @@
1
+ "use strict";
2
+
3
+ function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
4
+
5
+ function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }
6
+
7
+ var assert = require("assert");
8
+
9
+ var Database = require("./index");
10
+
11
+ var passed = 0;
12
+ var failed = 0;
13
+
14
+ function createDb(_ref) {
15
+ var {
16
+ lookupIn,
17
+ get
18
+ } = _ref;
19
+ var db = Object.create(Database.prototype);
20
+ var collection = {
21
+ lookupIn,
22
+ get
23
+ };
24
+ db.use_bucket_name = false;
25
+ db.collection = collection;
26
+ db.connection = collection;
27
+ return db;
28
+ }
29
+
30
+ function test(_x, _x2) {
31
+ return _test.apply(this, arguments);
32
+ }
33
+
34
+ function _test() {
35
+ _test = _asyncToGenerator(function* (name, fn) {
36
+ try {
37
+ yield fn();
38
+ console.log("\u2713 ".concat(name));
39
+ passed++;
40
+ } catch (error) {
41
+ console.log("\u2717 ".concat(name));
42
+ console.log(" Error: ".concat(error.message));
43
+ failed++;
44
+ }
45
+ });
46
+ return _test.apply(this, arguments);
47
+ }
48
+
49
+ function run() {
50
+ return _run.apply(this, arguments);
51
+ }
52
+
53
+ function _run() {
54
+ _run = _asyncToGenerator(function* () {
55
+ console.log("\n--- multi_get tests ---");
56
+ yield test("lookupIn success with dotted object path does not fallback to get",
57
+ /*#__PURE__*/
58
+ _asyncToGenerator(function* () {
59
+ var getCalls = 0;
60
+ var db = createDb({
61
+ lookupIn: function () {
62
+ var _lookupIn = _asyncToGenerator(function* () {
63
+ return {
64
+ content: [{
65
+ value: "Alice",
66
+ error: null
67
+ }]
68
+ };
69
+ });
70
+
71
+ function lookupIn() {
72
+ return _lookupIn.apply(this, arguments);
73
+ }
74
+
75
+ return lookupIn;
76
+ }(),
77
+ get: function () {
78
+ var _get = _asyncToGenerator(function* () {
79
+ getCalls++;
80
+ return {
81
+ content: {
82
+ profile: {
83
+ name: "Alice"
84
+ }
85
+ }
86
+ };
87
+ });
88
+
89
+ function get() {
90
+ return _get.apply(this, arguments);
91
+ }
92
+
93
+ return get;
94
+ }()
95
+ });
96
+ var result = yield db.multi_get(["doc-1"], {
97
+ columns: ["profile.name"]
98
+ });
99
+ assert.deepStrictEqual(result, [{
100
+ id: "doc-1",
101
+ "profile.name": "Alice"
102
+ }]);
103
+ assert.strictEqual(getCalls, 0);
104
+ }));
105
+ yield test("array-mapped dotted path falls back and returns mapped array values",
106
+ /*#__PURE__*/
107
+ _asyncToGenerator(function* () {
108
+ var db = createDb({
109
+ lookupIn: function () {
110
+ var _lookupIn2 = _asyncToGenerator(function* () {
111
+ return {
112
+ content: [{
113
+ value: undefined,
114
+ error: new Error("path mismatch")
115
+ }]
116
+ };
117
+ });
118
+
119
+ function lookupIn() {
120
+ return _lookupIn2.apply(this, arguments);
121
+ }
122
+
123
+ return lookupIn;
124
+ }(),
125
+ get: function () {
126
+ var _get2 = _asyncToGenerator(function* () {
127
+ return {
128
+ content: {
129
+ tags: [{
130
+ name: "vip"
131
+ }, {
132
+ name: "beta"
133
+ }]
134
+ }
135
+ };
136
+ });
137
+
138
+ function get() {
139
+ return _get2.apply(this, arguments);
140
+ }
141
+
142
+ return get;
143
+ }()
144
+ });
145
+ var result = yield db.multi_get(["doc-1"], {
146
+ columns: ["tags.name"]
147
+ });
148
+ assert.deepStrictEqual(result, [{
149
+ id: "doc-1",
150
+ "tags.name": ["vip", "beta"]
151
+ }]);
152
+ }));
153
+ yield test("missing dotted fields return typed empties (object and array)",
154
+ /*#__PURE__*/
155
+ _asyncToGenerator(function* () {
156
+ var db = createDb({
157
+ lookupIn: function () {
158
+ var _lookupIn3 = _asyncToGenerator(function* () {
159
+ return {
160
+ content: [{
161
+ value: undefined,
162
+ error: new Error("path mismatch")
163
+ }]
164
+ };
165
+ });
166
+
167
+ function lookupIn() {
168
+ return _lookupIn3.apply(this, arguments);
169
+ }
170
+
171
+ return lookupIn;
172
+ }(),
173
+ get: function () {
174
+ var _get3 = _asyncToGenerator(function* () {
175
+ return {
176
+ content: {
177
+ profile: {},
178
+ tags: []
179
+ }
180
+ };
181
+ });
182
+
183
+ function get() {
184
+ return _get3.apply(this, arguments);
185
+ }
186
+
187
+ return get;
188
+ }()
189
+ });
190
+ var result = yield db.multi_get(["doc-1"], {
191
+ columns: ["profile.details", "tags.name"]
192
+ });
193
+ assert.deepStrictEqual(result, [{
194
+ id: "doc-1",
195
+ "profile.details": {},
196
+ "tags.name": []
197
+ }]);
198
+ }));
199
+ yield test("mixed key outcomes keep order and omit missing docs",
200
+ /*#__PURE__*/
201
+ _asyncToGenerator(function* () {
202
+ var db = createDb({
203
+ lookupIn: function () {
204
+ var _lookupIn4 = _asyncToGenerator(function* (key) {
205
+ if (key === "a") {
206
+ return {
207
+ content: [{
208
+ value: "Alice",
209
+ error: null
210
+ }]
211
+ };
212
+ }
213
+
214
+ if (key === "b") {
215
+ return {
216
+ content: [{
217
+ value: undefined,
218
+ error: new Error("path mismatch")
219
+ }]
220
+ };
221
+ }
222
+
223
+ throw new Error("not found");
224
+ });
225
+
226
+ function lookupIn(_x3) {
227
+ return _lookupIn4.apply(this, arguments);
228
+ }
229
+
230
+ return lookupIn;
231
+ }(),
232
+ get: function () {
233
+ var _get4 = _asyncToGenerator(function* (key) {
234
+ if (key === "b") {
235
+ return {
236
+ content: {
237
+ tags: [{
238
+ name: "vip"
239
+ }, {
240
+ name: "beta"
241
+ }]
242
+ }
243
+ };
244
+ }
245
+
246
+ throw new Error("not found");
247
+ });
248
+
249
+ function get(_x4) {
250
+ return _get4.apply(this, arguments);
251
+ }
252
+
253
+ return get;
254
+ }()
255
+ });
256
+ var result = yield db.multi_get(["a", "b", "c"], {
257
+ columns: ["tags.name"]
258
+ });
259
+ assert.deepStrictEqual(result, [{
260
+ id: "a",
261
+ "tags.name": "Alice"
262
+ }, {
263
+ id: "b",
264
+ "tags.name": ["vip", "beta"]
265
+ }]);
266
+ }));
267
+ yield test("non-column multi_get behavior remains unchanged",
268
+ /*#__PURE__*/
269
+ _asyncToGenerator(function* () {
270
+ var db = createDb({
271
+ lookupIn: function () {
272
+ var _lookupIn5 = _asyncToGenerator(function* () {
273
+ throw new Error("should not be called");
274
+ });
275
+
276
+ function lookupIn() {
277
+ return _lookupIn5.apply(this, arguments);
278
+ }
279
+
280
+ return lookupIn;
281
+ }(),
282
+ get: function () {
283
+ var _get5 = _asyncToGenerator(function* (key) {
284
+ if (key === "missing") {
285
+ throw new Error("not found");
286
+ }
287
+
288
+ return {
289
+ value: {
290
+ type: "user",
291
+ name: key.toUpperCase()
292
+ }
293
+ };
294
+ });
295
+
296
+ function get(_x5) {
297
+ return _get5.apply(this, arguments);
298
+ }
299
+
300
+ return get;
301
+ }()
302
+ });
303
+ var result = yield db.multi_get(["a", "missing", "b"], {
304
+ keep_type: false
305
+ });
306
+ assert.deepStrictEqual(result, [{
307
+ name: "A",
308
+ id: "a"
309
+ }, {
310
+ name: "B",
311
+ id: "b"
312
+ }]);
313
+ }));
314
+ console.log("\nPassed: ".concat(passed));
315
+ console.log("Failed: ".concat(failed));
316
+
317
+ if (failed > 0) {
318
+ process.exitCode = 1;
319
+ }
320
+ });
321
+ return _run.apply(this, arguments);
322
+ }
323
+
324
+ run().catch(error => {
325
+ console.error(error);
326
+ process.exit(1);
327
+ });
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.47",
3
+ "version": "2.0.49",
4
4
  "description": "",
5
5
  "main": "build/index.js",
6
6
  "scripts": {
package/src/index.js CHANGED
@@ -113,10 +113,15 @@ module.exports = class Database {
113
113
  }
114
114
 
115
115
  async connect(connection_name, config) {
116
- let cluster = await couchbase.connect(config.nodes, {
116
+ let connectOptions = {
117
117
  username: config.username,
118
118
  password: config.password,
119
- });
119
+ };
120
+ if (config.configProfile) {
121
+ connectOptions.configProfile = config.configProfile;
122
+ }
123
+
124
+ let cluster = await couchbase.connect(config.nodes, connectOptions);
120
125
  let bucket = cluster.bucket(config.bucket);
121
126
  let collection;
122
127
  let scope_name = this.scope_name || config.scope || "_default";
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+
3
+ const assert = require("assert");
4
+ const Database = require("./index");
5
+
6
+ let passed = 0;
7
+ let failed = 0;
8
+
9
+ function createDb({ lookupIn, get }) {
10
+ const db = Object.create(Database.prototype);
11
+ const collection = {
12
+ lookupIn,
13
+ get,
14
+ };
15
+
16
+ db.use_bucket_name = false;
17
+ db.collection = collection;
18
+ db.connection = collection;
19
+
20
+ return db;
21
+ }
22
+
23
+ async function test(name, fn) {
24
+ try {
25
+ await fn();
26
+ console.log(`✓ ${name}`);
27
+ passed++;
28
+ } catch (error) {
29
+ console.log(`✗ ${name}`);
30
+ console.log(` Error: ${error.message}`);
31
+ failed++;
32
+ }
33
+ }
34
+
35
+ async function run() {
36
+ console.log("\n--- multi_get tests ---");
37
+
38
+ await test("lookupIn success with dotted object path does not fallback to get", async () => {
39
+ let getCalls = 0;
40
+ const db = createDb({
41
+ lookupIn: async () => ({
42
+ content: [{ value: "Alice", error: null }],
43
+ }),
44
+ get: async () => {
45
+ getCalls++;
46
+ return { content: { profile: { name: "Alice" } } };
47
+ },
48
+ });
49
+
50
+ const result = await db.multi_get(["doc-1"], {
51
+ columns: ["profile.name"],
52
+ });
53
+
54
+ assert.deepStrictEqual(result, [
55
+ {
56
+ id: "doc-1",
57
+ "profile.name": "Alice",
58
+ },
59
+ ]);
60
+ assert.strictEqual(getCalls, 0);
61
+ });
62
+
63
+ await test("array-mapped dotted path falls back and returns mapped array values", async () => {
64
+ const db = createDb({
65
+ lookupIn: async () => ({
66
+ content: [{ value: undefined, error: new Error("path mismatch") }],
67
+ }),
68
+ get: async () => ({
69
+ content: {
70
+ tags: [{ name: "vip" }, { name: "beta" }],
71
+ },
72
+ }),
73
+ });
74
+
75
+ const result = await db.multi_get(["doc-1"], {
76
+ columns: ["tags.name"],
77
+ });
78
+
79
+ assert.deepStrictEqual(result, [
80
+ {
81
+ id: "doc-1",
82
+ "tags.name": ["vip", "beta"],
83
+ },
84
+ ]);
85
+ });
86
+
87
+ await test("missing dotted fields return typed empties (object and array)", async () => {
88
+ const db = createDb({
89
+ lookupIn: async () => ({
90
+ content: [{ value: undefined, error: new Error("path mismatch") }],
91
+ }),
92
+ get: async () => ({
93
+ content: {
94
+ profile: {},
95
+ tags: [],
96
+ },
97
+ }),
98
+ });
99
+
100
+ const result = await db.multi_get(["doc-1"], {
101
+ columns: ["profile.details", "tags.name"],
102
+ });
103
+
104
+ assert.deepStrictEqual(result, [
105
+ {
106
+ id: "doc-1",
107
+ "profile.details": {},
108
+ "tags.name": [],
109
+ },
110
+ ]);
111
+ });
112
+
113
+ await test("mixed key outcomes keep order and omit missing docs", async () => {
114
+ const db = createDb({
115
+ lookupIn: async (key) => {
116
+ if (key === "a") {
117
+ return { content: [{ value: "Alice", error: null }] };
118
+ }
119
+ if (key === "b") {
120
+ return {
121
+ content: [{ value: undefined, error: new Error("path mismatch") }],
122
+ };
123
+ }
124
+ throw new Error("not found");
125
+ },
126
+ get: async (key) => {
127
+ if (key === "b") {
128
+ return {
129
+ content: {
130
+ tags: [{ name: "vip" }, { name: "beta" }],
131
+ },
132
+ };
133
+ }
134
+ throw new Error("not found");
135
+ },
136
+ });
137
+
138
+ const result = await db.multi_get(["a", "b", "c"], {
139
+ columns: ["tags.name"],
140
+ });
141
+
142
+ assert.deepStrictEqual(result, [
143
+ {
144
+ id: "a",
145
+ "tags.name": "Alice",
146
+ },
147
+ {
148
+ id: "b",
149
+ "tags.name": ["vip", "beta"],
150
+ },
151
+ ]);
152
+ });
153
+
154
+ await test("non-column multi_get behavior remains unchanged", async () => {
155
+ const db = createDb({
156
+ lookupIn: async () => {
157
+ throw new Error("should not be called");
158
+ },
159
+ get: async (key) => {
160
+ if (key === "missing") {
161
+ throw new Error("not found");
162
+ }
163
+ return {
164
+ value: {
165
+ type: "user",
166
+ name: key.toUpperCase(),
167
+ },
168
+ };
169
+ },
170
+ });
171
+
172
+ const result = await db.multi_get(["a", "missing", "b"], {
173
+ keep_type: false,
174
+ });
175
+
176
+ assert.deepStrictEqual(result, [
177
+ {
178
+ name: "A",
179
+ id: "a",
180
+ },
181
+ {
182
+ name: "B",
183
+ id: "b",
184
+ },
185
+ ]);
186
+ });
187
+
188
+ console.log(`\nPassed: ${passed}`);
189
+ console.log(`Failed: ${failed}`);
190
+
191
+ if (failed > 0) {
192
+ process.exitCode = 1;
193
+ }
194
+ }
195
+
196
+ run().catch((error) => {
197
+ console.error(error);
198
+ process.exit(1);
199
+ });
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`"));