spice-couchbase 2.0.63 → 2.0.65
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/connectionHealth.js +114 -149
- package/build/connectionHealth.test.js +301 -316
- package/build/index.auto-optimize.test.js +122 -204
- package/build/index.count-query.test.js +27 -63
- package/build/index.index-management.test.js +283 -0
- package/build/index.js +486 -418
- package/build/index.multi_get.test.js +9 -56
- package/build/index.query-consistency.test.js +28 -72
- package/build/utility.js +182 -152
- package/build/utility.test.js +79 -59
- package/package.json +1 -1
- package/src/connectionHealth.js +48 -7
- package/src/connectionHealth.test.js +53 -4
- package/src/index.index-management.test.js +232 -0
- package/src/index.js +270 -8
- package/src/utility.js +78 -0
package/build/utility.js
CHANGED
|
@@ -1,220 +1,203 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
var crypto = require("crypto");
|
|
4
|
+
|
|
4
5
|
/**
|
|
5
6
|
* Generates a short hash from a string for unique index naming
|
|
6
7
|
* @param {string} str - The string to hash
|
|
7
8
|
* @returns {string} - A short hash string
|
|
8
9
|
*/
|
|
9
|
-
|
|
10
|
-
|
|
11
10
|
function generateHash(str) {
|
|
12
11
|
return crypto.createHash("md5").update(str).digest("hex").substring(0, 8);
|
|
13
12
|
}
|
|
13
|
+
|
|
14
14
|
/**
|
|
15
15
|
* Extracts field names from WHERE clause of a query
|
|
16
16
|
* @param {string} query - The N1QL query
|
|
17
17
|
* @returns {string[]} - Array of field names found in WHERE clause
|
|
18
18
|
*/
|
|
19
|
-
|
|
20
|
-
|
|
21
19
|
function extractWhereFields(query) {
|
|
22
|
-
var fields = new Set();
|
|
23
|
-
|
|
24
|
-
var whereMatch = query.match(/\bWHERE\b([\s\S]+?)(?:\bORDER\b|\bLIMIT\b|\bOFFSET\b|\bGROUP\b|\bHAVING\b|$)/i);
|
|
20
|
+
var fields = new Set();
|
|
25
21
|
|
|
22
|
+
// Find the WHERE clause
|
|
23
|
+
var whereMatch = query.match(/\bWHERE\b([^]+?)(?:\bORDER\b|\bLIMIT\b|\bOFFSET\b|\bGROUP\b|\bHAVING\b|$)/i);
|
|
26
24
|
if (!whereMatch) {
|
|
27
25
|
return [];
|
|
28
26
|
}
|
|
27
|
+
var whereClause = whereMatch[1];
|
|
29
28
|
|
|
30
|
-
|
|
29
|
+
// Pattern to match field names in comparisons
|
|
31
30
|
// Matches: field = value, field > value, field < value, field >= value, field <= value
|
|
32
31
|
// Also matches: field IN (...), field LIKE ..., field IS NULL, field IS MISSING, field IS NOT NULL
|
|
33
32
|
// Handles backtick-quoted fields like `field_name`
|
|
34
|
-
|
|
35
33
|
var fieldPattern = /`?(\w+)`?\s*(?:=|!=|<>|>=|<=|>|<|\bIN\b|\bLIKE\b|\bIS\b|\bBETWEEN\b)/gi;
|
|
36
34
|
var match;
|
|
37
|
-
|
|
38
35
|
while ((match = fieldPattern.exec(whereClause)) !== null) {
|
|
39
|
-
var fieldName = match[1].toLowerCase();
|
|
40
|
-
|
|
36
|
+
var fieldName = match[1].toLowerCase();
|
|
37
|
+
// Skip common SQL keywords that might be matched
|
|
41
38
|
if (!["and", "or", "not", "null", "missing", "true", "false"].includes(fieldName)) {
|
|
42
39
|
fields.add(match[1]); // Keep original case
|
|
43
40
|
}
|
|
44
|
-
}
|
|
45
|
-
|
|
41
|
+
}
|
|
46
42
|
|
|
43
|
+
// Also look for fields in function calls like LOWER(field), UPPER(field)
|
|
47
44
|
var functionPattern = /(?:LOWER|UPPER|TRIM|LENGTH|SUBSTR)\s*\(\s*`?(\w+)`?/gi;
|
|
48
|
-
|
|
49
45
|
while ((match = functionPattern.exec(whereClause)) !== null) {
|
|
50
46
|
fields.add(match[1]);
|
|
51
|
-
}
|
|
52
|
-
// Pattern: ANY/EVERY variable IN array_field SATISFIES ... END
|
|
53
|
-
|
|
47
|
+
}
|
|
54
48
|
|
|
49
|
+
// Extract array fields from ANY/EVERY ... IN array_field SATISFIES ... END
|
|
50
|
+
// Pattern: ANY/EVERY variable IN array_field SATISFIES ... END
|
|
55
51
|
var anyEveryPattern = /\b(?:ANY|EVERY)\s+\w+\s+IN\s+`?(\w+)`?\s+SATISFIES/gi;
|
|
56
|
-
|
|
57
52
|
while ((match = anyEveryPattern.exec(whereClause)) !== null) {
|
|
58
53
|
fields.add(match[1]);
|
|
59
|
-
}
|
|
60
|
-
// This captures fields accessed through array iteration variables
|
|
61
|
-
|
|
54
|
+
}
|
|
62
55
|
|
|
56
|
+
// Also extract nested property access within SATISFIES: v.property = value
|
|
57
|
+
// This captures fields accessed through array iteration variables
|
|
63
58
|
var satisfiesPattern = /SATISFIES\s+(.+?)\s+END/gi;
|
|
64
|
-
|
|
65
59
|
while ((match = satisfiesPattern.exec(whereClause)) !== null) {
|
|
66
|
-
var satisfiesClause = match[1];
|
|
67
|
-
|
|
60
|
+
var satisfiesClause = match[1];
|
|
61
|
+
// Look for comparisons like: v.property = value, v.field > 10
|
|
68
62
|
var nestedFieldPattern = /\w+\.`?(\w+)`?\s*(?:=|!=|<>|>=|<=|>|<|\bIN\b|\bLIKE\b|\bIS\b)/gi;
|
|
69
63
|
var nestedMatch = void 0;
|
|
70
|
-
|
|
71
|
-
|
|
64
|
+
while ((nestedMatch = nestedFieldPattern.exec(satisfiesClause)) !== null) {
|
|
65
|
+
// These are nested properties, we might want to track them separately
|
|
72
66
|
// For now, we'll note them but the array field is what matters for indexing
|
|
73
67
|
}
|
|
74
68
|
}
|
|
75
|
-
|
|
76
69
|
return Array.from(fields);
|
|
77
70
|
}
|
|
71
|
+
|
|
78
72
|
/**
|
|
79
73
|
* Extracts field names from SELECT clause of a query
|
|
80
74
|
* @param {string} query - The N1QL query
|
|
81
75
|
* @returns {string[]|null} - Array of field names, or null if SELECT *
|
|
82
76
|
*/
|
|
83
|
-
|
|
84
|
-
|
|
85
77
|
function extractSelectFields(query) {
|
|
86
78
|
// Find the SELECT clause (between SELECT and FROM)
|
|
87
|
-
var selectMatch = query.match(/\bSELECT\b([
|
|
88
|
-
|
|
79
|
+
var selectMatch = query.match(/\bSELECT\b([^]+?)\bFROM\b/i);
|
|
89
80
|
if (!selectMatch) {
|
|
90
81
|
return null;
|
|
91
82
|
}
|
|
83
|
+
var selectClause = selectMatch[1].trim();
|
|
92
84
|
|
|
93
|
-
|
|
94
|
-
|
|
85
|
+
// Check for SELECT * patterns
|
|
95
86
|
if (/^\s*\*\s*$/.test(selectClause) || /\(\s*\w+\s*\)\s*\.\s*\*/.test(selectClause)) {
|
|
96
87
|
return null; // Cannot determine fields for SELECT *
|
|
97
88
|
}
|
|
89
|
+
var fields = new Set();
|
|
98
90
|
|
|
99
|
-
|
|
100
|
-
|
|
91
|
+
// Split by comma, handling nested parentheses
|
|
101
92
|
var parts = splitSelectClause(selectClause);
|
|
102
|
-
|
|
103
93
|
for (var part of parts) {
|
|
104
|
-
var trimmedPart = part.trim();
|
|
94
|
+
var trimmedPart = part.trim();
|
|
105
95
|
|
|
96
|
+
// Skip if it's a * pattern
|
|
106
97
|
if (trimmedPart === "*" || trimmedPart.endsWith(".*")) {
|
|
107
98
|
return null;
|
|
108
|
-
}
|
|
109
|
-
|
|
99
|
+
}
|
|
110
100
|
|
|
101
|
+
// Handle AS alias - get the source field
|
|
111
102
|
var asMatch = trimmedPart.match(/^(.+?)\s+AS\s+\w+$/i);
|
|
112
|
-
var fieldExpr = asMatch ? asMatch[1].trim() : trimmedPart;
|
|
103
|
+
var fieldExpr = asMatch ? asMatch[1].trim() : trimmedPart;
|
|
113
104
|
|
|
105
|
+
// Extract field from META().id pattern - skip these
|
|
114
106
|
if (/META\s*\(/i.test(fieldExpr)) {
|
|
115
107
|
continue;
|
|
116
|
-
}
|
|
117
|
-
|
|
108
|
+
}
|
|
118
109
|
|
|
110
|
+
// Extract field from function calls like COUNT(field), SUM(field)
|
|
119
111
|
var funcMatch = fieldExpr.match(/^\w+\s*\(\s*`?(\w+)`?\s*\)$/);
|
|
120
|
-
|
|
121
112
|
if (funcMatch && funcMatch[1].toLowerCase() !== "id") {
|
|
122
113
|
fields.add(funcMatch[1]);
|
|
123
114
|
continue;
|
|
124
|
-
}
|
|
125
|
-
|
|
115
|
+
}
|
|
126
116
|
|
|
117
|
+
// Handle simple field reference: `field` or collection.field or `collection`.`field`
|
|
127
118
|
var simpleFieldMatch = fieldExpr.match(/(?:`?\w+`?\s*\.\s*)?`?(\w+)`?$/);
|
|
128
|
-
|
|
129
119
|
if (simpleFieldMatch) {
|
|
130
120
|
var fieldName = simpleFieldMatch[1];
|
|
131
|
-
|
|
132
121
|
if (fieldName.toLowerCase() !== "id") {
|
|
133
122
|
fields.add(fieldName);
|
|
134
123
|
}
|
|
135
124
|
}
|
|
136
125
|
}
|
|
137
|
-
|
|
138
126
|
return Array.from(fields);
|
|
139
127
|
}
|
|
128
|
+
|
|
140
129
|
/**
|
|
141
130
|
* Extracts field names from GROUP BY clause of a query
|
|
142
131
|
* @param {string} query - The N1QL query
|
|
143
132
|
* @returns {string[]} - Array of field names found in GROUP BY clause
|
|
144
133
|
*/
|
|
145
|
-
|
|
146
|
-
|
|
147
134
|
function extractGroupByFields(query) {
|
|
148
|
-
var fields = [];
|
|
149
|
-
|
|
150
|
-
var groupByMatch = query.match(/\bGROUP[\t-\r \xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+BY\b([\s\S]+?)(?:\bHAVING\b|\bORDER\b|\bLIMIT\b|\bOFFSET\b|$)/i);
|
|
135
|
+
var fields = [];
|
|
151
136
|
|
|
137
|
+
// Find the GROUP BY clause
|
|
138
|
+
var groupByMatch = query.match(/\bGROUP\s+BY\b([^]+?)(?:\bHAVING\b|\bORDER\b|\bLIMIT\b|\bOFFSET\b|$)/i);
|
|
152
139
|
if (!groupByMatch) {
|
|
153
140
|
return [];
|
|
154
141
|
}
|
|
142
|
+
var groupByClause = groupByMatch[1];
|
|
155
143
|
|
|
156
|
-
|
|
157
|
-
|
|
144
|
+
// Split by comma and extract field names
|
|
158
145
|
var parts = groupByClause.split(",");
|
|
159
|
-
|
|
160
146
|
for (var part of parts) {
|
|
161
|
-
var trimmedPart = part.trim();
|
|
147
|
+
var trimmedPart = part.trim();
|
|
162
148
|
|
|
149
|
+
// Handle collection.field or `collection`.`field` syntax
|
|
163
150
|
var fieldMatch = trimmedPart.match(/(?:`?\w+`?\s*\.\s*)?`?(\w+)`?$/);
|
|
164
|
-
|
|
165
151
|
if (fieldMatch) {
|
|
166
|
-
var fieldName = fieldMatch[1];
|
|
167
|
-
|
|
152
|
+
var fieldName = fieldMatch[1];
|
|
153
|
+
// Skip if it's a keyword or id
|
|
168
154
|
if (!["id"].includes(fieldName.toLowerCase())) {
|
|
169
155
|
fields.push(fieldName);
|
|
170
156
|
}
|
|
171
157
|
}
|
|
172
158
|
}
|
|
173
|
-
|
|
174
159
|
return fields;
|
|
175
160
|
}
|
|
161
|
+
|
|
176
162
|
/**
|
|
177
163
|
* Extracts field names from ORDER BY clause of a query
|
|
178
164
|
* @param {string} query - The N1QL query
|
|
179
165
|
* @returns {string[]} - Array of field names found in ORDER BY clause
|
|
180
166
|
*/
|
|
181
|
-
|
|
182
|
-
|
|
183
167
|
function extractOrderByFields(query) {
|
|
184
168
|
return extractOrderByIndexFields(query).map(field => field.name);
|
|
185
169
|
}
|
|
170
|
+
|
|
186
171
|
/**
|
|
187
172
|
* Extracts ORDER BY fields with sort direction for index key generation
|
|
188
173
|
* @param {string} query - The N1QL query
|
|
189
174
|
* @returns {{name: string, direction: string}[]} - Array of fields with normalized direction
|
|
190
175
|
*/
|
|
191
|
-
|
|
192
|
-
|
|
193
176
|
function extractOrderByIndexFields(query) {
|
|
194
|
-
var fields = [];
|
|
195
|
-
|
|
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);
|
|
177
|
+
var fields = [];
|
|
197
178
|
|
|
179
|
+
// Find the ORDER BY clause
|
|
180
|
+
var orderByMatch = query.match(/\bORDER\s+BY\b([^]+?)(?:\bLIMIT\b|\bOFFSET\b|$)/i);
|
|
198
181
|
if (!orderByMatch) {
|
|
199
182
|
return [];
|
|
200
183
|
}
|
|
184
|
+
var orderByClause = orderByMatch[1];
|
|
201
185
|
|
|
202
|
-
|
|
203
|
-
|
|
186
|
+
// Split by comma and extract field names
|
|
204
187
|
var parts = orderByClause.split(",");
|
|
205
|
-
|
|
206
188
|
for (var part of parts) {
|
|
207
|
-
var trimmedPart = part.trim();
|
|
189
|
+
var trimmedPart = part.trim();
|
|
208
190
|
|
|
191
|
+
// Remove ASC/DESC suffix while preserving DESC for index key generation.
|
|
209
192
|
var directionMatch = trimmedPart.match(/\s+(ASC|DESC)\s*$/i);
|
|
210
193
|
var direction = directionMatch && directionMatch[1].toUpperCase() === "DESC" ? "DESC" : "";
|
|
211
|
-
var withoutDirection = trimmedPart.replace(/\s+(ASC|DESC)\s*$/i, "").trim();
|
|
194
|
+
var withoutDirection = trimmedPart.replace(/\s+(ASC|DESC)\s*$/i, "").trim();
|
|
212
195
|
|
|
196
|
+
// Handle collection.field or `collection`.`field` syntax
|
|
213
197
|
var fieldMatch = withoutDirection.match(/(?:`?\w+`?\s*\.\s*)?`?(\w+)`?$/);
|
|
214
|
-
|
|
215
198
|
if (fieldMatch) {
|
|
216
|
-
var fieldName = fieldMatch[1];
|
|
217
|
-
|
|
199
|
+
var fieldName = fieldMatch[1];
|
|
200
|
+
// Skip if it's a keyword or id
|
|
218
201
|
if (!["id", "asc", "desc"].includes(fieldName.toLowerCase())) {
|
|
219
202
|
fields.push({
|
|
220
203
|
name: fieldName,
|
|
@@ -223,41 +206,33 @@ function extractOrderByIndexFields(query) {
|
|
|
223
206
|
}
|
|
224
207
|
}
|
|
225
208
|
}
|
|
226
|
-
|
|
227
209
|
return fields;
|
|
228
210
|
}
|
|
229
|
-
|
|
230
211
|
function buildOrderDirectionMap(orderByIndexFields) {
|
|
231
212
|
var directionMap = new Map();
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
direction
|
|
236
|
-
} of orderByIndexFields) {
|
|
213
|
+
for (var _ref2 of orderByIndexFields) {
|
|
214
|
+
var name = _ref2.name;
|
|
215
|
+
var direction = _ref2.direction;
|
|
237
216
|
if (direction === "DESC") {
|
|
238
217
|
directionMap.set(name, direction);
|
|
239
218
|
}
|
|
240
219
|
}
|
|
241
|
-
|
|
242
220
|
return directionMap;
|
|
243
221
|
}
|
|
244
|
-
|
|
245
222
|
function formatIndexField(field, orderDirectionMap) {
|
|
246
223
|
var direction = orderDirectionMap.get(field);
|
|
247
224
|
return "`".concat(field, "`").concat(direction ? " ".concat(direction) : "");
|
|
248
225
|
}
|
|
226
|
+
|
|
249
227
|
/**
|
|
250
228
|
* Splits SELECT clause by commas, respecting parentheses
|
|
251
229
|
* @param {string} clause - The SELECT clause content
|
|
252
230
|
* @returns {string[]} - Array of individual select expressions
|
|
253
231
|
*/
|
|
254
|
-
|
|
255
|
-
|
|
256
232
|
function splitSelectClause(clause) {
|
|
257
233
|
var parts = [];
|
|
258
234
|
var current = "";
|
|
259
235
|
var depth = 0;
|
|
260
|
-
|
|
261
236
|
for (var char of clause) {
|
|
262
237
|
if (char === "(") {
|
|
263
238
|
depth++;
|
|
@@ -272,13 +247,12 @@ function splitSelectClause(clause) {
|
|
|
272
247
|
current += char;
|
|
273
248
|
}
|
|
274
249
|
}
|
|
275
|
-
|
|
276
250
|
if (current.trim()) {
|
|
277
251
|
parts.push(current);
|
|
278
252
|
}
|
|
279
|
-
|
|
280
253
|
return parts;
|
|
281
254
|
}
|
|
255
|
+
|
|
282
256
|
/**
|
|
283
257
|
* Builds a covering index CREATE statement from a query
|
|
284
258
|
* @param {string} query - The N1QL query to analyze
|
|
@@ -288,93 +262,93 @@ function splitSelectClause(clause) {
|
|
|
288
262
|
* @param {string} options.collection - The collection name
|
|
289
263
|
* @returns {string|null} - The CREATE INDEX statement, or null if cannot build
|
|
290
264
|
*/
|
|
291
|
-
|
|
292
|
-
|
|
293
265
|
function buildCoveringIndex(query, options) {
|
|
294
|
-
var
|
|
266
|
+
var _buildCoveringIndexDe, _buildCoveringIndexDe2;
|
|
267
|
+
return (_buildCoveringIndexDe = (_buildCoveringIndexDe2 = buildCoveringIndexDefinition(query, options)) === null || _buildCoveringIndexDe2 === void 0 ? void 0 : _buildCoveringIndexDe2.statement) !== null && _buildCoveringIndexDe !== void 0 ? _buildCoveringIndexDe : null;
|
|
268
|
+
}
|
|
295
269
|
|
|
296
|
-
|
|
297
|
-
} // Keep the access keys separate from projection-only keys so an older, wider
|
|
270
|
+
// Keep the access keys separate from projection-only keys so an older, wider
|
|
298
271
|
// index can satisfy the same request without depending on its generated name.
|
|
299
|
-
|
|
300
|
-
|
|
301
272
|
function buildCoveringIndexDefinition(query, options) {
|
|
302
|
-
var
|
|
303
|
-
|
|
304
|
-
scope = "_default",
|
|
305
|
-
collection =
|
|
306
|
-
|
|
307
|
-
|
|
273
|
+
var bucket = options.bucket,
|
|
274
|
+
_options$scope = options.scope,
|
|
275
|
+
scope = _options$scope === void 0 ? "_default" : _options$scope,
|
|
276
|
+
_options$collection = options.collection,
|
|
277
|
+
collection = _options$collection === void 0 ? "_default" : _options$collection;
|
|
308
278
|
if (!bucket) {
|
|
309
279
|
return null;
|
|
310
|
-
}
|
|
311
|
-
|
|
280
|
+
}
|
|
312
281
|
|
|
282
|
+
// Extract fields from SELECT clause
|
|
313
283
|
var selectFields = extractSelectFields(query);
|
|
314
|
-
|
|
315
284
|
if (selectFields === null) {
|
|
316
285
|
// Full-document projections cannot be covered by the extracted fields.
|
|
317
286
|
// Skip them instead of creating a non-covering fallback index.
|
|
318
287
|
return null;
|
|
319
|
-
}
|
|
320
|
-
|
|
288
|
+
}
|
|
321
289
|
|
|
322
|
-
|
|
290
|
+
// Extract fields from WHERE clause
|
|
291
|
+
var whereFields = extractWhereFields(query);
|
|
323
292
|
|
|
324
|
-
|
|
293
|
+
// Extract fields from GROUP BY clause
|
|
294
|
+
var groupByFields = extractGroupByFields(query);
|
|
325
295
|
|
|
296
|
+
// Extract fields from ORDER BY clause, preserving DESC sort direction
|
|
326
297
|
var orderByIndexFields = extractOrderByIndexFields(query);
|
|
327
298
|
var orderDirectionMap = buildOrderDirectionMap(orderByIndexFields);
|
|
328
|
-
var orderByFields = orderByIndexFields.map(field => field.name);
|
|
299
|
+
var orderByFields = orderByIndexFields.map(field => field.name);
|
|
329
300
|
|
|
330
|
-
|
|
301
|
+
// Remove 'deleted' from whereFields as it will be added first with INCLUDE MISSING
|
|
302
|
+
var filteredWhereFields = whereFields.filter(f => f !== "deleted");
|
|
331
303
|
|
|
332
|
-
|
|
304
|
+
// Track fields already added to avoid duplicates
|
|
305
|
+
var addedFields = new Set(filteredWhereFields);
|
|
333
306
|
|
|
307
|
+
// Filter GROUP BY fields (remove duplicates from WHERE, remove deleted)
|
|
334
308
|
var filteredGroupByFields = groupByFields.filter(f => {
|
|
335
309
|
if (addedFields.has(f) || f === "deleted") {
|
|
336
310
|
return false;
|
|
337
311
|
}
|
|
338
|
-
|
|
339
312
|
addedFields.add(f);
|
|
340
313
|
return true;
|
|
341
|
-
});
|
|
314
|
+
});
|
|
342
315
|
|
|
316
|
+
// Filter ORDER BY fields (remove duplicates from WHERE and GROUP BY, remove deleted)
|
|
343
317
|
var filteredOrderByFields = orderByFields.filter(f => {
|
|
344
318
|
if (addedFields.has(f) || f === "deleted") {
|
|
345
319
|
return false;
|
|
346
320
|
}
|
|
347
|
-
|
|
348
321
|
addedFields.add(f);
|
|
349
322
|
return true;
|
|
350
|
-
});
|
|
323
|
+
});
|
|
351
324
|
|
|
325
|
+
// Filter SELECT fields (remove duplicates from WHERE, GROUP BY, and ORDER BY, remove deleted)
|
|
352
326
|
var selectFieldsFiltered = selectFields.filter(f => {
|
|
353
327
|
if (addedFields.has(f) || f === "deleted") {
|
|
354
328
|
return false;
|
|
355
329
|
}
|
|
356
|
-
|
|
357
330
|
addedFields.add(f);
|
|
358
331
|
return true;
|
|
359
|
-
}).sort();
|
|
332
|
+
}).sort();
|
|
360
333
|
|
|
334
|
+
// Projection order has no effect on coverage. Keep access-key order intact.
|
|
361
335
|
var allFields = [...filteredWhereFields, ...filteredGroupByFields, ...filteredOrderByFields, ...selectFieldsFiltered];
|
|
362
|
-
|
|
363
336
|
if (allFields.length === 0) {
|
|
364
337
|
// No fields to index besides deleted
|
|
365
338
|
return null;
|
|
366
|
-
}
|
|
339
|
+
}
|
|
367
340
|
|
|
341
|
+
// Build the field list with deleted INCLUDE MISSING first
|
|
342
|
+
var keys = ["`deleted` INCLUDE MISSING", ...allFields.map(f => formatIndexField(f, orderDirectionMap))];
|
|
368
343
|
|
|
369
|
-
|
|
344
|
+
// Hash the actual ordered definition, not query syntax. In particular, an
|
|
370
345
|
// explicit ORDER BY ... ASC must not rename an otherwise identical index.
|
|
371
|
-
|
|
372
346
|
var indexKey = JSON.stringify([bucket, scope, collection, keys]);
|
|
373
347
|
var hash = generateHash(indexKey);
|
|
374
|
-
var indexName = "adv_covered_".concat(hash);
|
|
348
|
+
var indexName = "adv_covered_".concat(hash);
|
|
375
349
|
|
|
350
|
+
// Build the CREATE INDEX statement
|
|
376
351
|
var quote = identifier => "`".concat(identifier.replace(/`/g, "``"), "`");
|
|
377
|
-
|
|
378
352
|
var indexStatement = "CREATE INDEX `".concat(indexName, "` ON `default`:").concat(quote(bucket), ".").concat(quote(scope), ".").concat(quote(collection), "(").concat(keys.join(", "), ")");
|
|
379
353
|
return {
|
|
380
354
|
name: indexName,
|
|
@@ -385,56 +359,50 @@ function buildCoveringIndexDefinition(query, options) {
|
|
|
385
359
|
scope,
|
|
386
360
|
collection
|
|
387
361
|
};
|
|
388
|
-
}
|
|
389
|
-
// partial indexes, or case-distinct document fields as equivalent.
|
|
390
|
-
|
|
362
|
+
}
|
|
391
363
|
|
|
364
|
+
// Only compare simple scalar keys. Do not treat expressions, array indexes,
|
|
365
|
+
// partial indexes, or case-distinct document fields as equivalent.
|
|
392
366
|
function normalizeIndexKey(key) {
|
|
393
367
|
if (typeof key !== "string") return null;
|
|
394
|
-
var match = key.trim().match(/^(\(*
|
|
368
|
+
var match = key.trim().match(/^(\(*\s*(?:`(?:[^`]|``)+`|[A-Za-z_]\w*)\s*\)*)([^]*?)$/);
|
|
395
369
|
if (!match) return null;
|
|
396
370
|
var expression = match[1].trim();
|
|
397
|
-
|
|
398
371
|
while (expression.startsWith("(") && expression.endsWith(")")) {
|
|
399
372
|
expression = expression.slice(1, -1).trim();
|
|
400
373
|
}
|
|
401
|
-
|
|
402
374
|
var identifier = expression.match(/^(?:`((?:[^`]|``)+)`|([A-Za-z_]\w*))$/);
|
|
403
375
|
if (!identifier) return null;
|
|
404
376
|
var attributes = match[2].trim().toUpperCase();
|
|
405
|
-
|
|
406
377
|
if (!/^(?:(?:ASC|DESC)(?:\s+INCLUDE\s+MISSING)?|INCLUDE\s+MISSING(?:\s+(?:ASC|DESC))?)?$/.test(attributes)) {
|
|
407
378
|
return null;
|
|
408
379
|
}
|
|
409
|
-
|
|
410
380
|
return {
|
|
411
381
|
field: identifier[1] !== undefined ? identifier[1].replace(/``/g, "`") : identifier[2],
|
|
412
382
|
direction: /\bDESC\b/.test(attributes) ? "DESC" : "ASC",
|
|
413
383
|
includeMissing: /\bINCLUDE\s+MISSING\b/.test(attributes)
|
|
414
384
|
};
|
|
415
|
-
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Call with indexes from the candidate's exact bucket/scope/collection. State
|
|
416
388
|
// is deliberately not filtered: a matching deferred/building index is already
|
|
417
389
|
// committed work, even though EXPLAIN cannot use it yet.
|
|
418
|
-
|
|
419
|
-
|
|
420
390
|
function indexSatisfiesCoveringDefinition(index, candidate) {
|
|
421
391
|
if (index.is_primary || index.condition || index.using !== "gsi" || !Array.isArray(index.index_key)) {
|
|
422
392
|
return false;
|
|
423
393
|
}
|
|
424
|
-
|
|
425
394
|
var existingKeys = index.index_key.map(normalizeIndexKey);
|
|
426
395
|
var requiredKeys = candidate.keys.map(normalizeIndexKey);
|
|
427
396
|
if (existingKeys.some(key => !key) || requiredKeys.some(key => !key)) return false;
|
|
428
|
-
|
|
429
397
|
for (var i = 0; i < candidate.accessKeyCount; i++) {
|
|
430
398
|
var existing = existingKeys[i];
|
|
431
399
|
var required = requiredKeys[i];
|
|
432
400
|
if (!existing || existing.field !== required.field || existing.direction !== required.direction || existing.includeMissing !== required.includeMissing) return false;
|
|
433
401
|
}
|
|
434
|
-
|
|
435
402
|
var existingFields = new Set(existingKeys.map(key => key.field));
|
|
436
403
|
return requiredKeys.slice(candidate.accessKeyCount).every(key => existingFields.has(key.field));
|
|
437
404
|
}
|
|
405
|
+
|
|
438
406
|
/**
|
|
439
407
|
* Normalizes an exemption list into a Set of lower-case resource names.
|
|
440
408
|
* Accepts an array, a Set, or a comma-separated string (the env var form).
|
|
@@ -442,26 +410,22 @@ function indexSatisfiesCoveringDefinition(index, candidate) {
|
|
|
442
410
|
* @param {string|string[]|Set<string>} value
|
|
443
411
|
* @returns {Set<string>}
|
|
444
412
|
*/
|
|
445
|
-
|
|
446
|
-
|
|
447
413
|
function parseResourceList(value) {
|
|
448
414
|
var names = new Set();
|
|
449
415
|
var items = [];
|
|
450
|
-
|
|
451
416
|
if (Array.isArray(value) || value instanceof Set) {
|
|
452
417
|
items = [...value];
|
|
453
418
|
} else if (typeof value === "string") {
|
|
454
419
|
items = value.split(",");
|
|
455
420
|
}
|
|
456
|
-
|
|
457
421
|
for (var item of items) {
|
|
458
422
|
if (typeof item !== "string") continue;
|
|
459
423
|
var name = item.trim().replace(/^`+|`+$/g, "").trim().toLowerCase();
|
|
460
424
|
if (name) names.add(name);
|
|
461
425
|
}
|
|
462
|
-
|
|
463
426
|
return names;
|
|
464
427
|
}
|
|
428
|
+
|
|
465
429
|
/**
|
|
466
430
|
* Extracts the resource names a statement touches, lower-cased:
|
|
467
431
|
* - the last path segment of every FROM / JOIN keyspace, unquoted, so
|
|
@@ -472,34 +436,100 @@ function parseResourceList(value) {
|
|
|
472
436
|
* @param {string} query
|
|
473
437
|
* @returns {Set<string>}
|
|
474
438
|
*/
|
|
475
|
-
|
|
476
|
-
|
|
477
439
|
function extractStatementResources(query) {
|
|
478
440
|
var names = new Set();
|
|
479
441
|
if (typeof query !== "string") return names;
|
|
480
442
|
var identifier = "(?:`(?:[^`]|``)+`|[A-Za-z_][\\w$-]*)";
|
|
481
443
|
var keyspacePattern = new RegExp("\\b(?:FROM|JOIN)\\s+(".concat(identifier, "(?:\\s*[.:]\\s*").concat(identifier, ")*)"), "gi");
|
|
482
444
|
var segmentPattern = new RegExp(identifier, "g");
|
|
483
|
-
|
|
484
445
|
for (var match of query.matchAll(keyspacePattern)) {
|
|
485
446
|
var segments = match[1].match(segmentPattern) || [];
|
|
486
447
|
var last = (segments[segments.length - 1] || "").trim();
|
|
487
448
|
var name = last.startsWith("`") ? last.slice(1, -1).replace(/``/g, "`") : last;
|
|
488
449
|
if (name) names.add(name.toLowerCase());
|
|
489
450
|
}
|
|
490
|
-
|
|
491
451
|
var typePattern = /(?:`type`|\btype)\s*=\s*'((?:[^']|'')*)'/gi;
|
|
492
|
-
|
|
493
452
|
for (var _match of query.matchAll(typePattern)) {
|
|
494
453
|
var _name = _match[1].replace(/''/g, "'").trim();
|
|
495
|
-
|
|
496
454
|
if (_name) names.add(_name.toLowerCase());
|
|
497
455
|
}
|
|
498
|
-
|
|
499
456
|
return names;
|
|
500
457
|
}
|
|
501
458
|
|
|
459
|
+
// Names the application's index manager owns. A statement whose plan already
|
|
460
|
+
// scans one of these is being served by a deliberate index, so the slow-query
|
|
461
|
+
// path leaves it alone. `ix_base` is the per-collection fallback and does not
|
|
462
|
+
// count: a statement that only finds the fallback is exactly what to report.
|
|
463
|
+
var MANAGED_INDEX_NAME = /^ix_(sort|eq|agg|core|obs)_[0-9a-f]{6}_[0-9a-f]{4}$/;
|
|
464
|
+
|
|
465
|
+
// Replaces the primary index on every collection: usable by any statement
|
|
466
|
+
// because the leading key includes MISSING, and ordered for the default list
|
|
467
|
+
// sort (`created_at DESC`).
|
|
468
|
+
var BASE_INDEX = Object.freeze({
|
|
469
|
+
name: "ix_base",
|
|
470
|
+
keys: Object.freeze([Object.freeze({
|
|
471
|
+
expr: "`created_at`",
|
|
472
|
+
desc: true,
|
|
473
|
+
include_missing: true
|
|
474
|
+
}), Object.freeze({
|
|
475
|
+
expr: "`deleted`"
|
|
476
|
+
})])
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The statement with its literals replaced by `?`, so one query shape is
|
|
481
|
+
* examined once however many values it runs with. IN lists collapse to `[?]`.
|
|
482
|
+
* @param {string} query
|
|
483
|
+
* @returns {string}
|
|
484
|
+
*/
|
|
485
|
+
function statementShape(query) {
|
|
486
|
+
return String(query !== null && query !== void 0 ? query : "").replace(/'(?:[^'\\]|\\.|'')*'/g, "?").replace(/"(?:[^"\\]|\\.)*"/g, "?").replace(/\b\d+(?:\.\d+)?\b/g, "?").replace(/\[\s*\?(?:\s*,\s*\?)*\s*\]/g, "[?]").replace(/\s+/g, " ").trim();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Names of the indexes an EXPLAIN plan scans.
|
|
491
|
+
* @param {object} plan
|
|
492
|
+
* @returns {string[]}
|
|
493
|
+
*/
|
|
494
|
+
function planIndexNames(plan) {
|
|
495
|
+
var names = new Set();
|
|
496
|
+
var visit = node => {
|
|
497
|
+
if (!node || typeof node !== "object") return;
|
|
498
|
+
if (Array.isArray(node)) {
|
|
499
|
+
node.forEach(visit);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (/Scan/.test(String(node["#operator"] || "")) && typeof node.index === "string") {
|
|
503
|
+
names.add(node.index);
|
|
504
|
+
}
|
|
505
|
+
Object.values(node).forEach(visit);
|
|
506
|
+
};
|
|
507
|
+
visit(plan);
|
|
508
|
+
return [...names];
|
|
509
|
+
}
|
|
510
|
+
function usesManagedIndex(plan) {
|
|
511
|
+
return planIndexNames(plan).some(name => MANAGED_INDEX_NAME.test(name));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Renders one index key. Accepts a ready string or `{ expr, desc, include_missing }`.
|
|
516
|
+
* @param {string|{expr: string, desc?: boolean, include_missing?: boolean}} key
|
|
517
|
+
* @returns {string}
|
|
518
|
+
*/
|
|
519
|
+
function renderIndexKey(key) {
|
|
520
|
+
if (typeof key === "string") return key;
|
|
521
|
+
if (!key || typeof key.expr !== "string" || !key.expr.trim()) {
|
|
522
|
+
throw new TypeError("An index key needs an expression");
|
|
523
|
+
}
|
|
524
|
+
return "".concat(key.expr).concat(key.desc ? " DESC" : "").concat(key.include_missing ? " INCLUDE MISSING" : "");
|
|
525
|
+
}
|
|
502
526
|
module.exports = {
|
|
527
|
+
MANAGED_INDEX_NAME,
|
|
528
|
+
BASE_INDEX,
|
|
529
|
+
statementShape,
|
|
530
|
+
planIndexNames,
|
|
531
|
+
usesManagedIndex,
|
|
532
|
+
renderIndexKey,
|
|
503
533
|
buildCoveringIndex,
|
|
504
534
|
buildCoveringIndexDefinition,
|
|
505
535
|
indexSatisfiesCoveringDefinition,
|