@shko.online/dataverse-odata 0.1.6 → 0.2.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/CHANGELOG.md +13 -0
- package/azure-pipelines.yml +1 -1
- package/lib/cjs/getFilterFromParser.js +232 -6
- package/lib/esm/getFilterFromParser.js +232 -6
- package/lib/modern/getFilterFromParser.js +237 -6
- package/lib/ts3.4/OData.types.d.ts +35 -4
- package/lib/ts3.9/OData.types.d.ts +35 -4
- package/lib/ts4.2/OData.types.d.ts +48 -5
- package/lib/ts4.2/getFilterFromParser.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/OData.types.d.ts +48 -5
- package/src/getFilterFromParser.ts +206 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
# [0.2.0](https://github.com/Shko-Online/dataverse-odata/compare/v0.1.6...v0.2.0) (2026-04-11)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* column comparison uses no keyword prefix, e.g. firstname eq lastname ([34727d5](https://github.com/Shko-Online/dataverse-odata/commit/34727d5ce8698b4d0ec822ee226637169c881028))
|
|
7
|
+
* distinguish between null, boolean and column operators ([62511ef](https://github.com/Shko-Online/dataverse-odata/commit/62511ef624ebbd06aa6a7732b9693e7f50dbbbce))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* implement $filter parser and add sample tests for each operator ([e2f5387](https://github.com/Shko-Online/dataverse-odata/commit/e2f538740422ba51c13ff13ecb9222606a19ce17))
|
|
13
|
+
|
|
1
14
|
## [0.1.6](https://github.com/Shko-Online/dataverse-odata/compare/v0.1.5...v0.1.6) (2026-03-15)
|
|
2
15
|
|
|
3
16
|
|
package/azure-pipelines.yml
CHANGED
|
@@ -5,7 +5,229 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
5
5
|
});
|
|
6
6
|
exports.getFilterFromParser = void 0;
|
|
7
7
|
var _atMostOnce = require("./validators/atMostOnce");
|
|
8
|
+
var _hasContent = require("./validators/hasContent");
|
|
8
9
|
const option = '$filter';
|
|
10
|
+
const STANDARD_OPERATORS = ['eq', 'ne', 'gt', 'ge', 'lt', 'le'];
|
|
11
|
+
const QUERY_FUNCTION_OPERATORS = ['contains', 'endswith', 'startswith'];
|
|
12
|
+
function isStandardOperator(s) {
|
|
13
|
+
return STANDARD_OPERATORS.includes(s);
|
|
14
|
+
}
|
|
15
|
+
function isQueryFunctionOperator(s) {
|
|
16
|
+
return QUERY_FUNCTION_OPERATORS.includes(s);
|
|
17
|
+
}
|
|
18
|
+
const GUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/;
|
|
19
|
+
function tokenize(input) {
|
|
20
|
+
const tokens = [];
|
|
21
|
+
let i = 0;
|
|
22
|
+
while (i < input.length) {
|
|
23
|
+
if (/\s/.test(input[i])) {
|
|
24
|
+
i++;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (input[i] === '(') {
|
|
28
|
+
tokens.push({
|
|
29
|
+
type: 'lparen',
|
|
30
|
+
value: '('
|
|
31
|
+
});
|
|
32
|
+
i++;
|
|
33
|
+
} else if (input[i] === ')') {
|
|
34
|
+
tokens.push({
|
|
35
|
+
type: 'rparen',
|
|
36
|
+
value: ')'
|
|
37
|
+
});
|
|
38
|
+
i++;
|
|
39
|
+
} else if (input[i] === ',') {
|
|
40
|
+
tokens.push({
|
|
41
|
+
type: 'comma',
|
|
42
|
+
value: ','
|
|
43
|
+
});
|
|
44
|
+
i++;
|
|
45
|
+
} else if (input[i] === "'") {
|
|
46
|
+
let j = i + 1;
|
|
47
|
+
let str = '';
|
|
48
|
+
while (j < input.length) {
|
|
49
|
+
if (input[j] === "'" && j + 1 < input.length && input[j + 1] === "'") {
|
|
50
|
+
str += "'";
|
|
51
|
+
j += 2;
|
|
52
|
+
} else if (input[j] === "'") {
|
|
53
|
+
break;
|
|
54
|
+
} else {
|
|
55
|
+
str += input[j];
|
|
56
|
+
j++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
tokens.push({
|
|
60
|
+
type: 'string',
|
|
61
|
+
value: str
|
|
62
|
+
});
|
|
63
|
+
i = j + 1;
|
|
64
|
+
} else if (/[0-9a-fA-F]/.test(input[i]) && GUID_REGEX.test(input.slice(i))) {
|
|
65
|
+
tokens.push({
|
|
66
|
+
type: 'string',
|
|
67
|
+
value: input.slice(i, i + 36)
|
|
68
|
+
});
|
|
69
|
+
i += 36;
|
|
70
|
+
} else if (/[0-9]/.test(input[i]) || input[i] === '-' && /[0-9]/.test(input[i + 1] ?? '')) {
|
|
71
|
+
let j = i;
|
|
72
|
+
if (input[j] === '-') j++;
|
|
73
|
+
while (j < input.length && /[0-9.]/.test(input[j])) j++;
|
|
74
|
+
tokens.push({
|
|
75
|
+
type: 'number',
|
|
76
|
+
value: input.slice(i, j)
|
|
77
|
+
});
|
|
78
|
+
i = j;
|
|
79
|
+
} else if (/[a-zA-Z_]/.test(input[i])) {
|
|
80
|
+
let j = i;
|
|
81
|
+
while (j < input.length && /[a-zA-Z0-9_]/.test(input[j])) j++;
|
|
82
|
+
tokens.push({
|
|
83
|
+
type: 'word',
|
|
84
|
+
value: input.slice(i, j)
|
|
85
|
+
});
|
|
86
|
+
i = j;
|
|
87
|
+
} else {
|
|
88
|
+
i++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return tokens;
|
|
92
|
+
}
|
|
93
|
+
class FilterParser {
|
|
94
|
+
constructor(tokens) {
|
|
95
|
+
this.tokens = void 0;
|
|
96
|
+
this.pos = 0;
|
|
97
|
+
this.tokens = tokens;
|
|
98
|
+
}
|
|
99
|
+
peek() {
|
|
100
|
+
return this.tokens[this.pos] ?? null;
|
|
101
|
+
}
|
|
102
|
+
consume(expected) {
|
|
103
|
+
const token = this.tokens[this.pos++];
|
|
104
|
+
if (!token) throw new Error(`Unexpected end of filter expression${expected ? `, expected ${expected}` : ''}`);
|
|
105
|
+
return token;
|
|
106
|
+
}
|
|
107
|
+
expect(type, value) {
|
|
108
|
+
const token = this.consume(`${type}${value ? ` '${value}'` : ''}`);
|
|
109
|
+
if (token.type !== type || value !== undefined && token.value !== value) {
|
|
110
|
+
throw new Error(`Expected ${type}${value ? ` '${value}'` : ''} but got ${token.type} '${token.value}'`);
|
|
111
|
+
}
|
|
112
|
+
return token;
|
|
113
|
+
}
|
|
114
|
+
parse() {
|
|
115
|
+
const expr = this.parseOr();
|
|
116
|
+
if (this.pos < this.tokens.length) {
|
|
117
|
+
throw new Error(`Unexpected token '${this.tokens[this.pos].value}'`);
|
|
118
|
+
}
|
|
119
|
+
return expr;
|
|
120
|
+
}
|
|
121
|
+
parseOr() {
|
|
122
|
+
let left = this.parseAnd();
|
|
123
|
+
while (this.peek()?.value === 'or') {
|
|
124
|
+
this.consume();
|
|
125
|
+
const right = this.parseAnd();
|
|
126
|
+
left = {
|
|
127
|
+
operator: 'or',
|
|
128
|
+
left,
|
|
129
|
+
right
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return left;
|
|
133
|
+
}
|
|
134
|
+
parseAnd() {
|
|
135
|
+
let left = this.parseNot();
|
|
136
|
+
while (this.peek()?.value === 'and') {
|
|
137
|
+
this.consume();
|
|
138
|
+
const right = this.parseNot();
|
|
139
|
+
left = {
|
|
140
|
+
operator: 'and',
|
|
141
|
+
left,
|
|
142
|
+
right
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return left;
|
|
146
|
+
}
|
|
147
|
+
parseNot() {
|
|
148
|
+
if (this.peek()?.value === 'not') {
|
|
149
|
+
this.consume();
|
|
150
|
+
const right = this.parseNot();
|
|
151
|
+
return {
|
|
152
|
+
operator: 'not',
|
|
153
|
+
right
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return this.parsePrimary();
|
|
157
|
+
}
|
|
158
|
+
parsePrimary() {
|
|
159
|
+
const token = this.peek();
|
|
160
|
+
if (!token) throw new Error('Unexpected end of filter expression');
|
|
161
|
+
if (token.type === 'lparen') {
|
|
162
|
+
this.consume();
|
|
163
|
+
const expr = this.parseOr();
|
|
164
|
+
this.expect('rparen');
|
|
165
|
+
return expr;
|
|
166
|
+
}
|
|
167
|
+
if (token.type === 'word' && isQueryFunctionOperator(token.value.toLowerCase())) {
|
|
168
|
+
const func = this.consume().value.toLowerCase();
|
|
169
|
+
this.expect('lparen');
|
|
170
|
+
const left = this.expect('word').value;
|
|
171
|
+
this.expect('comma');
|
|
172
|
+
const right = this.expect('string').value;
|
|
173
|
+
this.expect('rparen');
|
|
174
|
+
return {
|
|
175
|
+
operator: func,
|
|
176
|
+
left,
|
|
177
|
+
right
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (token.type === 'word') {
|
|
181
|
+
const left = this.consume().value;
|
|
182
|
+
const opToken = this.consume(`a comparison operator`);
|
|
183
|
+
if (!isStandardOperator(opToken.value.toLowerCase())) {
|
|
184
|
+
throw new Error(`Invalid operator '${opToken.value}'`);
|
|
185
|
+
}
|
|
186
|
+
const operator = opToken.value.toLowerCase();
|
|
187
|
+
const right = this.consume(`a value or column name`);
|
|
188
|
+
if (right.type === 'string') {
|
|
189
|
+
return {
|
|
190
|
+
operator,
|
|
191
|
+
left,
|
|
192
|
+
right: right.value
|
|
193
|
+
};
|
|
194
|
+
} else if (right.type === 'number') {
|
|
195
|
+
return {
|
|
196
|
+
operator,
|
|
197
|
+
left,
|
|
198
|
+
right: Number(right.value)
|
|
199
|
+
};
|
|
200
|
+
} else if (right.type === 'word') {
|
|
201
|
+
// Constant keywords stay as StandardOperator; bare identifiers are column comparisons
|
|
202
|
+
const BOOL_CONSTANTS = ['true', 'false'];
|
|
203
|
+
if (BOOL_CONSTANTS.includes(right.value.toLowerCase())) {
|
|
204
|
+
return {
|
|
205
|
+
operator,
|
|
206
|
+
left,
|
|
207
|
+
isBooleanOperation: true,
|
|
208
|
+
right: right.value === 'true'
|
|
209
|
+
};
|
|
210
|
+
} else if (right.value.toLowerCase() === 'null') {
|
|
211
|
+
return {
|
|
212
|
+
operator,
|
|
213
|
+
left,
|
|
214
|
+
isNullOperation: true,
|
|
215
|
+
right: null
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
left,
|
|
220
|
+
operator,
|
|
221
|
+
isColumnOperation: true,
|
|
222
|
+
right: right.value
|
|
223
|
+
};
|
|
224
|
+
} else {
|
|
225
|
+
throw new Error(`Invalid right-hand side value of type '${right.type}' in filter expression`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
throw new Error(`Unexpected token '${token.value}'`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
9
231
|
|
|
10
232
|
/**
|
|
11
233
|
* Parses the {@link ODataFilter.$filter $filter} query
|
|
@@ -16,15 +238,19 @@ const getFilterFromParser = (parser, result) => {
|
|
|
16
238
|
if (value.length === 0) {
|
|
17
239
|
return true;
|
|
18
240
|
}
|
|
19
|
-
if (!(0, _atMostOnce.atMostOnce)(option, value, result)) {
|
|
241
|
+
if (!(0, _atMostOnce.atMostOnce)(option, value, result) || !(0, _hasContent.hasContent)(option, value, result)) {
|
|
20
242
|
return false;
|
|
21
243
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
244
|
+
try {
|
|
245
|
+
const tokens = tokenize(value[0]);
|
|
246
|
+
const p = new FilterParser(tokens);
|
|
247
|
+
result.$filter = p.parse();
|
|
248
|
+
} catch (e) {
|
|
249
|
+
result.error = {
|
|
250
|
+
code: '0x80060888',
|
|
251
|
+
message: `Syntax error in '$filter': ${e.message}`
|
|
27
252
|
};
|
|
253
|
+
return false;
|
|
28
254
|
}
|
|
29
255
|
return true;
|
|
30
256
|
};
|
|
@@ -1,5 +1,227 @@
|
|
|
1
1
|
import { atMostOnce } from './validators/atMostOnce';
|
|
2
|
+
import { hasContent } from './validators/hasContent';
|
|
2
3
|
const option = '$filter';
|
|
4
|
+
const STANDARD_OPERATORS = ['eq', 'ne', 'gt', 'ge', 'lt', 'le'];
|
|
5
|
+
const QUERY_FUNCTION_OPERATORS = ['contains', 'endswith', 'startswith'];
|
|
6
|
+
function isStandardOperator(s) {
|
|
7
|
+
return STANDARD_OPERATORS.includes(s);
|
|
8
|
+
}
|
|
9
|
+
function isQueryFunctionOperator(s) {
|
|
10
|
+
return QUERY_FUNCTION_OPERATORS.includes(s);
|
|
11
|
+
}
|
|
12
|
+
const GUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/;
|
|
13
|
+
function tokenize(input) {
|
|
14
|
+
const tokens = [];
|
|
15
|
+
let i = 0;
|
|
16
|
+
while (i < input.length) {
|
|
17
|
+
if (/\s/.test(input[i])) {
|
|
18
|
+
i++;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (input[i] === '(') {
|
|
22
|
+
tokens.push({
|
|
23
|
+
type: 'lparen',
|
|
24
|
+
value: '('
|
|
25
|
+
});
|
|
26
|
+
i++;
|
|
27
|
+
} else if (input[i] === ')') {
|
|
28
|
+
tokens.push({
|
|
29
|
+
type: 'rparen',
|
|
30
|
+
value: ')'
|
|
31
|
+
});
|
|
32
|
+
i++;
|
|
33
|
+
} else if (input[i] === ',') {
|
|
34
|
+
tokens.push({
|
|
35
|
+
type: 'comma',
|
|
36
|
+
value: ','
|
|
37
|
+
});
|
|
38
|
+
i++;
|
|
39
|
+
} else if (input[i] === "'") {
|
|
40
|
+
let j = i + 1;
|
|
41
|
+
let str = '';
|
|
42
|
+
while (j < input.length) {
|
|
43
|
+
if (input[j] === "'" && j + 1 < input.length && input[j + 1] === "'") {
|
|
44
|
+
str += "'";
|
|
45
|
+
j += 2;
|
|
46
|
+
} else if (input[j] === "'") {
|
|
47
|
+
break;
|
|
48
|
+
} else {
|
|
49
|
+
str += input[j];
|
|
50
|
+
j++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
tokens.push({
|
|
54
|
+
type: 'string',
|
|
55
|
+
value: str
|
|
56
|
+
});
|
|
57
|
+
i = j + 1;
|
|
58
|
+
} else if (/[0-9a-fA-F]/.test(input[i]) && GUID_REGEX.test(input.slice(i))) {
|
|
59
|
+
tokens.push({
|
|
60
|
+
type: 'string',
|
|
61
|
+
value: input.slice(i, i + 36)
|
|
62
|
+
});
|
|
63
|
+
i += 36;
|
|
64
|
+
} else if (/[0-9]/.test(input[i]) || input[i] === '-' && /[0-9]/.test(input[i + 1] ?? '')) {
|
|
65
|
+
let j = i;
|
|
66
|
+
if (input[j] === '-') j++;
|
|
67
|
+
while (j < input.length && /[0-9.]/.test(input[j])) j++;
|
|
68
|
+
tokens.push({
|
|
69
|
+
type: 'number',
|
|
70
|
+
value: input.slice(i, j)
|
|
71
|
+
});
|
|
72
|
+
i = j;
|
|
73
|
+
} else if (/[a-zA-Z_]/.test(input[i])) {
|
|
74
|
+
let j = i;
|
|
75
|
+
while (j < input.length && /[a-zA-Z0-9_]/.test(input[j])) j++;
|
|
76
|
+
tokens.push({
|
|
77
|
+
type: 'word',
|
|
78
|
+
value: input.slice(i, j)
|
|
79
|
+
});
|
|
80
|
+
i = j;
|
|
81
|
+
} else {
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return tokens;
|
|
86
|
+
}
|
|
87
|
+
class FilterParser {
|
|
88
|
+
constructor(tokens) {
|
|
89
|
+
this.tokens = void 0;
|
|
90
|
+
this.pos = 0;
|
|
91
|
+
this.tokens = tokens;
|
|
92
|
+
}
|
|
93
|
+
peek() {
|
|
94
|
+
return this.tokens[this.pos] ?? null;
|
|
95
|
+
}
|
|
96
|
+
consume(expected) {
|
|
97
|
+
const token = this.tokens[this.pos++];
|
|
98
|
+
if (!token) throw new Error(`Unexpected end of filter expression${expected ? `, expected ${expected}` : ''}`);
|
|
99
|
+
return token;
|
|
100
|
+
}
|
|
101
|
+
expect(type, value) {
|
|
102
|
+
const token = this.consume(`${type}${value ? ` '${value}'` : ''}`);
|
|
103
|
+
if (token.type !== type || value !== undefined && token.value !== value) {
|
|
104
|
+
throw new Error(`Expected ${type}${value ? ` '${value}'` : ''} but got ${token.type} '${token.value}'`);
|
|
105
|
+
}
|
|
106
|
+
return token;
|
|
107
|
+
}
|
|
108
|
+
parse() {
|
|
109
|
+
const expr = this.parseOr();
|
|
110
|
+
if (this.pos < this.tokens.length) {
|
|
111
|
+
throw new Error(`Unexpected token '${this.tokens[this.pos].value}'`);
|
|
112
|
+
}
|
|
113
|
+
return expr;
|
|
114
|
+
}
|
|
115
|
+
parseOr() {
|
|
116
|
+
let left = this.parseAnd();
|
|
117
|
+
while (this.peek()?.value === 'or') {
|
|
118
|
+
this.consume();
|
|
119
|
+
const right = this.parseAnd();
|
|
120
|
+
left = {
|
|
121
|
+
operator: 'or',
|
|
122
|
+
left,
|
|
123
|
+
right
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return left;
|
|
127
|
+
}
|
|
128
|
+
parseAnd() {
|
|
129
|
+
let left = this.parseNot();
|
|
130
|
+
while (this.peek()?.value === 'and') {
|
|
131
|
+
this.consume();
|
|
132
|
+
const right = this.parseNot();
|
|
133
|
+
left = {
|
|
134
|
+
operator: 'and',
|
|
135
|
+
left,
|
|
136
|
+
right
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return left;
|
|
140
|
+
}
|
|
141
|
+
parseNot() {
|
|
142
|
+
if (this.peek()?.value === 'not') {
|
|
143
|
+
this.consume();
|
|
144
|
+
const right = this.parseNot();
|
|
145
|
+
return {
|
|
146
|
+
operator: 'not',
|
|
147
|
+
right
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return this.parsePrimary();
|
|
151
|
+
}
|
|
152
|
+
parsePrimary() {
|
|
153
|
+
const token = this.peek();
|
|
154
|
+
if (!token) throw new Error('Unexpected end of filter expression');
|
|
155
|
+
if (token.type === 'lparen') {
|
|
156
|
+
this.consume();
|
|
157
|
+
const expr = this.parseOr();
|
|
158
|
+
this.expect('rparen');
|
|
159
|
+
return expr;
|
|
160
|
+
}
|
|
161
|
+
if (token.type === 'word' && isQueryFunctionOperator(token.value.toLowerCase())) {
|
|
162
|
+
const func = this.consume().value.toLowerCase();
|
|
163
|
+
this.expect('lparen');
|
|
164
|
+
const left = this.expect('word').value;
|
|
165
|
+
this.expect('comma');
|
|
166
|
+
const right = this.expect('string').value;
|
|
167
|
+
this.expect('rparen');
|
|
168
|
+
return {
|
|
169
|
+
operator: func,
|
|
170
|
+
left,
|
|
171
|
+
right
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (token.type === 'word') {
|
|
175
|
+
const left = this.consume().value;
|
|
176
|
+
const opToken = this.consume(`a comparison operator`);
|
|
177
|
+
if (!isStandardOperator(opToken.value.toLowerCase())) {
|
|
178
|
+
throw new Error(`Invalid operator '${opToken.value}'`);
|
|
179
|
+
}
|
|
180
|
+
const operator = opToken.value.toLowerCase();
|
|
181
|
+
const right = this.consume(`a value or column name`);
|
|
182
|
+
if (right.type === 'string') {
|
|
183
|
+
return {
|
|
184
|
+
operator,
|
|
185
|
+
left,
|
|
186
|
+
right: right.value
|
|
187
|
+
};
|
|
188
|
+
} else if (right.type === 'number') {
|
|
189
|
+
return {
|
|
190
|
+
operator,
|
|
191
|
+
left,
|
|
192
|
+
right: Number(right.value)
|
|
193
|
+
};
|
|
194
|
+
} else if (right.type === 'word') {
|
|
195
|
+
// Constant keywords stay as StandardOperator; bare identifiers are column comparisons
|
|
196
|
+
const BOOL_CONSTANTS = ['true', 'false'];
|
|
197
|
+
if (BOOL_CONSTANTS.includes(right.value.toLowerCase())) {
|
|
198
|
+
return {
|
|
199
|
+
operator,
|
|
200
|
+
left,
|
|
201
|
+
isBooleanOperation: true,
|
|
202
|
+
right: right.value === 'true'
|
|
203
|
+
};
|
|
204
|
+
} else if (right.value.toLowerCase() === 'null') {
|
|
205
|
+
return {
|
|
206
|
+
operator,
|
|
207
|
+
left,
|
|
208
|
+
isNullOperation: true,
|
|
209
|
+
right: null
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
left,
|
|
214
|
+
operator,
|
|
215
|
+
isColumnOperation: true,
|
|
216
|
+
right: right.value
|
|
217
|
+
};
|
|
218
|
+
} else {
|
|
219
|
+
throw new Error(`Invalid right-hand side value of type '${right.type}' in filter expression`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
throw new Error(`Unexpected token '${token.value}'`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
3
225
|
|
|
4
226
|
/**
|
|
5
227
|
* Parses the {@link ODataFilter.$filter $filter} query
|
|
@@ -10,15 +232,19 @@ export const getFilterFromParser = (parser, result) => {
|
|
|
10
232
|
if (value.length === 0) {
|
|
11
233
|
return true;
|
|
12
234
|
}
|
|
13
|
-
if (!atMostOnce(option, value, result)) {
|
|
235
|
+
if (!atMostOnce(option, value, result) || !hasContent(option, value, result)) {
|
|
14
236
|
return false;
|
|
15
237
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
238
|
+
try {
|
|
239
|
+
const tokens = tokenize(value[0]);
|
|
240
|
+
const p = new FilterParser(tokens);
|
|
241
|
+
result.$filter = p.parse();
|
|
242
|
+
} catch (e) {
|
|
243
|
+
result.error = {
|
|
244
|
+
code: '0x80060888',
|
|
245
|
+
message: `Syntax error in '$filter': ${e.message}`
|
|
21
246
|
};
|
|
247
|
+
return false;
|
|
22
248
|
}
|
|
23
249
|
return true;
|
|
24
250
|
};
|
|
@@ -1,5 +1,232 @@
|
|
|
1
1
|
import { atMostOnce } from './validators/atMostOnce';
|
|
2
|
+
import { hasContent } from './validators/hasContent';
|
|
2
3
|
const option = '$filter';
|
|
4
|
+
const STANDARD_OPERATORS = ['eq', 'ne', 'gt', 'ge', 'lt', 'le'];
|
|
5
|
+
const QUERY_FUNCTION_OPERATORS = ['contains', 'endswith', 'startswith'];
|
|
6
|
+
function isStandardOperator(s) {
|
|
7
|
+
return STANDARD_OPERATORS.includes(s);
|
|
8
|
+
}
|
|
9
|
+
function isQueryFunctionOperator(s) {
|
|
10
|
+
return QUERY_FUNCTION_OPERATORS.includes(s);
|
|
11
|
+
}
|
|
12
|
+
const GUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/;
|
|
13
|
+
function tokenize(input) {
|
|
14
|
+
const tokens = [];
|
|
15
|
+
let i = 0;
|
|
16
|
+
while (i < input.length) {
|
|
17
|
+
var _input;
|
|
18
|
+
if (/\s/.test(input[i])) {
|
|
19
|
+
i++;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (input[i] === '(') {
|
|
23
|
+
tokens.push({
|
|
24
|
+
type: 'lparen',
|
|
25
|
+
value: '('
|
|
26
|
+
});
|
|
27
|
+
i++;
|
|
28
|
+
} else if (input[i] === ')') {
|
|
29
|
+
tokens.push({
|
|
30
|
+
type: 'rparen',
|
|
31
|
+
value: ')'
|
|
32
|
+
});
|
|
33
|
+
i++;
|
|
34
|
+
} else if (input[i] === ',') {
|
|
35
|
+
tokens.push({
|
|
36
|
+
type: 'comma',
|
|
37
|
+
value: ','
|
|
38
|
+
});
|
|
39
|
+
i++;
|
|
40
|
+
} else if (input[i] === "'") {
|
|
41
|
+
let j = i + 1;
|
|
42
|
+
let str = '';
|
|
43
|
+
while (j < input.length) {
|
|
44
|
+
if (input[j] === "'" && j + 1 < input.length && input[j + 1] === "'") {
|
|
45
|
+
str += "'";
|
|
46
|
+
j += 2;
|
|
47
|
+
} else if (input[j] === "'") {
|
|
48
|
+
break;
|
|
49
|
+
} else {
|
|
50
|
+
str += input[j];
|
|
51
|
+
j++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
tokens.push({
|
|
55
|
+
type: 'string',
|
|
56
|
+
value: str
|
|
57
|
+
});
|
|
58
|
+
i = j + 1;
|
|
59
|
+
} else if (/[0-9a-fA-F]/.test(input[i]) && GUID_REGEX.test(input.slice(i))) {
|
|
60
|
+
tokens.push({
|
|
61
|
+
type: 'string',
|
|
62
|
+
value: input.slice(i, i + 36)
|
|
63
|
+
});
|
|
64
|
+
i += 36;
|
|
65
|
+
} else if (/[0-9]/.test(input[i]) || input[i] === '-' && /[0-9]/.test((_input = input[i + 1]) !== null && _input !== void 0 ? _input : '')) {
|
|
66
|
+
let j = i;
|
|
67
|
+
if (input[j] === '-') j++;
|
|
68
|
+
while (j < input.length && /[0-9.]/.test(input[j])) j++;
|
|
69
|
+
tokens.push({
|
|
70
|
+
type: 'number',
|
|
71
|
+
value: input.slice(i, j)
|
|
72
|
+
});
|
|
73
|
+
i = j;
|
|
74
|
+
} else if (/[a-zA-Z_]/.test(input[i])) {
|
|
75
|
+
let j = i;
|
|
76
|
+
while (j < input.length && /[a-zA-Z0-9_]/.test(input[j])) j++;
|
|
77
|
+
tokens.push({
|
|
78
|
+
type: 'word',
|
|
79
|
+
value: input.slice(i, j)
|
|
80
|
+
});
|
|
81
|
+
i = j;
|
|
82
|
+
} else {
|
|
83
|
+
i++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return tokens;
|
|
87
|
+
}
|
|
88
|
+
class FilterParser {
|
|
89
|
+
constructor(tokens) {
|
|
90
|
+
this.tokens = void 0;
|
|
91
|
+
this.pos = 0;
|
|
92
|
+
this.tokens = tokens;
|
|
93
|
+
}
|
|
94
|
+
peek() {
|
|
95
|
+
var _this$tokens$this$pos;
|
|
96
|
+
return (_this$tokens$this$pos = this.tokens[this.pos]) !== null && _this$tokens$this$pos !== void 0 ? _this$tokens$this$pos : null;
|
|
97
|
+
}
|
|
98
|
+
consume(expected) {
|
|
99
|
+
const token = this.tokens[this.pos++];
|
|
100
|
+
if (!token) throw new Error(`Unexpected end of filter expression${expected ? `, expected ${expected}` : ''}`);
|
|
101
|
+
return token;
|
|
102
|
+
}
|
|
103
|
+
expect(type, value) {
|
|
104
|
+
const token = this.consume(`${type}${value ? ` '${value}'` : ''}`);
|
|
105
|
+
if (token.type !== type || value !== undefined && token.value !== value) {
|
|
106
|
+
throw new Error(`Expected ${type}${value ? ` '${value}'` : ''} but got ${token.type} '${token.value}'`);
|
|
107
|
+
}
|
|
108
|
+
return token;
|
|
109
|
+
}
|
|
110
|
+
parse() {
|
|
111
|
+
const expr = this.parseOr();
|
|
112
|
+
if (this.pos < this.tokens.length) {
|
|
113
|
+
throw new Error(`Unexpected token '${this.tokens[this.pos].value}'`);
|
|
114
|
+
}
|
|
115
|
+
return expr;
|
|
116
|
+
}
|
|
117
|
+
parseOr() {
|
|
118
|
+
let left = this.parseAnd();
|
|
119
|
+
while (((_this$peek = this.peek()) === null || _this$peek === void 0 ? void 0 : _this$peek.value) === 'or') {
|
|
120
|
+
var _this$peek;
|
|
121
|
+
this.consume();
|
|
122
|
+
const right = this.parseAnd();
|
|
123
|
+
left = {
|
|
124
|
+
operator: 'or',
|
|
125
|
+
left,
|
|
126
|
+
right
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return left;
|
|
130
|
+
}
|
|
131
|
+
parseAnd() {
|
|
132
|
+
let left = this.parseNot();
|
|
133
|
+
while (((_this$peek2 = this.peek()) === null || _this$peek2 === void 0 ? void 0 : _this$peek2.value) === 'and') {
|
|
134
|
+
var _this$peek2;
|
|
135
|
+
this.consume();
|
|
136
|
+
const right = this.parseNot();
|
|
137
|
+
left = {
|
|
138
|
+
operator: 'and',
|
|
139
|
+
left,
|
|
140
|
+
right
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return left;
|
|
144
|
+
}
|
|
145
|
+
parseNot() {
|
|
146
|
+
var _this$peek3;
|
|
147
|
+
if (((_this$peek3 = this.peek()) === null || _this$peek3 === void 0 ? void 0 : _this$peek3.value) === 'not') {
|
|
148
|
+
this.consume();
|
|
149
|
+
const right = this.parseNot();
|
|
150
|
+
return {
|
|
151
|
+
operator: 'not',
|
|
152
|
+
right
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return this.parsePrimary();
|
|
156
|
+
}
|
|
157
|
+
parsePrimary() {
|
|
158
|
+
const token = this.peek();
|
|
159
|
+
if (!token) throw new Error('Unexpected end of filter expression');
|
|
160
|
+
if (token.type === 'lparen') {
|
|
161
|
+
this.consume();
|
|
162
|
+
const expr = this.parseOr();
|
|
163
|
+
this.expect('rparen');
|
|
164
|
+
return expr;
|
|
165
|
+
}
|
|
166
|
+
if (token.type === 'word' && isQueryFunctionOperator(token.value.toLowerCase())) {
|
|
167
|
+
const func = this.consume().value.toLowerCase();
|
|
168
|
+
this.expect('lparen');
|
|
169
|
+
const left = this.expect('word').value;
|
|
170
|
+
this.expect('comma');
|
|
171
|
+
const right = this.expect('string').value;
|
|
172
|
+
this.expect('rparen');
|
|
173
|
+
return {
|
|
174
|
+
operator: func,
|
|
175
|
+
left,
|
|
176
|
+
right
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (token.type === 'word') {
|
|
180
|
+
const left = this.consume().value;
|
|
181
|
+
const opToken = this.consume(`a comparison operator`);
|
|
182
|
+
if (!isStandardOperator(opToken.value.toLowerCase())) {
|
|
183
|
+
throw new Error(`Invalid operator '${opToken.value}'`);
|
|
184
|
+
}
|
|
185
|
+
const operator = opToken.value.toLowerCase();
|
|
186
|
+
const right = this.consume(`a value or column name`);
|
|
187
|
+
if (right.type === 'string') {
|
|
188
|
+
return {
|
|
189
|
+
operator,
|
|
190
|
+
left,
|
|
191
|
+
right: right.value
|
|
192
|
+
};
|
|
193
|
+
} else if (right.type === 'number') {
|
|
194
|
+
return {
|
|
195
|
+
operator,
|
|
196
|
+
left,
|
|
197
|
+
right: Number(right.value)
|
|
198
|
+
};
|
|
199
|
+
} else if (right.type === 'word') {
|
|
200
|
+
// Constant keywords stay as StandardOperator; bare identifiers are column comparisons
|
|
201
|
+
const BOOL_CONSTANTS = ['true', 'false'];
|
|
202
|
+
if (BOOL_CONSTANTS.includes(right.value.toLowerCase())) {
|
|
203
|
+
return {
|
|
204
|
+
operator,
|
|
205
|
+
left,
|
|
206
|
+
isBooleanOperation: true,
|
|
207
|
+
right: right.value === 'true'
|
|
208
|
+
};
|
|
209
|
+
} else if (right.value.toLowerCase() === 'null') {
|
|
210
|
+
return {
|
|
211
|
+
operator,
|
|
212
|
+
left,
|
|
213
|
+
isNullOperation: true,
|
|
214
|
+
right: null
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
left,
|
|
219
|
+
operator,
|
|
220
|
+
isColumnOperation: true,
|
|
221
|
+
right: right.value
|
|
222
|
+
};
|
|
223
|
+
} else {
|
|
224
|
+
throw new Error(`Invalid right-hand side value of type '${right.type}' in filter expression`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
throw new Error(`Unexpected token '${token.value}'`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
3
230
|
|
|
4
231
|
/**
|
|
5
232
|
* Parses the {@link ODataFilter.$filter $filter} query
|
|
@@ -10,15 +237,19 @@ export const getFilterFromParser = (parser, result) => {
|
|
|
10
237
|
if (value.length === 0) {
|
|
11
238
|
return true;
|
|
12
239
|
}
|
|
13
|
-
if (!atMostOnce(option, value, result)) {
|
|
240
|
+
if (!atMostOnce(option, value, result) || !hasContent(option, value, result)) {
|
|
14
241
|
return false;
|
|
15
242
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
243
|
+
try {
|
|
244
|
+
const tokens = tokenize(value[0]);
|
|
245
|
+
const p = new FilterParser(tokens);
|
|
246
|
+
result.$filter = p.parse();
|
|
247
|
+
} catch (e) {
|
|
248
|
+
result.error = {
|
|
249
|
+
code: '0x80060888',
|
|
250
|
+
message: `Syntax error in '$filter': ${e.message}`
|
|
21
251
|
};
|
|
252
|
+
return false;
|
|
22
253
|
}
|
|
23
254
|
return true;
|
|
24
255
|
};
|
|
@@ -33,7 +33,7 @@ interface ODataFilter {
|
|
|
33
33
|
*/
|
|
34
34
|
$filter?: FilterOperator;
|
|
35
35
|
}
|
|
36
|
-
type FilterOperator = StandardOperator | ColumnOperator | UnaryOperator | BinaryOperator | QueryFunctionOperator;
|
|
36
|
+
type FilterOperator = StandardOperator | BooleanColumnOperator | NullOperator | ColumnOperator | UnaryOperator | BinaryOperator | QueryFunctionOperator;
|
|
37
37
|
interface ODataFetch {
|
|
38
38
|
/**
|
|
39
39
|
* You can compose a FetchXML query for a specific table.
|
|
@@ -101,9 +101,40 @@ type StandardOperators = StandardEqualityOperators | 'gt' | 'ge' | 'lt' | 'le';
|
|
|
101
101
|
* * Microsoft Docs: {@link https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/query/filter-rows#column-comparison Column comparison }
|
|
102
102
|
*/
|
|
103
103
|
interface ColumnOperator {
|
|
104
|
-
|
|
104
|
+
/**
|
|
105
|
+
* The left side of the 'X' operator must be a property of the entity.
|
|
106
|
+
*/
|
|
107
|
+
left: string;
|
|
105
108
|
operator: StandardOperators;
|
|
106
|
-
|
|
109
|
+
isColumnOperation: true;
|
|
110
|
+
/**
|
|
111
|
+
* The right side of the 'X' operator must be a property of the entity.
|
|
112
|
+
*/
|
|
113
|
+
right: string;
|
|
114
|
+
}
|
|
115
|
+
interface BooleanColumnOperator {
|
|
116
|
+
/**
|
|
117
|
+
* The left side of the 'X' operator must be a property of the entity.
|
|
118
|
+
*/
|
|
119
|
+
left: string;
|
|
120
|
+
operator: StandardOperators;
|
|
121
|
+
isBooleanOperation: true;
|
|
122
|
+
/**
|
|
123
|
+
* The right side of the 'X' operator must be a property of the entity.
|
|
124
|
+
*/
|
|
125
|
+
right: boolean;
|
|
126
|
+
}
|
|
127
|
+
interface NullOperator {
|
|
128
|
+
/**
|
|
129
|
+
* The left side of the 'X' operator must be a property of the entity.
|
|
130
|
+
*/
|
|
131
|
+
left: string;
|
|
132
|
+
operator: StandardOperators;
|
|
133
|
+
isNullOperation: true;
|
|
134
|
+
/**
|
|
135
|
+
* The right side of the 'X' operator must be null.
|
|
136
|
+
*/
|
|
137
|
+
right: null;
|
|
107
138
|
}
|
|
108
139
|
interface StandardOperator {
|
|
109
140
|
operator: StandardOperators;
|
|
@@ -138,4 +169,4 @@ interface BinaryOperator {
|
|
|
138
169
|
right: FilterOperator;
|
|
139
170
|
}
|
|
140
171
|
type ODataQuery = ODataError & ODataExpand & ODataFetch & ODataFilter & ODataOrderBy & ODataSavedQuery & ODataSelect & ODataTop & ODataUserQuery;
|
|
141
|
-
export { BinaryOperator, ODataError, ODataExpand, ODataExpandQuery, ODataFetch, ODataFilter, ODataOrderBy, ODataQuery, ODataSavedQuery, ODataSelect, ODataTop, ODataUserQuery, StandardOperator, StandardOperators, UnaryOperator, };
|
|
172
|
+
export { BinaryOperator, ColumnOperator, FilterOperator, ODataError, ODataExpand, ODataExpandQuery, ODataFetch, ODataFilter, ODataOrderBy, ODataQuery, ODataSavedQuery, ODataSelect, ODataTop, ODataUserQuery, QueryFunctionOperator, QueryFunctionOperators, StandardOperator, StandardOperators, UnaryOperator, };
|
|
@@ -33,7 +33,7 @@ interface ODataFilter {
|
|
|
33
33
|
*/
|
|
34
34
|
$filter?: FilterOperator;
|
|
35
35
|
}
|
|
36
|
-
type FilterOperator = StandardOperator | ColumnOperator | UnaryOperator | BinaryOperator | QueryFunctionOperator;
|
|
36
|
+
type FilterOperator = StandardOperator | BooleanColumnOperator | NullOperator | ColumnOperator | UnaryOperator | BinaryOperator | QueryFunctionOperator;
|
|
37
37
|
interface ODataFetch {
|
|
38
38
|
/**
|
|
39
39
|
* You can compose a FetchXML query for a specific table.
|
|
@@ -101,9 +101,40 @@ type StandardOperators = StandardEqualityOperators | 'gt' | 'ge' | 'lt' | 'le';
|
|
|
101
101
|
* * Microsoft Docs: {@link https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/query/filter-rows#column-comparison Column comparison }
|
|
102
102
|
*/
|
|
103
103
|
interface ColumnOperator {
|
|
104
|
-
|
|
104
|
+
/**
|
|
105
|
+
* The left side of the 'X' operator must be a property of the entity.
|
|
106
|
+
*/
|
|
107
|
+
left: string;
|
|
105
108
|
operator: StandardOperators;
|
|
106
|
-
|
|
109
|
+
isColumnOperation: true;
|
|
110
|
+
/**
|
|
111
|
+
* The right side of the 'X' operator must be a property of the entity.
|
|
112
|
+
*/
|
|
113
|
+
right: string;
|
|
114
|
+
}
|
|
115
|
+
interface BooleanColumnOperator {
|
|
116
|
+
/**
|
|
117
|
+
* The left side of the 'X' operator must be a property of the entity.
|
|
118
|
+
*/
|
|
119
|
+
left: string;
|
|
120
|
+
operator: StandardOperators;
|
|
121
|
+
isBooleanOperation: true;
|
|
122
|
+
/**
|
|
123
|
+
* The right side of the 'X' operator must be a property of the entity.
|
|
124
|
+
*/
|
|
125
|
+
right: boolean;
|
|
126
|
+
}
|
|
127
|
+
interface NullOperator {
|
|
128
|
+
/**
|
|
129
|
+
* The left side of the 'X' operator must be a property of the entity.
|
|
130
|
+
*/
|
|
131
|
+
left: string;
|
|
132
|
+
operator: StandardOperators;
|
|
133
|
+
isNullOperation: true;
|
|
134
|
+
/**
|
|
135
|
+
* The right side of the 'X' operator must be null.
|
|
136
|
+
*/
|
|
137
|
+
right: null;
|
|
107
138
|
}
|
|
108
139
|
interface StandardOperator {
|
|
109
140
|
operator: StandardOperators;
|
|
@@ -138,4 +169,4 @@ interface BinaryOperator {
|
|
|
138
169
|
right: FilterOperator;
|
|
139
170
|
}
|
|
140
171
|
type ODataQuery = ODataError & ODataExpand & ODataFetch & ODataFilter & ODataOrderBy & ODataSavedQuery & ODataSelect & ODataTop & ODataUserQuery;
|
|
141
|
-
export type { BinaryOperator, ODataError, ODataExpand, ODataExpandQuery, ODataFetch, ODataFilter, ODataOrderBy, ODataQuery, ODataSavedQuery, ODataSelect, ODataTop, ODataUserQuery, StandardOperator, StandardOperators, UnaryOperator, };
|
|
172
|
+
export type { BinaryOperator, ColumnOperator, FilterOperator, ODataError, ODataExpand, ODataExpandQuery, ODataFetch, ODataFilter, ODataOrderBy, ODataQuery, ODataSavedQuery, ODataSelect, ODataTop, ODataUserQuery, QueryFunctionOperator, QueryFunctionOperators, StandardOperator, StandardOperators, UnaryOperator, };
|
|
@@ -37,7 +37,14 @@ interface ODataFilter {
|
|
|
37
37
|
$filter?: FilterOperator;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
type FilterOperator =
|
|
40
|
+
type FilterOperator =
|
|
41
|
+
| StandardOperator
|
|
42
|
+
| BooleanColumnOperator
|
|
43
|
+
| NullOperator
|
|
44
|
+
| ColumnOperator
|
|
45
|
+
| UnaryOperator
|
|
46
|
+
| BinaryOperator
|
|
47
|
+
| QueryFunctionOperator;
|
|
41
48
|
|
|
42
49
|
interface ODataFetch {
|
|
43
50
|
/**
|
|
@@ -107,15 +114,47 @@ type StandardEqualityOperators = 'eq' | 'ne';
|
|
|
107
114
|
type StandardOperators = StandardEqualityOperators | 'gt' | 'ge' | 'lt' | 'le';
|
|
108
115
|
|
|
109
116
|
/**
|
|
110
|
-
*
|
|
117
|
+
*
|
|
111
118
|
* * Microsoft Docs: {@link https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/query/filter-rows#column-comparison Column comparison }
|
|
112
119
|
*/
|
|
113
120
|
interface ColumnOperator {
|
|
114
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The left side of the 'X' operator must be a property of the entity.
|
|
123
|
+
*/
|
|
124
|
+
left: string;
|
|
125
|
+
operator: StandardOperators;
|
|
126
|
+
isColumnOperation: true;
|
|
127
|
+
/**
|
|
128
|
+
* The right side of the 'X' operator must be a property of the entity.
|
|
129
|
+
*/
|
|
130
|
+
right: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface BooleanColumnOperator {
|
|
134
|
+
/**
|
|
135
|
+
* The left side of the 'X' operator must be a property of the entity.
|
|
136
|
+
*/
|
|
137
|
+
left: string;
|
|
115
138
|
operator: StandardOperators;
|
|
116
|
-
|
|
139
|
+
isBooleanOperation: true;
|
|
140
|
+
/**
|
|
141
|
+
* The right side of the 'X' operator must be a property of the entity.
|
|
142
|
+
*/
|
|
143
|
+
right: boolean;
|
|
117
144
|
}
|
|
118
145
|
|
|
146
|
+
interface NullOperator {
|
|
147
|
+
/**
|
|
148
|
+
* The left side of the 'X' operator must be a property of the entity.
|
|
149
|
+
*/
|
|
150
|
+
left: string;
|
|
151
|
+
operator: StandardOperators;
|
|
152
|
+
isNullOperation: true;
|
|
153
|
+
/**
|
|
154
|
+
* The right side of the 'X' operator must be null.
|
|
155
|
+
*/
|
|
156
|
+
right: null;
|
|
157
|
+
}
|
|
119
158
|
|
|
120
159
|
interface StandardOperator {
|
|
121
160
|
operator: StandardOperators;
|
|
@@ -166,6 +205,8 @@ type ODataQuery = ODataError &
|
|
|
166
205
|
|
|
167
206
|
export type {
|
|
168
207
|
BinaryOperator,
|
|
208
|
+
ColumnOperator,
|
|
209
|
+
FilterOperator,
|
|
169
210
|
ODataError,
|
|
170
211
|
ODataExpand,
|
|
171
212
|
ODataExpandQuery,
|
|
@@ -177,7 +218,9 @@ export type {
|
|
|
177
218
|
ODataSelect,
|
|
178
219
|
ODataTop,
|
|
179
220
|
ODataUserQuery,
|
|
221
|
+
QueryFunctionOperator,
|
|
222
|
+
QueryFunctionOperators,
|
|
180
223
|
StandardOperator,
|
|
181
224
|
StandardOperators,
|
|
182
|
-
UnaryOperator,
|
|
225
|
+
UnaryOperator,
|
|
183
226
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"getFilterFromParser.d.ts","sourceRoot":"","sources":["../../src/getFilterFromParser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,
|
|
1
|
+
{"version":3,"file":"getFilterFromParser.d.ts","sourceRoot":"","sources":["../../src/getFilterFromParser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAkB,MAAM,eAAe,CAAC;AAuMhE;;;GAGG;AACH,eAAO,MAAM,mBAAmB,GAAI,QAAQ,eAAe,EAAE,QAAQ,UAAU,KAAG,OAoBjF,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shko.online/dataverse-odata",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "This package will help parse OData strings (only the Microsoft Dataverse subset). It can be used as a validator, or you can build some javascript library which consumes the output of this library.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"build": "npm run lint && build-npm-package",
|
package/src/OData.types.d.ts
CHANGED
|
@@ -37,7 +37,14 @@ interface ODataFilter {
|
|
|
37
37
|
$filter?: FilterOperator;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
type FilterOperator =
|
|
40
|
+
type FilterOperator =
|
|
41
|
+
| StandardOperator
|
|
42
|
+
| BooleanColumnOperator
|
|
43
|
+
| NullOperator
|
|
44
|
+
| ColumnOperator
|
|
45
|
+
| UnaryOperator
|
|
46
|
+
| BinaryOperator
|
|
47
|
+
| QueryFunctionOperator;
|
|
41
48
|
|
|
42
49
|
interface ODataFetch {
|
|
43
50
|
/**
|
|
@@ -107,15 +114,47 @@ type StandardEqualityOperators = 'eq' | 'ne';
|
|
|
107
114
|
type StandardOperators = StandardEqualityOperators | 'gt' | 'ge' | 'lt' | 'le';
|
|
108
115
|
|
|
109
116
|
/**
|
|
110
|
-
*
|
|
117
|
+
*
|
|
111
118
|
* * Microsoft Docs: {@link https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/query/filter-rows#column-comparison Column comparison }
|
|
112
119
|
*/
|
|
113
120
|
interface ColumnOperator {
|
|
114
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The left side of the 'X' operator must be a property of the entity.
|
|
123
|
+
*/
|
|
124
|
+
left: string;
|
|
125
|
+
operator: StandardOperators;
|
|
126
|
+
isColumnOperation: true;
|
|
127
|
+
/**
|
|
128
|
+
* The right side of the 'X' operator must be a property of the entity.
|
|
129
|
+
*/
|
|
130
|
+
right: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface BooleanColumnOperator {
|
|
134
|
+
/**
|
|
135
|
+
* The left side of the 'X' operator must be a property of the entity.
|
|
136
|
+
*/
|
|
137
|
+
left: string;
|
|
115
138
|
operator: StandardOperators;
|
|
116
|
-
|
|
139
|
+
isBooleanOperation: true;
|
|
140
|
+
/**
|
|
141
|
+
* The right side of the 'X' operator must be a property of the entity.
|
|
142
|
+
*/
|
|
143
|
+
right: boolean;
|
|
117
144
|
}
|
|
118
145
|
|
|
146
|
+
interface NullOperator {
|
|
147
|
+
/**
|
|
148
|
+
* The left side of the 'X' operator must be a property of the entity.
|
|
149
|
+
*/
|
|
150
|
+
left: string;
|
|
151
|
+
operator: StandardOperators;
|
|
152
|
+
isNullOperation: true;
|
|
153
|
+
/**
|
|
154
|
+
* The right side of the 'X' operator must be null.
|
|
155
|
+
*/
|
|
156
|
+
right: null;
|
|
157
|
+
}
|
|
119
158
|
|
|
120
159
|
interface StandardOperator {
|
|
121
160
|
operator: StandardOperators;
|
|
@@ -166,6 +205,8 @@ type ODataQuery = ODataError &
|
|
|
166
205
|
|
|
167
206
|
export type {
|
|
168
207
|
BinaryOperator,
|
|
208
|
+
ColumnOperator,
|
|
209
|
+
FilterOperator,
|
|
169
210
|
ODataError,
|
|
170
211
|
ODataExpand,
|
|
171
212
|
ODataExpandQuery,
|
|
@@ -177,7 +218,9 @@ export type {
|
|
|
177
218
|
ODataSelect,
|
|
178
219
|
ODataTop,
|
|
179
220
|
ODataUserQuery,
|
|
221
|
+
QueryFunctionOperator,
|
|
222
|
+
QueryFunctionOperators,
|
|
180
223
|
StandardOperator,
|
|
181
224
|
StandardOperators,
|
|
182
|
-
UnaryOperator,
|
|
225
|
+
UnaryOperator,
|
|
183
226
|
};
|
|
@@ -1,8 +1,202 @@
|
|
|
1
|
-
import type { ODataQuery,
|
|
1
|
+
import type { ODataQuery, FilterOperator } from './OData.types';
|
|
2
2
|
import { atMostOnce } from './validators/atMostOnce';
|
|
3
|
+
import { hasContent } from './validators/hasContent';
|
|
3
4
|
|
|
4
5
|
const option = '$filter';
|
|
5
6
|
|
|
7
|
+
const STANDARD_OPERATORS = ['eq', 'ne', 'gt', 'ge', 'lt', 'le'] as const;
|
|
8
|
+
const QUERY_FUNCTION_OPERATORS = ['contains', 'endswith', 'startswith'] as const;
|
|
9
|
+
|
|
10
|
+
type StandardOp = (typeof STANDARD_OPERATORS)[number];
|
|
11
|
+
type QueryFunctionOp = (typeof QUERY_FUNCTION_OPERATORS)[number];
|
|
12
|
+
|
|
13
|
+
function isStandardOperator(s: string): s is StandardOp {
|
|
14
|
+
return (STANDARD_OPERATORS as readonly string[]).includes(s);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isQueryFunctionOperator(s: string): s is QueryFunctionOp {
|
|
18
|
+
return (QUERY_FUNCTION_OPERATORS as readonly string[]).includes(s);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type TokenType = 'lparen' | 'rparen' | 'comma' | 'word' | 'string' | 'number';
|
|
22
|
+
|
|
23
|
+
interface Token {
|
|
24
|
+
type: TokenType;
|
|
25
|
+
value: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const GUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/;
|
|
29
|
+
|
|
30
|
+
function tokenize(input: string): Token[] {
|
|
31
|
+
const tokens: Token[] = [];
|
|
32
|
+
let i = 0;
|
|
33
|
+
while (i < input.length) {
|
|
34
|
+
if (/\s/.test(input[i])) {
|
|
35
|
+
i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (input[i] === '(') {
|
|
39
|
+
tokens.push({ type: 'lparen', value: '(' });
|
|
40
|
+
i++;
|
|
41
|
+
} else if (input[i] === ')') {
|
|
42
|
+
tokens.push({ type: 'rparen', value: ')' });
|
|
43
|
+
i++;
|
|
44
|
+
} else if (input[i] === ',') {
|
|
45
|
+
tokens.push({ type: 'comma', value: ',' });
|
|
46
|
+
i++;
|
|
47
|
+
} else if (input[i] === "'") {
|
|
48
|
+
let j = i + 1;
|
|
49
|
+
let str = '';
|
|
50
|
+
while (j < input.length) {
|
|
51
|
+
if (input[j] === "'" && j + 1 < input.length && input[j + 1] === "'") {
|
|
52
|
+
str += "'";
|
|
53
|
+
j += 2;
|
|
54
|
+
} else if (input[j] === "'") {
|
|
55
|
+
break;
|
|
56
|
+
} else {
|
|
57
|
+
str += input[j];
|
|
58
|
+
j++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
tokens.push({ type: 'string', value: str });
|
|
62
|
+
i = j + 1;
|
|
63
|
+
} else if (/[0-9a-fA-F]/.test(input[i]) && GUID_REGEX.test(input.slice(i))) {
|
|
64
|
+
tokens.push({ type: 'string', value: input.slice(i, i + 36) });
|
|
65
|
+
i += 36;
|
|
66
|
+
} else if (/[0-9]/.test(input[i]) || (input[i] === '-' && /[0-9]/.test(input[i + 1] ?? ''))) {
|
|
67
|
+
let j = i;
|
|
68
|
+
if (input[j] === '-') j++;
|
|
69
|
+
while (j < input.length && /[0-9.]/.test(input[j])) j++;
|
|
70
|
+
tokens.push({ type: 'number', value: input.slice(i, j) });
|
|
71
|
+
i = j;
|
|
72
|
+
} else if (/[a-zA-Z_]/.test(input[i])) {
|
|
73
|
+
let j = i;
|
|
74
|
+
while (j < input.length && /[a-zA-Z0-9_]/.test(input[j])) j++;
|
|
75
|
+
tokens.push({ type: 'word', value: input.slice(i, j) });
|
|
76
|
+
i = j;
|
|
77
|
+
} else {
|
|
78
|
+
i++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return tokens;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
class FilterParser {
|
|
85
|
+
private tokens: Token[];
|
|
86
|
+
private pos = 0;
|
|
87
|
+
|
|
88
|
+
constructor(tokens: Token[]) {
|
|
89
|
+
this.tokens = tokens;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private peek(): Token | null {
|
|
93
|
+
return this.tokens[this.pos] ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private consume(expected?: string): Token {
|
|
97
|
+
const token = this.tokens[this.pos++];
|
|
98
|
+
if (!token) throw new Error(`Unexpected end of filter expression${expected ? `, expected ${expected}` : ''}`);
|
|
99
|
+
return token;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private expect(type: TokenType, value?: string): Token {
|
|
103
|
+
const token = this.consume(`${type}${value ? ` '${value}'` : ''}`);
|
|
104
|
+
if (token.type !== type || (value !== undefined && token.value !== value)) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Expected ${type}${value ? ` '${value}'` : ''} but got ${token.type} '${token.value}'`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return token;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
parse(): FilterOperator {
|
|
113
|
+
const expr = this.parseOr();
|
|
114
|
+
if (this.pos < this.tokens.length) {
|
|
115
|
+
throw new Error(`Unexpected token '${this.tokens[this.pos].value}'`);
|
|
116
|
+
}
|
|
117
|
+
return expr;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private parseOr(): FilterOperator {
|
|
121
|
+
let left = this.parseAnd();
|
|
122
|
+
while (this.peek()?.value === 'or') {
|
|
123
|
+
this.consume();
|
|
124
|
+
const right = this.parseAnd();
|
|
125
|
+
left = { operator: 'or', left, right };
|
|
126
|
+
}
|
|
127
|
+
return left;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private parseAnd(): FilterOperator {
|
|
131
|
+
let left = this.parseNot();
|
|
132
|
+
while (this.peek()?.value === 'and') {
|
|
133
|
+
this.consume();
|
|
134
|
+
const right = this.parseNot();
|
|
135
|
+
left = { operator: 'and', left, right };
|
|
136
|
+
}
|
|
137
|
+
return left;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private parseNot(): FilterOperator {
|
|
141
|
+
if (this.peek()?.value === 'not') {
|
|
142
|
+
this.consume();
|
|
143
|
+
const right = this.parseNot();
|
|
144
|
+
return { operator: 'not', right };
|
|
145
|
+
}
|
|
146
|
+
return this.parsePrimary();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private parsePrimary(): FilterOperator {
|
|
150
|
+
const token = this.peek();
|
|
151
|
+
if (!token) throw new Error('Unexpected end of filter expression');
|
|
152
|
+
|
|
153
|
+
if (token.type === 'lparen') {
|
|
154
|
+
this.consume();
|
|
155
|
+
const expr = this.parseOr();
|
|
156
|
+
this.expect('rparen');
|
|
157
|
+
return expr;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (token.type === 'word' && isQueryFunctionOperator(token.value.toLowerCase())) {
|
|
161
|
+
const func = this.consume().value.toLowerCase() as QueryFunctionOp;
|
|
162
|
+
this.expect('lparen');
|
|
163
|
+
const left = this.expect('word').value;
|
|
164
|
+
this.expect('comma');
|
|
165
|
+
const right = this.expect('string').value;
|
|
166
|
+
this.expect('rparen');
|
|
167
|
+
return { operator: func, left, right };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (token.type === 'word') {
|
|
171
|
+
const left = this.consume().value;
|
|
172
|
+
const opToken = this.consume(`a comparison operator`);
|
|
173
|
+
if (!isStandardOperator(opToken.value.toLowerCase())) {
|
|
174
|
+
throw new Error(`Invalid operator '${opToken.value}'`);
|
|
175
|
+
}
|
|
176
|
+
const operator = opToken.value.toLowerCase() as StandardOp;
|
|
177
|
+
const right = this.consume(`a value or column name`);
|
|
178
|
+
if (right.type === 'string') {
|
|
179
|
+
return { operator, left, right: right.value };
|
|
180
|
+
} else if (right.type === 'number') {
|
|
181
|
+
return { operator, left, right: Number(right.value) };
|
|
182
|
+
} else if (right.type === 'word') {
|
|
183
|
+
// Constant keywords stay as StandardOperator; bare identifiers are column comparisons
|
|
184
|
+
const BOOL_CONSTANTS = ['true', 'false'];
|
|
185
|
+
if (BOOL_CONSTANTS.includes(right.value.toLowerCase())) {
|
|
186
|
+
return { operator, left, isBooleanOperation: true, right: right.value === 'true' };
|
|
187
|
+
}else if (right.value.toLowerCase() === 'null') {
|
|
188
|
+
return { operator, left, isNullOperation: true, right: null };
|
|
189
|
+
}
|
|
190
|
+
return { left, operator, isColumnOperation: true, right: right.value };
|
|
191
|
+
} else {
|
|
192
|
+
throw new Error(`Invalid right-hand side value of type '${right.type}' in filter expression`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
throw new Error(`Unexpected token '${token.value}'`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
6
200
|
/**
|
|
7
201
|
* Parses the {@link ODataFilter.$filter $filter} query
|
|
8
202
|
* @returns {boolean} Returns `false` when the parse has an error
|
|
@@ -12,12 +206,19 @@ export const getFilterFromParser = (parser: URLSearchParams, result: ODataQuery)
|
|
|
12
206
|
if (value.length === 0) {
|
|
13
207
|
return true;
|
|
14
208
|
}
|
|
15
|
-
if (!atMostOnce(option, value, result)) {
|
|
209
|
+
if (!atMostOnce(option, value, result) || !hasContent(option, value, result)) {
|
|
16
210
|
return false;
|
|
17
211
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
212
|
+
try {
|
|
213
|
+
const tokens = tokenize(value[0]);
|
|
214
|
+
const p = new FilterParser(tokens);
|
|
215
|
+
result.$filter = p.parse();
|
|
216
|
+
} catch (e) {
|
|
217
|
+
result.error = {
|
|
218
|
+
code: '0x80060888',
|
|
219
|
+
message: `Syntax error in '$filter': ${(e as Error).message}`,
|
|
220
|
+
};
|
|
221
|
+
return false;
|
|
21
222
|
}
|
|
22
223
|
return true;
|
|
23
224
|
};
|