search-input-query-parser 0.1.0
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/dist/cjs/first-pass-parser.js +77 -0
- package/dist/cjs/lexer.js +322 -0
- package/dist/cjs/parse-in-values.js +65 -0
- package/dist/cjs/parse-primary.js +154 -0
- package/dist/cjs/parse-range-expression.js +174 -0
- package/dist/cjs/parser.js +85 -0
- package/dist/cjs/search-query-to-sql.js +346 -0
- package/dist/cjs/transform-to-expression.js +130 -0
- package/dist/cjs/validate-expression-fields.js +244 -0
- package/dist/cjs/validate-in-expression.js +33 -0
- package/dist/cjs/validate-string.js +65 -0
- package/dist/cjs/validate-wildcard.js +40 -0
- package/dist/cjs/validator.js +34 -0
- package/dist/esm/first-pass-parser.js +73 -0
- package/dist/esm/lexer.js +315 -0
- package/dist/esm/parse-in-values.js +61 -0
- package/dist/esm/parse-primary.js +147 -0
- package/dist/esm/parse-range-expression.js +170 -0
- package/dist/esm/parser.js +81 -0
- package/dist/esm/search-query-to-sql.js +341 -0
- package/dist/esm/transform-to-expression.js +126 -0
- package/dist/esm/validate-expression-fields.js +240 -0
- package/dist/esm/validate-in-expression.js +29 -0
- package/dist/esm/validate-string.js +61 -0
- package/dist/esm/validate-wildcard.js +36 -0
- package/dist/esm/validator.js +30 -0
- package/dist/types/first-pass-parser.d.ts +40 -0
- package/dist/types/lexer.d.ts +27 -0
- package/dist/types/parse-in-values.d.ts +3 -0
- package/dist/types/parse-primary.d.ts +6 -0
- package/dist/types/parse-range-expression.d.ts +2 -0
- package/dist/types/parser.d.ts +68 -0
- package/dist/types/search-query-to-sql.d.ts +18 -0
- package/dist/types/transform-to-expression.d.ts +3 -0
- package/dist/types/validate-expression-fields.d.ts +4 -0
- package/dist/types/validate-in-expression.d.ts +3 -0
- package/dist/types/validate-string.d.ts +3 -0
- package/dist/types/validate-wildcard.d.ts +3 -0
- package/dist/types/validator.d.ts +8 -0
- package/package.json +52 -0
- package/src/first-pass-parser.test.ts +441 -0
- package/src/first-pass-parser.ts +144 -0
- package/src/lexer.test.ts +439 -0
- package/src/lexer.ts +387 -0
- package/src/parse-in-values.ts +74 -0
- package/src/parse-primary.ts +179 -0
- package/src/parse-range-expression.ts +187 -0
- package/src/parser.test.ts +982 -0
- package/src/parser.ts +219 -0
- package/src/search-query-to-sql.test.ts +503 -0
- package/src/search-query-to-sql.ts +506 -0
- package/src/transform-to-expression.ts +153 -0
- package/src/validate-expression-fields.ts +296 -0
- package/src/validate-in-expression.ts +36 -0
- package/src/validate-string.ts +73 -0
- package/src/validate-wildcard.ts +45 -0
- package/src/validator.test.ts +192 -0
- package/src/validator.ts +53 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { FieldSchema } from "./parser";
|
|
2
|
+
import { FirstPassExpression } from "./first-pass-parser";
|
|
3
|
+
import { ValidationError } from "./validator";
|
|
4
|
+
|
|
5
|
+
// Helper to validate numeric values
|
|
6
|
+
const validateNumber = (
|
|
7
|
+
value: string,
|
|
8
|
+
position: number,
|
|
9
|
+
errors: ValidationError[]
|
|
10
|
+
): boolean => {
|
|
11
|
+
if (value === "") return false;
|
|
12
|
+
if (isNaN(Number(value))) {
|
|
13
|
+
errors.push({
|
|
14
|
+
message: "Invalid numeric value",
|
|
15
|
+
position,
|
|
16
|
+
length: value.length,
|
|
17
|
+
});
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Helper to validate range values for numeric fields
|
|
24
|
+
const validateNumericRange = (
|
|
25
|
+
start: string,
|
|
26
|
+
end: string,
|
|
27
|
+
basePosition: number,
|
|
28
|
+
errors: ValidationError[]
|
|
29
|
+
): boolean => {
|
|
30
|
+
let isValid = true;
|
|
31
|
+
const startPos = basePosition;
|
|
32
|
+
const endPos = basePosition + start.length + 2; // +2 for the '..'
|
|
33
|
+
|
|
34
|
+
if (start && !validateNumber(start, startPos, errors)) {
|
|
35
|
+
isValid = false;
|
|
36
|
+
}
|
|
37
|
+
if (end && !validateNumber(end, endPos, errors)) {
|
|
38
|
+
isValid = false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (isValid && start && end) {
|
|
42
|
+
const startNum = Number(start);
|
|
43
|
+
const endNum = Number(end);
|
|
44
|
+
if (startNum > endNum) {
|
|
45
|
+
errors.push({
|
|
46
|
+
message: "Range start must be less than or equal to range end",
|
|
47
|
+
position: basePosition,
|
|
48
|
+
length: start.length + 2 + end.length,
|
|
49
|
+
});
|
|
50
|
+
isValid = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return isValid;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Helper to validate numeric comparison operators
|
|
58
|
+
const validateNumericComparison = (
|
|
59
|
+
operator: string,
|
|
60
|
+
value: string,
|
|
61
|
+
basePosition: number,
|
|
62
|
+
errors: ValidationError[]
|
|
63
|
+
): boolean => {
|
|
64
|
+
const valuePosition = basePosition + operator.length;
|
|
65
|
+
return validateNumber(value, valuePosition, errors);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Field validation helpers
|
|
69
|
+
const validateFieldValue = (
|
|
70
|
+
expr: FirstPassExpression,
|
|
71
|
+
allowedFields: Set<string>,
|
|
72
|
+
errors: ValidationError[],
|
|
73
|
+
schemas: Map<string, FieldSchema>
|
|
74
|
+
): void => {
|
|
75
|
+
switch (expr.type) {
|
|
76
|
+
case "IN": {
|
|
77
|
+
if (!allowedFields.has(expr.field.toLowerCase())) {
|
|
78
|
+
errors.push({
|
|
79
|
+
message: `Invalid field: "${expr.field}"`,
|
|
80
|
+
position: expr.position,
|
|
81
|
+
length: expr.field.length,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Get schema for type validation
|
|
86
|
+
const schema = schemas.get(expr.field.toLowerCase());
|
|
87
|
+
if (schema) {
|
|
88
|
+
expr.values.forEach((value, index) => {
|
|
89
|
+
switch (schema.type) {
|
|
90
|
+
case "number":
|
|
91
|
+
if (isNaN(Number(value))) {
|
|
92
|
+
errors.push({
|
|
93
|
+
message: "Invalid numeric value",
|
|
94
|
+
position:
|
|
95
|
+
expr.position +
|
|
96
|
+
expr.field.length +
|
|
97
|
+
4 +
|
|
98
|
+
index * (value.length + 1),
|
|
99
|
+
length: value.length,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
case "date":
|
|
104
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
105
|
+
errors.push({
|
|
106
|
+
message: "Invalid date format",
|
|
107
|
+
position:
|
|
108
|
+
expr.position +
|
|
109
|
+
expr.field.length +
|
|
110
|
+
3 +
|
|
111
|
+
index * (value.length + 1),
|
|
112
|
+
length: value.length,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case "WILDCARD": {
|
|
123
|
+
// For wildcard patterns, validate against field type constraints
|
|
124
|
+
const schema = schemas.get(expr.prefix.toLowerCase());
|
|
125
|
+
if (schema?.type === "number" || schema?.type === "date") {
|
|
126
|
+
errors.push({
|
|
127
|
+
message: `Wildcards are not allowed for ${schema.type} fields`,
|
|
128
|
+
position: expr.position,
|
|
129
|
+
length: expr.length,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
case "STRING": {
|
|
136
|
+
const colonIndex = expr.value.indexOf(":");
|
|
137
|
+
if (colonIndex === -1) return;
|
|
138
|
+
|
|
139
|
+
const fieldName = expr.value.substring(0, colonIndex).trim();
|
|
140
|
+
const value = expr.value.substring(colonIndex + 1).trim();
|
|
141
|
+
|
|
142
|
+
if (!allowedFields.has(fieldName.toLowerCase()) && colonIndex > 0) {
|
|
143
|
+
errors.push({
|
|
144
|
+
message: `Invalid field: "${fieldName}"`,
|
|
145
|
+
position: expr.position,
|
|
146
|
+
length: colonIndex,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!value) {
|
|
151
|
+
errors.push({
|
|
152
|
+
message: "Expected field value",
|
|
153
|
+
position: expr.position,
|
|
154
|
+
length: colonIndex + 1,
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (value.startsWith(":")) {
|
|
160
|
+
errors.push({
|
|
161
|
+
message: "Missing field name",
|
|
162
|
+
position: expr.position,
|
|
163
|
+
length: value.length + colonIndex + 1,
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const schema = schemas.get(fieldName.toLowerCase());
|
|
169
|
+
if (!schema) return;
|
|
170
|
+
|
|
171
|
+
const valueStartPosition = expr.position + colonIndex + 1;
|
|
172
|
+
|
|
173
|
+
if (schema.type === "number") {
|
|
174
|
+
if (value.includes("..")) {
|
|
175
|
+
if (value === ".." || value.includes("...")) {
|
|
176
|
+
errors.push({
|
|
177
|
+
message: "Invalid range format",
|
|
178
|
+
position: valueStartPosition,
|
|
179
|
+
length: value.length,
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const [start, end] = value.split("..");
|
|
185
|
+
validateNumericRange(start, end, valueStartPosition, errors);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const comparisonMatch = value.match(/^(>=|>|<=|<)(.*)$/);
|
|
190
|
+
if (comparisonMatch) {
|
|
191
|
+
const [, operator, compValue] = comparisonMatch;
|
|
192
|
+
|
|
193
|
+
const invalidOp = /^[<>]{2,}|>=>/;
|
|
194
|
+
if (invalidOp.test(value)) {
|
|
195
|
+
errors.push({
|
|
196
|
+
message: "Invalid range operator",
|
|
197
|
+
position: valueStartPosition,
|
|
198
|
+
length: 3,
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!compValue) {
|
|
204
|
+
errors.push({
|
|
205
|
+
message: "Expected range value",
|
|
206
|
+
position: valueStartPosition + operator.length,
|
|
207
|
+
length: 0,
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
validateNumericComparison(
|
|
213
|
+
operator,
|
|
214
|
+
compValue,
|
|
215
|
+
valueStartPosition,
|
|
216
|
+
errors
|
|
217
|
+
);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
validateNumber(value, valueStartPosition, errors);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (schema.type === "date") {
|
|
226
|
+
const dateValidator = (dateStr: string) => {
|
|
227
|
+
if (!dateStr) return true;
|
|
228
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
const date = new Date(dateStr);
|
|
232
|
+
return (
|
|
233
|
+
!isNaN(date.getTime()) &&
|
|
234
|
+
dateStr === date.toISOString().split("T")[0]
|
|
235
|
+
);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
if (value.includes("..")) {
|
|
239
|
+
const [start, end] = value.split("..");
|
|
240
|
+
if (!dateValidator(start) || !dateValidator(end)) {
|
|
241
|
+
errors.push({
|
|
242
|
+
message: "Invalid date format",
|
|
243
|
+
position: valueStartPosition,
|
|
244
|
+
length: value.length,
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
const comparisonMatch = value.match(/^(>=|>|<=|<)(.*)$/);
|
|
250
|
+
if (comparisonMatch) {
|
|
251
|
+
const [, , dateStr] = comparisonMatch;
|
|
252
|
+
if (!dateValidator(dateStr)) {
|
|
253
|
+
errors.push({
|
|
254
|
+
message: "Invalid date format",
|
|
255
|
+
position: valueStartPosition,
|
|
256
|
+
length: value.length,
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
} else if (!dateValidator(value)) {
|
|
261
|
+
errors.push({
|
|
262
|
+
message: "Invalid date format",
|
|
263
|
+
position: valueStartPosition,
|
|
264
|
+
length: value.length,
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export const validateExpressionFields = (
|
|
276
|
+
expr: FirstPassExpression,
|
|
277
|
+
allowedFields: Set<string>,
|
|
278
|
+
errors: ValidationError[],
|
|
279
|
+
schemas: Map<string, FieldSchema>
|
|
280
|
+
): void => {
|
|
281
|
+
switch (expr.type) {
|
|
282
|
+
case "STRING":
|
|
283
|
+
case "WILDCARD":
|
|
284
|
+
case "IN":
|
|
285
|
+
validateFieldValue(expr, allowedFields, errors, schemas);
|
|
286
|
+
break;
|
|
287
|
+
case "AND":
|
|
288
|
+
case "OR":
|
|
289
|
+
validateExpressionFields(expr.left, allowedFields, errors, schemas);
|
|
290
|
+
validateExpressionFields(expr.right, allowedFields, errors, schemas);
|
|
291
|
+
break;
|
|
292
|
+
case "NOT":
|
|
293
|
+
validateExpressionFields(expr.expression, allowedFields, errors, schemas);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { InExpression } from "./first-pass-parser";
|
|
2
|
+
import { ValidationError, reservedWords } from "./validator";
|
|
3
|
+
|
|
4
|
+
export const validateInExpression = (
|
|
5
|
+
expr: InExpression,
|
|
6
|
+
errors: ValidationError[]
|
|
7
|
+
): void => {
|
|
8
|
+
// Validate field name pattern
|
|
9
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(expr.field)) {
|
|
10
|
+
errors.push({
|
|
11
|
+
message: "Invalid characters in field name",
|
|
12
|
+
position: expr.position,
|
|
13
|
+
length: expr.field.length,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Check for reserved words
|
|
18
|
+
if (reservedWords.has(expr.field.toUpperCase())) {
|
|
19
|
+
errors.push({
|
|
20
|
+
message: `${expr.field} is a reserved word`,
|
|
21
|
+
position: expr.position,
|
|
22
|
+
length: expr.field.length,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Validate value format based on field type
|
|
27
|
+
expr.values.forEach((value, index) => {
|
|
28
|
+
if (value.includes(",")) {
|
|
29
|
+
errors.push({
|
|
30
|
+
message: "Invalid character in IN value",
|
|
31
|
+
position: expr.position + expr.field.length + 3 + index * (value.length + 1),
|
|
32
|
+
length: value.length,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { StringLiteral, WildcardPattern } from "./first-pass-parser";
|
|
2
|
+
import { ValidationError, reservedWords } from "./validator";
|
|
3
|
+
import { validateWildcard } from "./validate-wildcard";
|
|
4
|
+
|
|
5
|
+
// Validate individual strings (field:value pairs or plain terms)
|
|
6
|
+
export const validateString = (
|
|
7
|
+
expr: StringLiteral | WildcardPattern,
|
|
8
|
+
errors: ValidationError[]
|
|
9
|
+
) => {
|
|
10
|
+
// Validate wildcard usage
|
|
11
|
+
validateWildcard(expr, errors);
|
|
12
|
+
|
|
13
|
+
// For wildcard patterns, no additional validation needed
|
|
14
|
+
if (expr.type === "WILDCARD") {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Handle STRING type
|
|
19
|
+
// Check for empty field values
|
|
20
|
+
if (expr.value.endsWith(":")) {
|
|
21
|
+
errors.push({
|
|
22
|
+
message: "Expected field value",
|
|
23
|
+
position: expr.position,
|
|
24
|
+
length: expr.length,
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check for field values that start with colon
|
|
30
|
+
if (expr.value.startsWith(":")) {
|
|
31
|
+
errors.push({
|
|
32
|
+
message: "Missing field name",
|
|
33
|
+
position: expr.position,
|
|
34
|
+
length: expr.length,
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// For field:value patterns, validate the field name
|
|
40
|
+
if (expr.value.includes(":")) {
|
|
41
|
+
const [fieldName] = expr.value.split(":");
|
|
42
|
+
|
|
43
|
+
// Check for reserved words used as field names
|
|
44
|
+
if (reservedWords.has(fieldName.toUpperCase())) {
|
|
45
|
+
errors.push({
|
|
46
|
+
message: `${fieldName} is a reserved word`,
|
|
47
|
+
position: expr.position,
|
|
48
|
+
length: fieldName.length,
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check for invalid characters in field names
|
|
54
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(fieldName)) {
|
|
55
|
+
errors.push({
|
|
56
|
+
message: "Invalid characters in field name",
|
|
57
|
+
position: expr.position,
|
|
58
|
+
length: fieldName.length,
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Handle standalone reserved words (not in field:value pattern)
|
|
65
|
+
if (!expr.value.includes(":") &&
|
|
66
|
+
reservedWords.has(expr.value.toUpperCase())) {
|
|
67
|
+
errors.push({
|
|
68
|
+
message: `${expr.value} is a reserved word`,
|
|
69
|
+
position: expr.position,
|
|
70
|
+
length: expr.length,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { StringLiteral, WildcardPattern } from "./first-pass-parser";
|
|
2
|
+
import { ValidationError } from "./validator";
|
|
3
|
+
|
|
4
|
+
// Validates wildcard patterns
|
|
5
|
+
|
|
6
|
+
export const validateWildcard = (
|
|
7
|
+
expr: StringLiteral | WildcardPattern,
|
|
8
|
+
errors: ValidationError[]
|
|
9
|
+
) => {
|
|
10
|
+
const value = expr.type === "STRING" ? expr.value : expr.prefix + "*";
|
|
11
|
+
const starCount = (value.match(/\*/g) || []).length;
|
|
12
|
+
const isQuoted = expr.quoted;
|
|
13
|
+
|
|
14
|
+
// For unquoted strings
|
|
15
|
+
if (!isQuoted) {
|
|
16
|
+
const firstStar = value.indexOf("*");
|
|
17
|
+
if (starCount > 1) {
|
|
18
|
+
const secondStar = value.indexOf("*", firstStar + 1);
|
|
19
|
+
errors.push({
|
|
20
|
+
message: "Only one trailing wildcard (*) is allowed",
|
|
21
|
+
position: expr.position + secondStar,
|
|
22
|
+
length: 1,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
if ((firstStar !== -1 && firstStar !== value.length - 1) && !value.endsWith("**")) {
|
|
26
|
+
errors.push({
|
|
27
|
+
message: "Wildcard (*) can only appear at the end of a term",
|
|
28
|
+
position: expr.position + firstStar,
|
|
29
|
+
length: 1,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// For quoted strings
|
|
35
|
+
else {
|
|
36
|
+
// Handle multiple wildcards or internal wildcards in quoted strings
|
|
37
|
+
if (value.endsWith("**")) {
|
|
38
|
+
errors.push({
|
|
39
|
+
message: "Only one trailing wildcard (*) is allowed",
|
|
40
|
+
position: expr.position + value.length - 1,
|
|
41
|
+
length: 1,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, expect, test } from "@jest/globals";
|
|
2
|
+
import { validateSearchQuery, ValidationError } from "./validator";
|
|
3
|
+
import { tokenize, createStream } from "./lexer";
|
|
4
|
+
import { parseExpression } from "./first-pass-parser";
|
|
5
|
+
|
|
6
|
+
describe("Search Query Validator", () => {
|
|
7
|
+
const validateQuery = (input: string): ValidationError[] => {
|
|
8
|
+
const tokens = tokenize(input);
|
|
9
|
+
const stream = createStream(tokens);
|
|
10
|
+
const result = parseExpression(stream);
|
|
11
|
+
return validateSearchQuery(result.result);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("Field Name Validation", () => {
|
|
15
|
+
test("accepts valid field names", () => {
|
|
16
|
+
expect(validateQuery("simple:value")).toEqual([]);
|
|
17
|
+
expect(validateQuery("field123:value")).toEqual([]);
|
|
18
|
+
expect(validateQuery("field_name:value")).toEqual([]);
|
|
19
|
+
expect(validateQuery("field-name:value")).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("handles field names with special characters", () => {
|
|
23
|
+
expect(validateQuery("special@field:value")).toEqual([
|
|
24
|
+
{
|
|
25
|
+
message: "Invalid characters in field name",
|
|
26
|
+
position: 0,
|
|
27
|
+
length: 13,
|
|
28
|
+
},
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("validates multiple field:value pairs", () => {
|
|
33
|
+
expect(validateQuery("valid:value special!:value")).toEqual([
|
|
34
|
+
{
|
|
35
|
+
message: "Invalid characters in field name",
|
|
36
|
+
position: 12,
|
|
37
|
+
length: 8,
|
|
38
|
+
},
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("Field Value Validation", () => {
|
|
44
|
+
test("detects missing field values", () => {
|
|
45
|
+
expect(validateQuery("field:")).toEqual([
|
|
46
|
+
{
|
|
47
|
+
message: "Expected field value",
|
|
48
|
+
position: 0,
|
|
49
|
+
length: 6,
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("validates field values with spaces", () => {
|
|
55
|
+
expect(validateQuery('field:"value with spaces"')).toEqual([]);
|
|
56
|
+
expect(validateQuery("field:value with spaces")).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("validates empty colon patterns", () => {
|
|
60
|
+
expect(validateQuery(":value")).toEqual([
|
|
61
|
+
{
|
|
62
|
+
message: "Missing field name",
|
|
63
|
+
position: 0,
|
|
64
|
+
length: 6,
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("Reserved Word Validation", () => {
|
|
71
|
+
test("detects reserved words as field names", () => {
|
|
72
|
+
expect(validateQuery("AND:value")).toEqual([
|
|
73
|
+
{
|
|
74
|
+
message: "AND is a reserved word",
|
|
75
|
+
position: 0,
|
|
76
|
+
length: 3,
|
|
77
|
+
},
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
expect(validateQuery("OR:value")).toEqual([
|
|
81
|
+
{
|
|
82
|
+
message: "OR is a reserved word",
|
|
83
|
+
position: 0,
|
|
84
|
+
length: 2,
|
|
85
|
+
},
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("detects reserved words as standalone terms", () => {
|
|
90
|
+
// This will throw because it's handled by the parser
|
|
91
|
+
expect(() => validateQuery("AND")).toThrow("AND is a reserved word");
|
|
92
|
+
expect(() => validateQuery("OR")).toThrow("OR is a reserved word");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("allows reserved words as field values", () => {
|
|
96
|
+
expect(validateQuery("field:AND")).toEqual([]);
|
|
97
|
+
expect(validateQuery("field:OR")).toEqual([]);
|
|
98
|
+
expect(validateQuery('field:"AND OR"')).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("Complex Expression Validation", () => {
|
|
103
|
+
test("validates nested expressions", () => {
|
|
104
|
+
expect(validateQuery("(field:value AND invalid!:value)")).toEqual([
|
|
105
|
+
{
|
|
106
|
+
message: "Invalid characters in field name",
|
|
107
|
+
position: 17,
|
|
108
|
+
length: 8,
|
|
109
|
+
},
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("validates multiple errors in one expression", () => {
|
|
114
|
+
expect(validateQuery("AND:test OR invalid!:value")).toEqual([
|
|
115
|
+
{
|
|
116
|
+
message: "AND is a reserved word",
|
|
117
|
+
position: 0,
|
|
118
|
+
length: 3,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
message: "Invalid characters in field name",
|
|
122
|
+
position: 12,
|
|
123
|
+
length: 8,
|
|
124
|
+
},
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("validates complex nested expressions", () => {
|
|
129
|
+
expect(
|
|
130
|
+
validateQuery("(field:value AND (OR:test OR valid:value))")
|
|
131
|
+
).toEqual([
|
|
132
|
+
{
|
|
133
|
+
message: "OR is a reserved word",
|
|
134
|
+
position: 18,
|
|
135
|
+
length: 2,
|
|
136
|
+
},
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("Edge Cases", () => {
|
|
142
|
+
test("handles empty input", () => {
|
|
143
|
+
expect(() => validateQuery("")).toThrow("Unexpected token");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("handles whitespace-only input", () => {
|
|
147
|
+
expect(() => validateQuery(" ")).toThrow("Unexpected token");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("invalidates consecutive colons", () => {
|
|
151
|
+
expect(validateQuery("field::value")).toEqual([
|
|
152
|
+
{
|
|
153
|
+
message: "Expected field value",
|
|
154
|
+
position: 0,
|
|
155
|
+
length: 6,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
message: "Missing field name",
|
|
159
|
+
position: 6,
|
|
160
|
+
length: 6,
|
|
161
|
+
}
|
|
162
|
+
]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("validates field names with only special characters", () => {
|
|
166
|
+
expect(validateQuery("@#$:value")).toEqual([
|
|
167
|
+
{
|
|
168
|
+
message: "Invalid characters in field name",
|
|
169
|
+
position: 0,
|
|
170
|
+
length: 3,
|
|
171
|
+
},
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("validates mixed valid and invalid patterns", () => {
|
|
176
|
+
const complexQuery =
|
|
177
|
+
'valid:value AND field:"test" OR @invalid:value AND OR:test';
|
|
178
|
+
expect(validateQuery(complexQuery)).toEqual([
|
|
179
|
+
{
|
|
180
|
+
message: "Invalid characters in field name",
|
|
181
|
+
position: 32,
|
|
182
|
+
length: 8,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
message: "OR is a reserved word",
|
|
186
|
+
position: 51,
|
|
187
|
+
length: 2,
|
|
188
|
+
},
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
package/src/validator.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FirstPassExpression,
|
|
3
|
+
} from "./first-pass-parser";
|
|
4
|
+
|
|
5
|
+
import { FieldSchema } from "./parser";
|
|
6
|
+
import { validateInExpression } from "./validate-in-expression";
|
|
7
|
+
import { validateString } from "./validate-string";
|
|
8
|
+
|
|
9
|
+
// Validation error type
|
|
10
|
+
export type ValidationError = {
|
|
11
|
+
message: string;
|
|
12
|
+
position: number;
|
|
13
|
+
length: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const reservedWords = new Set(["AND", "OR"]);
|
|
17
|
+
|
|
18
|
+
const walkExpression = (
|
|
19
|
+
expr: FirstPassExpression,
|
|
20
|
+
errors: ValidationError[]
|
|
21
|
+
) => {
|
|
22
|
+
switch (expr.type) {
|
|
23
|
+
case "STRING":
|
|
24
|
+
case "WILDCARD":
|
|
25
|
+
validateString(expr, errors);
|
|
26
|
+
break;
|
|
27
|
+
case "AND":
|
|
28
|
+
case "OR":
|
|
29
|
+
walkExpression(expr.left, errors);
|
|
30
|
+
walkExpression(expr.right, errors);
|
|
31
|
+
break;
|
|
32
|
+
case "NOT":
|
|
33
|
+
walkExpression(expr.expression, errors);
|
|
34
|
+
break;
|
|
35
|
+
case "IN":
|
|
36
|
+
validateInExpression(expr, errors);
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const validateSearchQuery = (
|
|
42
|
+
expression: FirstPassExpression
|
|
43
|
+
): ValidationError[] => {
|
|
44
|
+
const errors: ValidationError[] = [];
|
|
45
|
+
|
|
46
|
+
if (expression === null) {
|
|
47
|
+
return errors;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
walkExpression(expression, errors);
|
|
51
|
+
|
|
52
|
+
return errors;
|
|
53
|
+
};
|