spice-couchbase 2.0.45 → 2.0.46
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 +73 -17
- package/build/utility.js +352 -0
- package/build/utility.test.js +784 -0
- package/package.json +2 -2
- package/src/index.js +79 -30
- package/src/utility.js +368 -0
- package/src/utility.test.js +986 -0
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spice-couchbase",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.46",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "
|
|
7
|
+
"test": "node src/utility.test.js"
|
|
8
8
|
},
|
|
9
9
|
"author": "Chad Fraser <chad@sonover.com> (http://sonover.com)",
|
|
10
10
|
"license": "ISC",
|
package/src/index.js
CHANGED
|
@@ -19,8 +19,8 @@ if (!Promise.allSettled) {
|
|
|
19
19
|
.catch((reason) => ({
|
|
20
20
|
status: "rejected",
|
|
21
21
|
reason,
|
|
22
|
-
}))
|
|
23
|
-
)
|
|
22
|
+
})),
|
|
23
|
+
),
|
|
24
24
|
);
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -36,7 +36,7 @@ module.exports = class Database {
|
|
|
36
36
|
scope,
|
|
37
37
|
query_scope = "cluster",
|
|
38
38
|
auto_create_collection,
|
|
39
|
-
} = {}
|
|
39
|
+
} = {},
|
|
40
40
|
) {
|
|
41
41
|
if (connection) {
|
|
42
42
|
this.connection_name = connection;
|
|
@@ -151,7 +151,7 @@ module.exports = class Database {
|
|
|
151
151
|
{ name: "scope_admin", bucket: this.db_config.bucket },
|
|
152
152
|
{ name: "data_reader", bucket: this.db_config.bucket },
|
|
153
153
|
],
|
|
154
|
-
}
|
|
154
|
+
},
|
|
155
155
|
);
|
|
156
156
|
} catch (e) {
|
|
157
157
|
console.log(e);
|
|
@@ -162,7 +162,7 @@ module.exports = class Database {
|
|
|
162
162
|
return (
|
|
163
163
|
(this.db_config.api_base ||
|
|
164
164
|
`http://${_.last(
|
|
165
|
-
_.first(this.db_config.nodes.split(",")).split("://")
|
|
165
|
+
_.first(this.db_config.nodes.split(",")).split("://"),
|
|
166
166
|
)}`) +
|
|
167
167
|
":" +
|
|
168
168
|
default_port
|
|
@@ -179,7 +179,7 @@ module.exports = class Database {
|
|
|
179
179
|
if (!this.checkGlobelConnectionObject(this.collection_name)) {
|
|
180
180
|
let collection_exists = await this.collectionExists(
|
|
181
181
|
this.collection_name,
|
|
182
|
-
this.scope_name
|
|
182
|
+
this.scope_name,
|
|
183
183
|
);
|
|
184
184
|
if (!collection_exists) {
|
|
185
185
|
await this.createCollection(this.collection_name, this.scope_name);
|
|
@@ -205,7 +205,7 @@ module.exports = class Database {
|
|
|
205
205
|
username: that.db_config.username,
|
|
206
206
|
password: that.db_config.password,
|
|
207
207
|
},
|
|
208
|
-
}
|
|
208
|
+
},
|
|
209
209
|
);
|
|
210
210
|
let scope = _.find(all_scopes.data.scopes, { name: scope_name });
|
|
211
211
|
let collectionSet = new Set();
|
|
@@ -248,7 +248,7 @@ module.exports = class Database {
|
|
|
248
248
|
async createPrimaryIndex(scope_name, col_name) {
|
|
249
249
|
try {
|
|
250
250
|
let response = await this.query(
|
|
251
|
-
`CREATE PRIMARY INDEX ON default:\`${this.db_config.bucket}\`.\`${scope_name}\`.\`${col_name}
|
|
251
|
+
`CREATE PRIMARY INDEX ON default:\`${this.db_config.bucket}\`.\`${scope_name}\`.\`${col_name}\``,
|
|
252
252
|
);
|
|
253
253
|
return response;
|
|
254
254
|
} catch (e) {
|
|
@@ -271,7 +271,8 @@ module.exports = class Database {
|
|
|
271
271
|
async get(doc_id, { columns = [] } = {}) {
|
|
272
272
|
try {
|
|
273
273
|
const useLookupIn = columns.length > 0;
|
|
274
|
-
const collection =
|
|
274
|
+
const collection =
|
|
275
|
+
this.use_bucket_name ? this.connection : this.collection;
|
|
275
276
|
|
|
276
277
|
if (!useLookupIn) {
|
|
277
278
|
let response = await collection.get(doc_id);
|
|
@@ -315,7 +316,7 @@ module.exports = class Database {
|
|
|
315
316
|
document,
|
|
316
317
|
{
|
|
317
318
|
expiry: expiry,
|
|
318
|
-
}
|
|
319
|
+
},
|
|
319
320
|
);
|
|
320
321
|
} catch (e) {
|
|
321
322
|
console.log(e.message, e, doc_id);
|
|
@@ -326,7 +327,7 @@ module.exports = class Database {
|
|
|
326
327
|
async touch(doc_id, expiry = 0) {
|
|
327
328
|
await (this.use_bucket_name ? this.connection : this.collection).touch(
|
|
328
329
|
doc_id,
|
|
329
|
-
expiry
|
|
330
|
+
expiry,
|
|
330
331
|
);
|
|
331
332
|
return await this.get(doc_id);
|
|
332
333
|
}
|
|
@@ -365,7 +366,8 @@ module.exports = class Database {
|
|
|
365
366
|
}
|
|
366
367
|
if (_.isArray(keys) && keys.length > 0) {
|
|
367
368
|
const useLookupIn = columns.length > 0;
|
|
368
|
-
const collection =
|
|
369
|
+
const collection =
|
|
370
|
+
this.use_bucket_name ? this.connection : this.collection;
|
|
369
371
|
|
|
370
372
|
if (!useLookupIn) {
|
|
371
373
|
// Regular get - no batching needed
|
|
@@ -373,7 +375,7 @@ module.exports = class Database {
|
|
|
373
375
|
let returned_all = await Promise.allSettled(functions);
|
|
374
376
|
|
|
375
377
|
let responses = _.flatten(
|
|
376
|
-
_.map(returned_all, (returned_obj) => returned_obj?.value)
|
|
378
|
+
_.map(returned_all, (returned_obj) => returned_obj?.value),
|
|
377
379
|
);
|
|
378
380
|
|
|
379
381
|
let final_responses = _.compact(
|
|
@@ -388,7 +390,7 @@ module.exports = class Database {
|
|
|
388
390
|
}
|
|
389
391
|
return value;
|
|
390
392
|
}
|
|
391
|
-
})
|
|
393
|
+
}),
|
|
392
394
|
);
|
|
393
395
|
|
|
394
396
|
return final_responses;
|
|
@@ -440,7 +442,7 @@ module.exports = class Database {
|
|
|
440
442
|
return Object.keys(result.value).length > 1 ? result.value : null;
|
|
441
443
|
}
|
|
442
444
|
return null;
|
|
443
|
-
})
|
|
445
|
+
}),
|
|
444
446
|
);
|
|
445
447
|
|
|
446
448
|
return final_responses;
|
|
@@ -508,7 +510,7 @@ module.exports = class Database {
|
|
|
508
510
|
order_by,
|
|
509
511
|
do_count = "false",
|
|
510
512
|
query_scope,
|
|
511
|
-
join = ""
|
|
513
|
+
join = "",
|
|
512
514
|
) {
|
|
513
515
|
const is_legacy =
|
|
514
516
|
this.scope_name !== "_default" && this.collection_name !== "_default";
|
|
@@ -603,7 +605,7 @@ module.exports = class Database {
|
|
|
603
605
|
let full_query = search_query + " AND type:" + type;
|
|
604
606
|
var query = that.SearchQuery.new(
|
|
605
607
|
that.full_text_index || full_text_index,
|
|
606
|
-
that.SearchQuery.queryString(full_query)
|
|
608
|
+
that.SearchQuery.queryString(full_query),
|
|
607
609
|
);
|
|
608
610
|
|
|
609
611
|
if (!limit) {
|
|
@@ -659,22 +661,69 @@ module.exports = class Database {
|
|
|
659
661
|
return response.rows;
|
|
660
662
|
}
|
|
661
663
|
|
|
664
|
+
/**
|
|
665
|
+
* Recursively checks if the query plan contains a Fetch operator
|
|
666
|
+
* @param {Object} plan - The EXPLAIN query plan
|
|
667
|
+
* @returns {boolean} - True if Fetch operator is found
|
|
668
|
+
*/
|
|
669
|
+
hasFetchOperator(plan) {
|
|
670
|
+
if (!plan || typeof plan !== "object") {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Check if this node is a Fetch operator
|
|
675
|
+
if (plan["#operator"] === "Fetch") {
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Recursively check all child properties
|
|
680
|
+
for (const key of Object.keys(plan)) {
|
|
681
|
+
const value = plan[key];
|
|
682
|
+
if (Array.isArray(value)) {
|
|
683
|
+
for (const item of value) {
|
|
684
|
+
if (this.hasFetchOperator(item)) {
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
} else if (typeof value === "object" && value !== null) {
|
|
689
|
+
if (this.hasFetchOperator(value)) {
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
|
|
662
698
|
async optimize(query, exec_scope) {
|
|
663
699
|
try {
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
await exec_scope.query(best_index);
|
|
700
|
+
const { buildCoveringIndex } = require("./utility");
|
|
701
|
+
|
|
702
|
+
// 1. Run EXPLAIN to get the query plan
|
|
703
|
+
const explainQuery = `EXPLAIN ${query}`;
|
|
704
|
+
const explainResponse = await exec_scope.query(explainQuery);
|
|
705
|
+
|
|
706
|
+
// 2. Check if query plan contains Fetch operator (not covered)
|
|
707
|
+
const plan = explainResponse?.rows?.[0];
|
|
708
|
+
if (!this.hasFetchOperator(plan)) {
|
|
709
|
+
// Query is already covered, no need to create index
|
|
710
|
+
return;
|
|
676
711
|
}
|
|
677
|
-
|
|
712
|
+
|
|
713
|
+
// 3. Build covering index from the query
|
|
714
|
+
const indexStatement = buildCoveringIndex(query, {
|
|
715
|
+
bucket: this.bucket,
|
|
716
|
+
scope: this.scope_name,
|
|
717
|
+
collection: this.collection_name,
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// 4. Execute the index creation if we got a valid statement
|
|
721
|
+
if (indexStatement) {
|
|
722
|
+
const response = await exec_scope.query(indexStatement);
|
|
723
|
+
}
|
|
724
|
+
} catch (e) {
|
|
725
|
+
// Silently handle errors (index may already exist, etc.)
|
|
726
|
+
}
|
|
678
727
|
}
|
|
679
728
|
|
|
680
729
|
async query(query, query_scope = "cluster") {
|
package/src/utility.js
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generates a short hash from a string for unique index naming
|
|
7
|
+
* @param {string} str - The string to hash
|
|
8
|
+
* @returns {string} - A short hash string
|
|
9
|
+
*/
|
|
10
|
+
function generateHash(str) {
|
|
11
|
+
return crypto.createHash("md5").update(str).digest("hex").substring(0, 8);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extracts field names from WHERE clause of a query
|
|
16
|
+
* @param {string} query - The N1QL query
|
|
17
|
+
* @returns {string[]} - Array of field names found in WHERE clause
|
|
18
|
+
*/
|
|
19
|
+
function extractWhereFields(query) {
|
|
20
|
+
const fields = new Set();
|
|
21
|
+
|
|
22
|
+
// Find the WHERE clause
|
|
23
|
+
const whereMatch = query.match(
|
|
24
|
+
/\bWHERE\b(.+?)(?:\bORDER\b|\bLIMIT\b|\bOFFSET\b|\bGROUP\b|\bHAVING\b|$)/is,
|
|
25
|
+
);
|
|
26
|
+
if (!whereMatch) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const whereClause = whereMatch[1];
|
|
31
|
+
|
|
32
|
+
// Pattern to match field names in comparisons
|
|
33
|
+
// Matches: field = value, field > value, field < value, field >= value, field <= value
|
|
34
|
+
// Also matches: field IN (...), field LIKE ..., field IS NULL, field IS MISSING, field IS NOT NULL
|
|
35
|
+
// Handles backtick-quoted fields like `field_name`
|
|
36
|
+
const fieldPattern =
|
|
37
|
+
/`?(\w+)`?\s*(?:=|!=|<>|>=|<=|>|<|\bIN\b|\bLIKE\b|\bIS\b|\bBETWEEN\b)/gi;
|
|
38
|
+
|
|
39
|
+
let match;
|
|
40
|
+
while ((match = fieldPattern.exec(whereClause)) !== null) {
|
|
41
|
+
const fieldName = match[1].toLowerCase();
|
|
42
|
+
// Skip common SQL keywords that might be matched
|
|
43
|
+
if (
|
|
44
|
+
!["and", "or", "not", "null", "missing", "true", "false"].includes(
|
|
45
|
+
fieldName,
|
|
46
|
+
)
|
|
47
|
+
) {
|
|
48
|
+
fields.add(match[1]); // Keep original case
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Also look for fields in function calls like LOWER(field), UPPER(field)
|
|
53
|
+
const functionPattern =
|
|
54
|
+
/(?:LOWER|UPPER|TRIM|LENGTH|SUBSTR)\s*\(\s*`?(\w+)`?/gi;
|
|
55
|
+
while ((match = functionPattern.exec(whereClause)) !== null) {
|
|
56
|
+
fields.add(match[1]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Extract array fields from ANY/EVERY ... IN array_field SATISFIES ... END
|
|
60
|
+
// Pattern: ANY/EVERY variable IN array_field SATISFIES ... END
|
|
61
|
+
const anyEveryPattern =
|
|
62
|
+
/\b(?:ANY|EVERY)\s+\w+\s+IN\s+`?(\w+)`?\s+SATISFIES/gi;
|
|
63
|
+
while ((match = anyEveryPattern.exec(whereClause)) !== null) {
|
|
64
|
+
fields.add(match[1]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Also extract nested property access within SATISFIES: v.property = value
|
|
68
|
+
// This captures fields accessed through array iteration variables
|
|
69
|
+
const satisfiesPattern = /SATISFIES\s+(.+?)\s+END/gi;
|
|
70
|
+
while ((match = satisfiesPattern.exec(whereClause)) !== null) {
|
|
71
|
+
const satisfiesClause = match[1];
|
|
72
|
+
// Look for comparisons like: v.property = value, v.field > 10
|
|
73
|
+
const nestedFieldPattern =
|
|
74
|
+
/\w+\.`?(\w+)`?\s*(?:=|!=|<>|>=|<=|>|<|\bIN\b|\bLIKE\b|\bIS\b)/gi;
|
|
75
|
+
let nestedMatch;
|
|
76
|
+
while ((nestedMatch = nestedFieldPattern.exec(satisfiesClause)) !== null) {
|
|
77
|
+
// These are nested properties, we might want to track them separately
|
|
78
|
+
// For now, we'll note them but the array field is what matters for indexing
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return Array.from(fields);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Extracts field names from SELECT clause of a query
|
|
87
|
+
* @param {string} query - The N1QL query
|
|
88
|
+
* @returns {string[]|null} - Array of field names, or null if SELECT *
|
|
89
|
+
*/
|
|
90
|
+
function extractSelectFields(query) {
|
|
91
|
+
// Find the SELECT clause (between SELECT and FROM)
|
|
92
|
+
const selectMatch = query.match(/\bSELECT\b(.+?)\bFROM\b/is);
|
|
93
|
+
if (!selectMatch) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const selectClause = selectMatch[1].trim();
|
|
98
|
+
|
|
99
|
+
// Check for SELECT * patterns
|
|
100
|
+
if (
|
|
101
|
+
/^\s*\*\s*$/.test(selectClause) ||
|
|
102
|
+
/\(\s*\w+\s*\)\s*\.\s*\*/.test(selectClause)
|
|
103
|
+
) {
|
|
104
|
+
return null; // Cannot determine fields for SELECT *
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const fields = new Set();
|
|
108
|
+
|
|
109
|
+
// Split by comma, handling nested parentheses
|
|
110
|
+
const parts = splitSelectClause(selectClause);
|
|
111
|
+
|
|
112
|
+
for (const part of parts) {
|
|
113
|
+
const trimmedPart = part.trim();
|
|
114
|
+
|
|
115
|
+
// Skip if it's a * pattern
|
|
116
|
+
if (trimmedPart === "*" || trimmedPart.endsWith(".*")) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Handle AS alias - get the source field
|
|
121
|
+
const asMatch = trimmedPart.match(/^(.+?)\s+AS\s+\w+$/i);
|
|
122
|
+
const fieldExpr = asMatch ? asMatch[1].trim() : trimmedPart;
|
|
123
|
+
|
|
124
|
+
// Extract field from META().id pattern - skip these
|
|
125
|
+
if (/META\s*\(/i.test(fieldExpr)) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Extract field from function calls like COUNT(field), SUM(field)
|
|
130
|
+
const funcMatch = fieldExpr.match(/^\w+\s*\(\s*`?(\w+)`?\s*\)$/);
|
|
131
|
+
if (funcMatch && funcMatch[1].toLowerCase() !== "id") {
|
|
132
|
+
fields.add(funcMatch[1]);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Handle simple field reference: `field` or collection.field or `collection`.`field`
|
|
137
|
+
const simpleFieldMatch = fieldExpr.match(/(?:`?\w+`?\s*\.\s*)?`?(\w+)`?$/);
|
|
138
|
+
if (simpleFieldMatch) {
|
|
139
|
+
const fieldName = simpleFieldMatch[1];
|
|
140
|
+
if (fieldName.toLowerCase() !== "id") {
|
|
141
|
+
fields.add(fieldName);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return Array.from(fields);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Extracts field names from GROUP BY clause of a query
|
|
151
|
+
* @param {string} query - The N1QL query
|
|
152
|
+
* @returns {string[]} - Array of field names found in GROUP BY clause
|
|
153
|
+
*/
|
|
154
|
+
function extractGroupByFields(query) {
|
|
155
|
+
const fields = [];
|
|
156
|
+
|
|
157
|
+
// Find the GROUP BY clause
|
|
158
|
+
const groupByMatch = query.match(
|
|
159
|
+
/\bGROUP\s+BY\b(.+?)(?:\bHAVING\b|\bORDER\b|\bLIMIT\b|\bOFFSET\b|$)/is,
|
|
160
|
+
);
|
|
161
|
+
if (!groupByMatch) {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const groupByClause = groupByMatch[1];
|
|
166
|
+
|
|
167
|
+
// Split by comma and extract field names
|
|
168
|
+
const parts = groupByClause.split(",");
|
|
169
|
+
|
|
170
|
+
for (const part of parts) {
|
|
171
|
+
const trimmedPart = part.trim();
|
|
172
|
+
|
|
173
|
+
// Handle collection.field or `collection`.`field` syntax
|
|
174
|
+
const fieldMatch = trimmedPart.match(/(?:`?\w+`?\s*\.\s*)?`?(\w+)`?$/);
|
|
175
|
+
if (fieldMatch) {
|
|
176
|
+
const fieldName = fieldMatch[1];
|
|
177
|
+
// Skip if it's a keyword or id
|
|
178
|
+
if (!["id"].includes(fieldName.toLowerCase())) {
|
|
179
|
+
fields.push(fieldName);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return fields;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Extracts field names from ORDER BY clause of a query
|
|
189
|
+
* @param {string} query - The N1QL query
|
|
190
|
+
* @returns {string[]} - Array of field names found in ORDER BY clause
|
|
191
|
+
*/
|
|
192
|
+
function extractOrderByFields(query) {
|
|
193
|
+
const fields = [];
|
|
194
|
+
|
|
195
|
+
// Find the ORDER BY clause
|
|
196
|
+
const orderByMatch = query.match(
|
|
197
|
+
/\bORDER\s+BY\b(.+?)(?:\bLIMIT\b|\bOFFSET\b|$)/is,
|
|
198
|
+
);
|
|
199
|
+
if (!orderByMatch) {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const orderByClause = orderByMatch[1];
|
|
204
|
+
|
|
205
|
+
// Split by comma and extract field names
|
|
206
|
+
const parts = orderByClause.split(",");
|
|
207
|
+
|
|
208
|
+
for (const part of parts) {
|
|
209
|
+
const trimmedPart = part.trim();
|
|
210
|
+
|
|
211
|
+
// Remove ASC/DESC suffix
|
|
212
|
+
const withoutDirection = trimmedPart
|
|
213
|
+
.replace(/\s+(ASC|DESC)\s*$/i, "")
|
|
214
|
+
.trim();
|
|
215
|
+
|
|
216
|
+
// Handle collection.field or `collection`.`field` syntax
|
|
217
|
+
const fieldMatch = withoutDirection.match(/(?:`?\w+`?\s*\.\s*)?`?(\w+)`?$/);
|
|
218
|
+
if (fieldMatch) {
|
|
219
|
+
const fieldName = fieldMatch[1];
|
|
220
|
+
// Skip if it's a keyword or id
|
|
221
|
+
if (!["id", "asc", "desc"].includes(fieldName.toLowerCase())) {
|
|
222
|
+
fields.push(fieldName);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return fields;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Splits SELECT clause by commas, respecting parentheses
|
|
232
|
+
* @param {string} clause - The SELECT clause content
|
|
233
|
+
* @returns {string[]} - Array of individual select expressions
|
|
234
|
+
*/
|
|
235
|
+
function splitSelectClause(clause) {
|
|
236
|
+
const parts = [];
|
|
237
|
+
let current = "";
|
|
238
|
+
let depth = 0;
|
|
239
|
+
|
|
240
|
+
for (const char of clause) {
|
|
241
|
+
if (char === "(") {
|
|
242
|
+
depth++;
|
|
243
|
+
current += char;
|
|
244
|
+
} else if (char === ")") {
|
|
245
|
+
depth--;
|
|
246
|
+
current += char;
|
|
247
|
+
} else if (char === "," && depth === 0) {
|
|
248
|
+
parts.push(current);
|
|
249
|
+
current = "";
|
|
250
|
+
} else {
|
|
251
|
+
current += char;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (current.trim()) {
|
|
256
|
+
parts.push(current);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return parts;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Builds a covering index CREATE statement from a query
|
|
264
|
+
* @param {string} query - The N1QL query to analyze
|
|
265
|
+
* @param {Object} options - Configuration options
|
|
266
|
+
* @param {string} options.bucket - The bucket name
|
|
267
|
+
* @param {string} options.scope - The scope name
|
|
268
|
+
* @param {string} options.collection - The collection name
|
|
269
|
+
* @returns {string|null} - The CREATE INDEX statement, or null if cannot build
|
|
270
|
+
*/
|
|
271
|
+
function buildCoveringIndex(query, options) {
|
|
272
|
+
const { bucket, scope, collection } = options;
|
|
273
|
+
|
|
274
|
+
if (!bucket || !scope || !collection) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Extract fields from SELECT clause
|
|
279
|
+
const selectFields = extractSelectFields(query);
|
|
280
|
+
if (selectFields === null) {
|
|
281
|
+
// SELECT * query - cannot create covering index
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Extract fields from WHERE clause
|
|
286
|
+
const whereFields = extractWhereFields(query);
|
|
287
|
+
|
|
288
|
+
// Extract fields from GROUP BY clause
|
|
289
|
+
const groupByFields = extractGroupByFields(query);
|
|
290
|
+
|
|
291
|
+
// Extract fields from ORDER BY clause
|
|
292
|
+
const orderByFields = extractOrderByFields(query);
|
|
293
|
+
|
|
294
|
+
// Remove 'deleted' from whereFields as it will be added first with INCLUDE MISSING
|
|
295
|
+
const filteredWhereFields = whereFields.filter(
|
|
296
|
+
(f) => f.toLowerCase() !== "deleted",
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// Track fields already added to avoid duplicates
|
|
300
|
+
const addedFields = new Set(filteredWhereFields.map((f) => f.toLowerCase()));
|
|
301
|
+
|
|
302
|
+
// Filter GROUP BY fields (remove duplicates from WHERE, remove deleted)
|
|
303
|
+
const filteredGroupByFields = groupByFields.filter((f) => {
|
|
304
|
+
const lower = f.toLowerCase();
|
|
305
|
+
if (addedFields.has(lower) || lower === "deleted") {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
addedFields.add(lower);
|
|
309
|
+
return true;
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Filter ORDER BY fields (remove duplicates from WHERE and GROUP BY, remove deleted)
|
|
313
|
+
const filteredOrderByFields = orderByFields.filter((f) => {
|
|
314
|
+
const lower = f.toLowerCase();
|
|
315
|
+
if (addedFields.has(lower) || lower === "deleted") {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
addedFields.add(lower);
|
|
319
|
+
return true;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Filter SELECT fields (remove duplicates from WHERE, GROUP BY, and ORDER BY, remove deleted)
|
|
323
|
+
const selectFieldsFiltered = selectFields.filter((f) => {
|
|
324
|
+
const lower = f.toLowerCase();
|
|
325
|
+
if (addedFields.has(lower) || lower === "deleted") {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
addedFields.add(lower);
|
|
329
|
+
return true;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// All fields for the index: WHERE -> GROUP BY -> ORDER BY -> SELECT
|
|
333
|
+
const allFields = [
|
|
334
|
+
...filteredWhereFields,
|
|
335
|
+
...filteredGroupByFields,
|
|
336
|
+
...filteredOrderByFields,
|
|
337
|
+
...selectFieldsFiltered,
|
|
338
|
+
];
|
|
339
|
+
|
|
340
|
+
if (allFields.length === 0) {
|
|
341
|
+
// No fields to index besides deleted
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Generate unique index name based on query hash
|
|
346
|
+
const hash = generateHash(query);
|
|
347
|
+
const indexName = `adv_covered_${hash}`;
|
|
348
|
+
|
|
349
|
+
// Build the field list with deleted INCLUDE MISSING first
|
|
350
|
+
const fieldList = [
|
|
351
|
+
"`deleted` INCLUDE MISSING",
|
|
352
|
+
...allFields.map((f) => `\`${f}\``),
|
|
353
|
+
].join(", ");
|
|
354
|
+
|
|
355
|
+
// Build the CREATE INDEX statement
|
|
356
|
+
const indexStatement = `CREATE INDEX \`${indexName}\` ON \`default\`:\`${bucket}\`.\`${scope}\`.\`${collection}\`(${fieldList})`;
|
|
357
|
+
|
|
358
|
+
return indexStatement;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
module.exports = {
|
|
362
|
+
buildCoveringIndex,
|
|
363
|
+
extractWhereFields,
|
|
364
|
+
extractSelectFields,
|
|
365
|
+
extractGroupByFields,
|
|
366
|
+
extractOrderByFields,
|
|
367
|
+
generateHash,
|
|
368
|
+
};
|