@nestjs-odata/core 1.0.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/README.md +55 -0
- package/dist/index.cjs +2688 -0
- package/dist/index.d.cts +1402 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +1402 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2565 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +74 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2688 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region \0rolldown/runtime.js
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
11
|
+
key = keys[i];
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
13
|
+
get: ((k) => from[k]).bind(null, key),
|
|
14
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
20
|
+
value: mod,
|
|
21
|
+
enumerable: true
|
|
22
|
+
}) : target, mod));
|
|
23
|
+
//#endregion
|
|
24
|
+
let _nestjs_common = require("@nestjs/common");
|
|
25
|
+
let pluralize = require("pluralize");
|
|
26
|
+
pluralize = __toESM(pluralize);
|
|
27
|
+
require("reflect-metadata");
|
|
28
|
+
let _nestjs_core = require("@nestjs/core");
|
|
29
|
+
let rxjs_operators = require("rxjs/operators");
|
|
30
|
+
let _nestjs_common_constants_js = require("@nestjs/common/constants.js");
|
|
31
|
+
//#region src/query/odata-validation.error.ts
|
|
32
|
+
/**
|
|
33
|
+
* OData validation error types.
|
|
34
|
+
* ODataValidationError is distinct from ODataParseError — it represents
|
|
35
|
+
* semantic validation failures (unknown properties, type mismatches) rather
|
|
36
|
+
* than syntax errors.
|
|
37
|
+
*
|
|
38
|
+
* Per threat model T-03-02: includes property name and entity type name
|
|
39
|
+
* (user's own query input) but never stack traces or internal paths.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* Thrown when field name validation fails against the EdmRegistry.
|
|
43
|
+
*
|
|
44
|
+
* The `entityTypeName` and `propertyName` fields provide diagnostic context
|
|
45
|
+
* for the user's own query (not internal server data).
|
|
46
|
+
*/
|
|
47
|
+
var ODataValidationError = class extends Error {
|
|
48
|
+
constructor(message, entityTypeName, propertyName, availableProperties) {
|
|
49
|
+
const enrichedMessage = availableProperties && availableProperties.length > 0 ? `${message}. Available properties: ${availableProperties.join(", ")}` : message;
|
|
50
|
+
super(enrichedMessage);
|
|
51
|
+
this.entityTypeName = entityTypeName;
|
|
52
|
+
this.propertyName = propertyName;
|
|
53
|
+
this.availableProperties = availableProperties;
|
|
54
|
+
this.name = "ODataValidationError";
|
|
55
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/batch/batch-parser.ts
|
|
60
|
+
/**
|
|
61
|
+
* OData v4 $batch multipart/mixed body parser.
|
|
62
|
+
*
|
|
63
|
+
* Custom parser with zero external dependencies per D-01.
|
|
64
|
+
* Implements OData v4 Part 1 section 11 wire format.
|
|
65
|
+
*
|
|
66
|
+
* Security (T-05-01): validates boundary format, throws ODataValidationError
|
|
67
|
+
* on malformed input — never passes invalid data through.
|
|
68
|
+
*/
|
|
69
|
+
/**
|
|
70
|
+
* Maximum number of operations allowed in a single batch request (T-05-02).
|
|
71
|
+
* Prevents unbounded transaction size and DoS via large batches.
|
|
72
|
+
*/
|
|
73
|
+
const MAX_BATCH_OPERATIONS = 100;
|
|
74
|
+
/**
|
|
75
|
+
* Extract the boundary value from a Content-Type header.
|
|
76
|
+
*
|
|
77
|
+
* Handles both quoted (boundary="abc") and unquoted (boundary=abc) formats.
|
|
78
|
+
* Throws ODataValidationError if the boundary parameter is missing.
|
|
79
|
+
*
|
|
80
|
+
* @param contentType - The Content-Type header value (e.g., 'multipart/mixed; boundary=batch_abc123')
|
|
81
|
+
* @returns The boundary string value
|
|
82
|
+
* @throws ODataValidationError if boundary parameter is missing
|
|
83
|
+
*/
|
|
84
|
+
function extractBoundary(contentType) {
|
|
85
|
+
const match = /boundary=(?:"([^"]+)"|([^\s;]+))/i.exec(contentType);
|
|
86
|
+
if (!match) throw new ODataValidationError("Missing boundary parameter in Content-Type header for $batch request", "$batch", "boundary");
|
|
87
|
+
return match[1] ?? match[2] ?? "";
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Parse a multipart/mixed batch body into structured BatchPart objects.
|
|
91
|
+
*
|
|
92
|
+
* Handles:
|
|
93
|
+
* - Individual request parts (kind: 'request')
|
|
94
|
+
* - Changesets (kind: 'changeset') containing sub-request parts
|
|
95
|
+
* - Both CRLF (\r\n) and LF (\n) line endings
|
|
96
|
+
* - Content-ID headers for changeset sub-parts
|
|
97
|
+
* - Embedded HTTP request parsing (METHOD URL HTTP/1.1, headers, body)
|
|
98
|
+
*
|
|
99
|
+
* Per T-05-02: rejects batches exceeding MAX_BATCH_OPERATIONS operations.
|
|
100
|
+
*
|
|
101
|
+
* @param body - The raw request body string
|
|
102
|
+
* @param boundary - The multipart boundary (from extractBoundary())
|
|
103
|
+
* @returns ParsedBatch with all parsed parts
|
|
104
|
+
* @throws ODataValidationError if body is malformed or exceeds limits
|
|
105
|
+
*/
|
|
106
|
+
function parseBatchBody(body, boundary) {
|
|
107
|
+
if (!body || body.trim() === "") return {
|
|
108
|
+
boundary,
|
|
109
|
+
parts: []
|
|
110
|
+
};
|
|
111
|
+
const parts = splitByBoundary(body.replace(/\r\n/g, "\n"), boundary);
|
|
112
|
+
let totalOperations = 0;
|
|
113
|
+
const batchParts = [];
|
|
114
|
+
for (const part of parts) {
|
|
115
|
+
const trimmed = part.trim();
|
|
116
|
+
if (!trimmed) continue;
|
|
117
|
+
const parsed = parsePart(trimmed);
|
|
118
|
+
if (parsed) {
|
|
119
|
+
if (parsed.kind === "request") totalOperations++;
|
|
120
|
+
else if (parsed.kind === "changeset") totalOperations += parsed.parts.length;
|
|
121
|
+
if (totalOperations > 100) throw new ODataValidationError(`Batch request exceeds maximum of 100 operations`, "$batch", "operations");
|
|
122
|
+
batchParts.push(parsed);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
boundary,
|
|
127
|
+
parts: batchParts
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Split a normalized (LF-only) body by the multipart boundary.
|
|
132
|
+
* Returns the content segments between boundary markers.
|
|
133
|
+
*/
|
|
134
|
+
function splitByBoundary(body, boundary) {
|
|
135
|
+
const delimiter = `--${boundary}`;
|
|
136
|
+
const terminator = `--${boundary}--`;
|
|
137
|
+
const lines = body.split("\n");
|
|
138
|
+
const segments = [];
|
|
139
|
+
let currentSegment = [];
|
|
140
|
+
let inSegment = false;
|
|
141
|
+
for (const line of lines) {
|
|
142
|
+
const trimmedLine = line.trimEnd();
|
|
143
|
+
if (trimmedLine === terminator) {
|
|
144
|
+
if (inSegment && currentSegment.length > 0) segments.push(currentSegment.join("\n"));
|
|
145
|
+
inSegment = false;
|
|
146
|
+
currentSegment = [];
|
|
147
|
+
break;
|
|
148
|
+
} else if (trimmedLine === delimiter) {
|
|
149
|
+
if (inSegment && currentSegment.length > 0) {
|
|
150
|
+
segments.push(currentSegment.join("\n"));
|
|
151
|
+
currentSegment = [];
|
|
152
|
+
}
|
|
153
|
+
inSegment = true;
|
|
154
|
+
} else if (inSegment) currentSegment.push(line);
|
|
155
|
+
}
|
|
156
|
+
if (inSegment && currentSegment.length > 0) segments.push(currentSegment.join("\n"));
|
|
157
|
+
return segments;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Parse a single multipart segment into a BatchPart.
|
|
161
|
+
* Detects whether it's a changeset or an individual request.
|
|
162
|
+
*/
|
|
163
|
+
function parsePart(segment) {
|
|
164
|
+
const blankLineIdx = segment.indexOf("\n\n");
|
|
165
|
+
if (blankLineIdx === -1) throw new ODataValidationError("Malformed batch part: missing blank line separator between headers and content", "$batch", "part");
|
|
166
|
+
const headerSection = segment.slice(0, blankLineIdx);
|
|
167
|
+
const content = segment.slice(blankLineIdx + 2);
|
|
168
|
+
const partHeaders = parseMimeHeaders(headerSection);
|
|
169
|
+
const contentType = getHeaderValue(partHeaders, "content-type");
|
|
170
|
+
if (contentType && contentType.toLowerCase().startsWith("multipart/mixed")) return parseChangeset(content, contentType);
|
|
171
|
+
else return parseRequestPart(content, partHeaders);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Parse a changeset part (kind: 'changeset') by recursively parsing its sub-parts.
|
|
175
|
+
*/
|
|
176
|
+
function parseChangeset(content, changesetContentType) {
|
|
177
|
+
const changesetBoundary = extractBoundary(changesetContentType);
|
|
178
|
+
const subSegments = splitByBoundary(content.trim(), changesetBoundary);
|
|
179
|
+
const subParts = [];
|
|
180
|
+
for (const subSegment of subSegments) {
|
|
181
|
+
const trimmed = subSegment.trim();
|
|
182
|
+
if (!trimmed) continue;
|
|
183
|
+
const blankLineIdx = trimmed.indexOf("\n\n");
|
|
184
|
+
if (blankLineIdx === -1) throw new ODataValidationError("Malformed changeset sub-part: missing blank line separator", "$batch", "changeset-part");
|
|
185
|
+
const subHeaderSection = trimmed.slice(0, blankLineIdx);
|
|
186
|
+
const requestPart = parseRequestPart(trimmed.slice(blankLineIdx + 2), parseMimeHeaders(subHeaderSection));
|
|
187
|
+
if (requestPart) subParts.push(requestPart);
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
kind: "changeset",
|
|
191
|
+
parts: subParts
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Parse an individual HTTP request embedded in a batch part.
|
|
196
|
+
*
|
|
197
|
+
* The content is an embedded HTTP request:
|
|
198
|
+
* METHOD URL HTTP/1.1\n
|
|
199
|
+
* Header-Name: value\n
|
|
200
|
+
* \n
|
|
201
|
+
* [optional body]
|
|
202
|
+
*/
|
|
203
|
+
function parseRequestPart(content, mimeHeaders) {
|
|
204
|
+
const lines = content.trim().split("\n");
|
|
205
|
+
if (lines.length === 0 || !lines[0]) throw new ODataValidationError("Malformed batch sub-request: empty content", "$batch", "request");
|
|
206
|
+
const requestLine = lines[0].trimEnd();
|
|
207
|
+
const requestLineMatch = /^(\S+)\s+(\S+)(?:\s+HTTP\/\S+)?$/.exec(requestLine);
|
|
208
|
+
if (!requestLineMatch) throw new ODataValidationError(`Malformed batch sub-request line: '${requestLine}'`, "$batch", "request-line");
|
|
209
|
+
const method = requestLineMatch[1] ?? "";
|
|
210
|
+
const url = requestLineMatch[2] ?? "";
|
|
211
|
+
const httpHeaders = {};
|
|
212
|
+
let bodyStartIdx = 1;
|
|
213
|
+
for (let i = 1; i < lines.length; i++) {
|
|
214
|
+
const line = lines[i].trimEnd();
|
|
215
|
+
if (line === "") {
|
|
216
|
+
bodyStartIdx = i + 1;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
const colonIdx = line.indexOf(":");
|
|
220
|
+
if (colonIdx !== -1) {
|
|
221
|
+
const headerName = line.slice(0, colonIdx).trim().toLowerCase();
|
|
222
|
+
httpHeaders[headerName] = line.slice(colonIdx + 1).trim();
|
|
223
|
+
}
|
|
224
|
+
bodyStartIdx = i + 1;
|
|
225
|
+
}
|
|
226
|
+
const body = lines.slice(bodyStartIdx).join("\n").trim() || void 0;
|
|
227
|
+
const contentId = getHeaderValue(mimeHeaders, "content-id");
|
|
228
|
+
return {
|
|
229
|
+
kind: "request",
|
|
230
|
+
contentId: contentId !== void 0 ? contentId.replace(/^<|>$/g, "") : void 0,
|
|
231
|
+
method: method.toUpperCase(),
|
|
232
|
+
url,
|
|
233
|
+
headers: httpHeaders,
|
|
234
|
+
body
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Parse MIME-style headers from a header section string.
|
|
239
|
+
* Returns a map of lowercase header names to values.
|
|
240
|
+
*/
|
|
241
|
+
function parseMimeHeaders(headerSection) {
|
|
242
|
+
const headers = {};
|
|
243
|
+
const lines = headerSection.split("\n");
|
|
244
|
+
for (const line of lines) {
|
|
245
|
+
const trimmedLine = line.trimEnd();
|
|
246
|
+
if (!trimmedLine) continue;
|
|
247
|
+
const colonIdx = trimmedLine.indexOf(":");
|
|
248
|
+
if (colonIdx !== -1) {
|
|
249
|
+
const name = trimmedLine.slice(0, colonIdx).trim().toLowerCase();
|
|
250
|
+
headers[name] = trimmedLine.slice(colonIdx + 1).trim();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return headers;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Get a header value by name (case-insensitive lookup).
|
|
257
|
+
* Returns undefined if not found.
|
|
258
|
+
*/
|
|
259
|
+
function getHeaderValue(headers, name) {
|
|
260
|
+
return headers[name.toLowerCase()];
|
|
261
|
+
}
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region src/parser/visitor.ts
|
|
264
|
+
/**
|
|
265
|
+
* Dispatch a FilterNode to the appropriate visitor method.
|
|
266
|
+
* Convenience function to avoid writing switch statements in every visitor.
|
|
267
|
+
*/
|
|
268
|
+
function acceptVisitor(node, visitor) {
|
|
269
|
+
switch (node.kind) {
|
|
270
|
+
case "BinaryExpr": return visitor.visitBinaryExpr(node);
|
|
271
|
+
case "UnaryExpr": return visitor.visitUnaryExpr(node);
|
|
272
|
+
case "FunctionCall": return visitor.visitFunctionCall(node);
|
|
273
|
+
case "LambdaExpr": return visitor.visitLambdaExpr(node);
|
|
274
|
+
case "PropertyAccess": return visitor.visitPropertyAccess(node);
|
|
275
|
+
case "Literal": return visitor.visitLiteral(node);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
279
|
+
//#region src/parser/errors.ts
|
|
280
|
+
/**
|
|
281
|
+
* OData parser error types.
|
|
282
|
+
* ODataParseError carries position information for diagnostic messages.
|
|
283
|
+
*/
|
|
284
|
+
/**
|
|
285
|
+
* Thrown when the lexer or parser encounters malformed OData query syntax.
|
|
286
|
+
*
|
|
287
|
+
* The `position` field indicates the character offset in the input string
|
|
288
|
+
* where the error was detected. The `token` field (if available) holds
|
|
289
|
+
* the token that triggered the error.
|
|
290
|
+
*
|
|
291
|
+
* Note: position info is intentionally included — the input is the user's own
|
|
292
|
+
* query string, not server-internal data, so there is no information disclosure risk.
|
|
293
|
+
*/
|
|
294
|
+
var ODataParseError = class ODataParseError extends Error {
|
|
295
|
+
constructor(message, position, token = null, queryContext) {
|
|
296
|
+
super(message);
|
|
297
|
+
this.position = position;
|
|
298
|
+
this.token = token;
|
|
299
|
+
this.queryContext = queryContext;
|
|
300
|
+
this.name = "ODataParseError";
|
|
301
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Create an ODataParseError with a context snippet extracted from the query string.
|
|
305
|
+
* Extracts ~20 characters before and after the error position for diagnostic context.
|
|
306
|
+
*
|
|
307
|
+
* Per threat model T-12-05: context snippet shows only the user's own query input
|
|
308
|
+
* (already known to them) — never includes stack traces or internal paths.
|
|
309
|
+
*
|
|
310
|
+
* @param message - Base error message
|
|
311
|
+
* @param position - Character offset where the error was detected
|
|
312
|
+
* @param queryString - The full query string for context extraction
|
|
313
|
+
* @param token - Optional token that triggered the error
|
|
314
|
+
*/
|
|
315
|
+
static withContext(message, position, queryString, token = null) {
|
|
316
|
+
const contextRadius = 20;
|
|
317
|
+
const start = Math.max(0, position - contextRadius);
|
|
318
|
+
const end = Math.min(queryString.length, position + contextRadius);
|
|
319
|
+
const prefix = start > 0 ? "..." : "";
|
|
320
|
+
const suffix = end < queryString.length ? "..." : "";
|
|
321
|
+
const snippet = `${prefix}${queryString.slice(start, end)}${suffix}`;
|
|
322
|
+
return new ODataParseError(`${message} at position ${position}: ${snippet}`, position, token, snippet);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
//#endregion
|
|
326
|
+
//#region src/parser/lexer.ts
|
|
327
|
+
/**
|
|
328
|
+
* OData v4 Lexer (tokenizer).
|
|
329
|
+
* Converts an OData query expression string into a token stream.
|
|
330
|
+
*
|
|
331
|
+
* Character-by-character scanning with keyword disambiguation.
|
|
332
|
+
* OData keyword recognition per Part 2 Section 5.1.1 ABNF grammar.
|
|
333
|
+
*/
|
|
334
|
+
/** All token kinds produced by the OData lexer */
|
|
335
|
+
let TokenKind = /* @__PURE__ */ function(TokenKind) {
|
|
336
|
+
TokenKind["STRING_LITERAL"] = "STRING_LITERAL";
|
|
337
|
+
TokenKind["INT_LITERAL"] = "INT_LITERAL";
|
|
338
|
+
TokenKind["DECIMAL_LITERAL"] = "DECIMAL_LITERAL";
|
|
339
|
+
TokenKind["BOOL_LITERAL"] = "BOOL_LITERAL";
|
|
340
|
+
TokenKind["NULL_LITERAL"] = "NULL_LITERAL";
|
|
341
|
+
TokenKind["GUID_LITERAL"] = "GUID_LITERAL";
|
|
342
|
+
TokenKind["DATETIME_LITERAL"] = "DATETIME_LITERAL";
|
|
343
|
+
TokenKind["IDENTIFIER"] = "IDENTIFIER";
|
|
344
|
+
TokenKind["OPEN_PAREN"] = "OPEN_PAREN";
|
|
345
|
+
TokenKind["CLOSE_PAREN"] = "CLOSE_PAREN";
|
|
346
|
+
TokenKind["COMMA"] = "COMMA";
|
|
347
|
+
TokenKind["SLASH"] = "SLASH";
|
|
348
|
+
TokenKind["COLON"] = "COLON";
|
|
349
|
+
TokenKind["STAR"] = "STAR";
|
|
350
|
+
TokenKind["AND"] = "AND";
|
|
351
|
+
TokenKind["OR"] = "OR";
|
|
352
|
+
TokenKind["NOT"] = "NOT";
|
|
353
|
+
TokenKind["EQ"] = "EQ";
|
|
354
|
+
TokenKind["NE"] = "NE";
|
|
355
|
+
TokenKind["LT"] = "LT";
|
|
356
|
+
TokenKind["LE"] = "LE";
|
|
357
|
+
TokenKind["GT"] = "GT";
|
|
358
|
+
TokenKind["GE"] = "GE";
|
|
359
|
+
TokenKind["HAS"] = "HAS";
|
|
360
|
+
TokenKind["IN"] = "IN";
|
|
361
|
+
TokenKind["ADD"] = "ADD";
|
|
362
|
+
TokenKind["SUB"] = "SUB";
|
|
363
|
+
TokenKind["MUL"] = "MUL";
|
|
364
|
+
TokenKind["DIV"] = "DIV";
|
|
365
|
+
TokenKind["DIVBY"] = "DIVBY";
|
|
366
|
+
TokenKind["MOD"] = "MOD";
|
|
367
|
+
TokenKind["EOF"] = "EOF";
|
|
368
|
+
return TokenKind;
|
|
369
|
+
}({});
|
|
370
|
+
/** Mapping of OData keyword strings to their TokenKind */
|
|
371
|
+
const KEYWORDS = {
|
|
372
|
+
and: TokenKind.AND,
|
|
373
|
+
or: TokenKind.OR,
|
|
374
|
+
not: TokenKind.NOT,
|
|
375
|
+
eq: TokenKind.EQ,
|
|
376
|
+
ne: TokenKind.NE,
|
|
377
|
+
lt: TokenKind.LT,
|
|
378
|
+
le: TokenKind.LE,
|
|
379
|
+
gt: TokenKind.GT,
|
|
380
|
+
ge: TokenKind.GE,
|
|
381
|
+
has: TokenKind.HAS,
|
|
382
|
+
in: TokenKind.IN,
|
|
383
|
+
add: TokenKind.ADD,
|
|
384
|
+
sub: TokenKind.SUB,
|
|
385
|
+
mul: TokenKind.MUL,
|
|
386
|
+
div: TokenKind.DIV,
|
|
387
|
+
divby: TokenKind.DIVBY,
|
|
388
|
+
mod: TokenKind.MOD
|
|
389
|
+
};
|
|
390
|
+
/** GUID pattern: 8-4-4-4-12 hex chars */
|
|
391
|
+
const GUID_RE = /^[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}$/;
|
|
392
|
+
function isDigit(ch) {
|
|
393
|
+
return ch >= "0" && ch <= "9";
|
|
394
|
+
}
|
|
395
|
+
function isAlpha(ch) {
|
|
396
|
+
return ch >= "a" && ch <= "z" || ch >= "A" && ch <= "Z" || ch === "_";
|
|
397
|
+
}
|
|
398
|
+
function isAlphaNum(ch) {
|
|
399
|
+
return isAlpha(ch) || isDigit(ch);
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Tokenize an OData query expression string into a token array.
|
|
403
|
+
* The last token is always EOF.
|
|
404
|
+
*
|
|
405
|
+
* @param input - Raw OData expression string (e.g., "Price gt 5 and Active eq true")
|
|
406
|
+
* @returns Array of tokens ending with EOF
|
|
407
|
+
* @throws ODataParseError on unrecognized characters
|
|
408
|
+
*/
|
|
409
|
+
function tokenize(input) {
|
|
410
|
+
const tokens = [];
|
|
411
|
+
let pos = 0;
|
|
412
|
+
while (pos < input.length) {
|
|
413
|
+
if (input[pos] === " " || input[pos] === " ") {
|
|
414
|
+
pos++;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const start = pos;
|
|
418
|
+
const ch = input[pos];
|
|
419
|
+
if (ch === "(") {
|
|
420
|
+
tokens.push({
|
|
421
|
+
kind: TokenKind.OPEN_PAREN,
|
|
422
|
+
value: "(",
|
|
423
|
+
position: start
|
|
424
|
+
});
|
|
425
|
+
pos++;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (ch === ")") {
|
|
429
|
+
tokens.push({
|
|
430
|
+
kind: TokenKind.CLOSE_PAREN,
|
|
431
|
+
value: ")",
|
|
432
|
+
position: start
|
|
433
|
+
});
|
|
434
|
+
pos++;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (ch === ",") {
|
|
438
|
+
tokens.push({
|
|
439
|
+
kind: TokenKind.COMMA,
|
|
440
|
+
value: ",",
|
|
441
|
+
position: start
|
|
442
|
+
});
|
|
443
|
+
pos++;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (ch === "/") {
|
|
447
|
+
tokens.push({
|
|
448
|
+
kind: TokenKind.SLASH,
|
|
449
|
+
value: "/",
|
|
450
|
+
position: start
|
|
451
|
+
});
|
|
452
|
+
pos++;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (ch === ":") {
|
|
456
|
+
tokens.push({
|
|
457
|
+
kind: TokenKind.COLON,
|
|
458
|
+
value: ":",
|
|
459
|
+
position: start
|
|
460
|
+
});
|
|
461
|
+
pos++;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (ch === "*") {
|
|
465
|
+
tokens.push({
|
|
466
|
+
kind: TokenKind.STAR,
|
|
467
|
+
value: "*",
|
|
468
|
+
position: start
|
|
469
|
+
});
|
|
470
|
+
pos++;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (ch === "'") {
|
|
474
|
+
pos++;
|
|
475
|
+
let value = "";
|
|
476
|
+
while (pos < input.length) if (input[pos] === "'") if (pos + 1 < input.length && input[pos + 1] === "'") {
|
|
477
|
+
value += "'";
|
|
478
|
+
pos += 2;
|
|
479
|
+
} else {
|
|
480
|
+
pos++;
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
value += input[pos];
|
|
485
|
+
pos++;
|
|
486
|
+
}
|
|
487
|
+
tokens.push({
|
|
488
|
+
kind: TokenKind.STRING_LITERAL,
|
|
489
|
+
value,
|
|
490
|
+
position: start
|
|
491
|
+
});
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (isDigit(ch)) {
|
|
495
|
+
let numStr = "";
|
|
496
|
+
while (pos < input.length && isDigit(input[pos])) numStr += input[pos++];
|
|
497
|
+
if (pos < input.length && input[pos] === "." && pos + 1 < input.length && isDigit(input[pos + 1])) {
|
|
498
|
+
numStr += ".";
|
|
499
|
+
pos++;
|
|
500
|
+
while (pos < input.length && isDigit(input[pos])) numStr += input[pos++];
|
|
501
|
+
tokens.push({
|
|
502
|
+
kind: TokenKind.DECIMAL_LITERAL,
|
|
503
|
+
value: parseFloat(numStr),
|
|
504
|
+
position: start
|
|
505
|
+
});
|
|
506
|
+
} else {
|
|
507
|
+
const remaining = input.slice(start);
|
|
508
|
+
if (GUID_RE.exec(remaining) && remaining.length >= 36) {
|
|
509
|
+
const guidStr = remaining.slice(0, 36);
|
|
510
|
+
if (GUID_RE.test(guidStr)) {
|
|
511
|
+
tokens.push({
|
|
512
|
+
kind: TokenKind.GUID_LITERAL,
|
|
513
|
+
value: guidStr,
|
|
514
|
+
position: start
|
|
515
|
+
});
|
|
516
|
+
pos = start + 36;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
tokens.push({
|
|
521
|
+
kind: TokenKind.INT_LITERAL,
|
|
522
|
+
value: parseInt(numStr, 10),
|
|
523
|
+
position: start
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (isAlpha(ch)) {
|
|
529
|
+
let word = "";
|
|
530
|
+
while (pos < input.length && (isAlphaNum(input[pos]) || input[pos] === "-")) {
|
|
531
|
+
if (input[pos] === "-") {
|
|
532
|
+
word + input.slice(start, pos);
|
|
533
|
+
const remaining = input.slice(start);
|
|
534
|
+
if (GUID_RE.test(remaining.slice(0, 36))) {
|
|
535
|
+
const guidStr = remaining.slice(0, 36);
|
|
536
|
+
tokens.push({
|
|
537
|
+
kind: TokenKind.GUID_LITERAL,
|
|
538
|
+
value: guidStr,
|
|
539
|
+
position: start
|
|
540
|
+
});
|
|
541
|
+
pos = start + 36;
|
|
542
|
+
word = "";
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
word += input[pos++];
|
|
548
|
+
}
|
|
549
|
+
if (word === "") continue;
|
|
550
|
+
if (word === "true") {
|
|
551
|
+
tokens.push({
|
|
552
|
+
kind: TokenKind.BOOL_LITERAL,
|
|
553
|
+
value: true,
|
|
554
|
+
position: start
|
|
555
|
+
});
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (word === "false") {
|
|
559
|
+
tokens.push({
|
|
560
|
+
kind: TokenKind.BOOL_LITERAL,
|
|
561
|
+
value: false,
|
|
562
|
+
position: start
|
|
563
|
+
});
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (word === "null") {
|
|
567
|
+
tokens.push({
|
|
568
|
+
kind: TokenKind.NULL_LITERAL,
|
|
569
|
+
value: null,
|
|
570
|
+
position: start
|
|
571
|
+
});
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (word in KEYWORDS) {
|
|
575
|
+
tokens.push({
|
|
576
|
+
kind: KEYWORDS[word],
|
|
577
|
+
value: word,
|
|
578
|
+
position: start
|
|
579
|
+
});
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
tokens.push({
|
|
583
|
+
kind: TokenKind.IDENTIFIER,
|
|
584
|
+
value: word,
|
|
585
|
+
position: start
|
|
586
|
+
});
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
throw new ODataParseError(`Unexpected character '${ch}' at position ${pos}`, pos);
|
|
590
|
+
}
|
|
591
|
+
tokens.push({
|
|
592
|
+
kind: TokenKind.EOF,
|
|
593
|
+
value: null,
|
|
594
|
+
position: pos
|
|
595
|
+
});
|
|
596
|
+
return tokens;
|
|
597
|
+
}
|
|
598
|
+
//#endregion
|
|
599
|
+
//#region src/parser/parser.ts
|
|
600
|
+
/** Maximum nesting depth for parenthesized expressions (DoS protection, T-01-09) */
|
|
601
|
+
const MAX_NESTING_DEPTH = 50;
|
|
602
|
+
/**
|
|
603
|
+
* Operator precedence levels.
|
|
604
|
+
* Higher number = binds tighter.
|
|
605
|
+
*/
|
|
606
|
+
const PRECEDENCE = {
|
|
607
|
+
[TokenKind.OR]: 1,
|
|
608
|
+
[TokenKind.AND]: 2,
|
|
609
|
+
[TokenKind.EQ]: 4,
|
|
610
|
+
[TokenKind.NE]: 4,
|
|
611
|
+
[TokenKind.LT]: 4,
|
|
612
|
+
[TokenKind.LE]: 4,
|
|
613
|
+
[TokenKind.GT]: 4,
|
|
614
|
+
[TokenKind.GE]: 4,
|
|
615
|
+
[TokenKind.HAS]: 4,
|
|
616
|
+
[TokenKind.IN]: 4,
|
|
617
|
+
[TokenKind.ADD]: 5,
|
|
618
|
+
[TokenKind.SUB]: 5,
|
|
619
|
+
[TokenKind.MUL]: 6,
|
|
620
|
+
[TokenKind.DIV]: 6,
|
|
621
|
+
[TokenKind.DIVBY]: 6,
|
|
622
|
+
[TokenKind.MOD]: 6
|
|
623
|
+
};
|
|
624
|
+
/** Map from TokenKind to BinaryOperator string */
|
|
625
|
+
const BINARY_OP_MAP = {
|
|
626
|
+
[TokenKind.EQ]: "eq",
|
|
627
|
+
[TokenKind.NE]: "ne",
|
|
628
|
+
[TokenKind.LT]: "lt",
|
|
629
|
+
[TokenKind.LE]: "le",
|
|
630
|
+
[TokenKind.GT]: "gt",
|
|
631
|
+
[TokenKind.GE]: "ge",
|
|
632
|
+
[TokenKind.HAS]: "has",
|
|
633
|
+
[TokenKind.IN]: "in",
|
|
634
|
+
[TokenKind.AND]: "and",
|
|
635
|
+
[TokenKind.OR]: "or",
|
|
636
|
+
[TokenKind.ADD]: "add",
|
|
637
|
+
[TokenKind.SUB]: "sub",
|
|
638
|
+
[TokenKind.MUL]: "mul",
|
|
639
|
+
[TokenKind.DIV]: "div",
|
|
640
|
+
[TokenKind.DIVBY]: "divby",
|
|
641
|
+
[TokenKind.MOD]: "mod"
|
|
642
|
+
};
|
|
643
|
+
/**
|
|
644
|
+
* Known OData v4 built-in functions that the parser validates.
|
|
645
|
+
* Prevents arbitrary function names from being silently accepted.
|
|
646
|
+
*/
|
|
647
|
+
const KNOWN_FUNCTIONS = new Set([
|
|
648
|
+
"startswith",
|
|
649
|
+
"endswith",
|
|
650
|
+
"contains",
|
|
651
|
+
"indexof",
|
|
652
|
+
"substring",
|
|
653
|
+
"length",
|
|
654
|
+
"tolower",
|
|
655
|
+
"toupper",
|
|
656
|
+
"trim",
|
|
657
|
+
"concat",
|
|
658
|
+
"matchesPattern",
|
|
659
|
+
"year",
|
|
660
|
+
"month",
|
|
661
|
+
"day",
|
|
662
|
+
"hour",
|
|
663
|
+
"minute",
|
|
664
|
+
"second",
|
|
665
|
+
"fractionalseconds",
|
|
666
|
+
"totaloffsetminutes",
|
|
667
|
+
"date",
|
|
668
|
+
"time",
|
|
669
|
+
"now",
|
|
670
|
+
"mindatetime",
|
|
671
|
+
"maxdatetime",
|
|
672
|
+
"totalseconds",
|
|
673
|
+
"round",
|
|
674
|
+
"floor",
|
|
675
|
+
"ceiling",
|
|
676
|
+
"cast",
|
|
677
|
+
"isof",
|
|
678
|
+
"geo.distance",
|
|
679
|
+
"geo.intersects",
|
|
680
|
+
"geo.length"
|
|
681
|
+
]);
|
|
682
|
+
/** Lambda operators */
|
|
683
|
+
const LAMBDA_OPS = new Set(["any", "all"]);
|
|
684
|
+
var Parser = class {
|
|
685
|
+
tokens;
|
|
686
|
+
pos = 0;
|
|
687
|
+
nestingDepth = 0;
|
|
688
|
+
constructor(tokens) {
|
|
689
|
+
this.tokens = tokens;
|
|
690
|
+
}
|
|
691
|
+
peek() {
|
|
692
|
+
return this.tokens[this.pos] ?? {
|
|
693
|
+
kind: TokenKind.EOF,
|
|
694
|
+
value: null,
|
|
695
|
+
position: -1
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
advance() {
|
|
699
|
+
const tok = this.tokens[this.pos];
|
|
700
|
+
if (tok === void 0) return {
|
|
701
|
+
kind: TokenKind.EOF,
|
|
702
|
+
value: null,
|
|
703
|
+
position: -1
|
|
704
|
+
};
|
|
705
|
+
this.pos++;
|
|
706
|
+
return tok;
|
|
707
|
+
}
|
|
708
|
+
expect(kind) {
|
|
709
|
+
const tok = this.peek();
|
|
710
|
+
if (tok.kind !== kind) throw new ODataParseError(`Expected ${kind} but got ${tok.kind} ('${String(tok.value)}') at position ${tok.position}`, tok.position, tok);
|
|
711
|
+
return this.advance();
|
|
712
|
+
}
|
|
713
|
+
isBinaryOperator(token) {
|
|
714
|
+
return token.kind in PRECEDENCE;
|
|
715
|
+
}
|
|
716
|
+
getPrecedence(token) {
|
|
717
|
+
return PRECEDENCE[token.kind] ?? 0;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Parse a filter expression using Pratt/precedence-climbing.
|
|
721
|
+
* @param minPrecedence - Minimum binding power for the current operator level
|
|
722
|
+
*/
|
|
723
|
+
parseExpression(minPrecedence = 0) {
|
|
724
|
+
let left = this.parsePrimary();
|
|
725
|
+
while (this.isBinaryOperator(this.peek()) && this.getPrecedence(this.peek()) > minPrecedence) {
|
|
726
|
+
const opToken = this.advance();
|
|
727
|
+
const prec = this.getPrecedence(opToken);
|
|
728
|
+
const right = this.parseExpression(prec);
|
|
729
|
+
const operator = BINARY_OP_MAP[opToken.kind];
|
|
730
|
+
if (operator === void 0) throw new ODataParseError(`Unknown binary operator token: ${opToken.kind}`, opToken.position, opToken);
|
|
731
|
+
left = {
|
|
732
|
+
kind: "BinaryExpr",
|
|
733
|
+
operator,
|
|
734
|
+
left,
|
|
735
|
+
right
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
return left;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Parse a primary expression (literals, identifiers, function calls, lambdas, parens, unary).
|
|
742
|
+
*/
|
|
743
|
+
parsePrimary() {
|
|
744
|
+
const token = this.peek();
|
|
745
|
+
if (token.kind === TokenKind.OPEN_PAREN) {
|
|
746
|
+
this.advance();
|
|
747
|
+
this.nestingDepth++;
|
|
748
|
+
if (this.nestingDepth > MAX_NESTING_DEPTH) throw new ODataParseError(`Maximum nesting depth of ${MAX_NESTING_DEPTH} exceeded`, token.position, token);
|
|
749
|
+
const expr = this.parseExpression(0);
|
|
750
|
+
this.nestingDepth--;
|
|
751
|
+
this.expect(TokenKind.CLOSE_PAREN);
|
|
752
|
+
return expr;
|
|
753
|
+
}
|
|
754
|
+
if (token.kind === TokenKind.NOT) {
|
|
755
|
+
this.advance();
|
|
756
|
+
return {
|
|
757
|
+
kind: "UnaryExpr",
|
|
758
|
+
operator: "not",
|
|
759
|
+
operand: this.parseExpression(3)
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
if (token.kind === TokenKind.SUB) {
|
|
763
|
+
this.advance();
|
|
764
|
+
const operand = this.parsePrimary();
|
|
765
|
+
if (operand.kind === "Literal" && operand.literalKind === "number") return {
|
|
766
|
+
kind: "Literal",
|
|
767
|
+
literalKind: operand.literalKind,
|
|
768
|
+
value: -operand.value
|
|
769
|
+
};
|
|
770
|
+
return {
|
|
771
|
+
kind: "UnaryExpr",
|
|
772
|
+
operator: "neg",
|
|
773
|
+
operand
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
if (token.kind === TokenKind.STRING_LITERAL || token.kind === TokenKind.INT_LITERAL || token.kind === TokenKind.DECIMAL_LITERAL) {
|
|
777
|
+
this.advance();
|
|
778
|
+
return {
|
|
779
|
+
kind: "Literal",
|
|
780
|
+
literalKind: token.kind === TokenKind.STRING_LITERAL ? "string" : "number",
|
|
781
|
+
value: token.value
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
if (token.kind === TokenKind.BOOL_LITERAL) {
|
|
785
|
+
this.advance();
|
|
786
|
+
return {
|
|
787
|
+
kind: "Literal",
|
|
788
|
+
literalKind: "boolean",
|
|
789
|
+
value: token.value
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
if (token.kind === TokenKind.NULL_LITERAL) {
|
|
793
|
+
this.advance();
|
|
794
|
+
return {
|
|
795
|
+
kind: "Literal",
|
|
796
|
+
literalKind: "null",
|
|
797
|
+
value: null
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
if (token.kind === TokenKind.GUID_LITERAL) {
|
|
801
|
+
this.advance();
|
|
802
|
+
return {
|
|
803
|
+
kind: "Literal",
|
|
804
|
+
literalKind: "guid",
|
|
805
|
+
value: token.value
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
if (token.kind === TokenKind.DATETIME_LITERAL) {
|
|
809
|
+
this.advance();
|
|
810
|
+
return {
|
|
811
|
+
kind: "Literal",
|
|
812
|
+
literalKind: "dateTimeOffset",
|
|
813
|
+
value: token.value
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
if (token.kind === TokenKind.IDENTIFIER) return this.parseIdentifierExpression();
|
|
817
|
+
throw new ODataParseError(`Unexpected token ${token.kind} ('${String(token.value)}') at position ${token.position}`, token.position, token);
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Parse an identifier-started expression:
|
|
821
|
+
* 1. identifier( → function call
|
|
822
|
+
* 2. identifier/identifier( → lambda (any/all)
|
|
823
|
+
* 3. identifier/identifier/.../identifier → property access
|
|
824
|
+
* 4. identifier → single-segment property access
|
|
825
|
+
*/
|
|
826
|
+
parseIdentifierExpression() {
|
|
827
|
+
const firstToken = this.advance();
|
|
828
|
+
const firstName = firstToken.value;
|
|
829
|
+
if (this.peek().kind === TokenKind.OPEN_PAREN) return this.parseFunctionCall(firstName, firstToken.position);
|
|
830
|
+
if (this.peek().kind === TokenKind.SLASH) {
|
|
831
|
+
this.advance();
|
|
832
|
+
const nextToken = this.peek();
|
|
833
|
+
if (nextToken.kind !== TokenKind.IDENTIFIER) throw new ODataParseError(`Expected identifier after '/' but got ${nextToken.kind} at position ${nextToken.position}`, nextToken.position, nextToken);
|
|
834
|
+
const secondName = nextToken.value;
|
|
835
|
+
if (LAMBDA_OPS.has(secondName)) {
|
|
836
|
+
const savedPos = this.pos;
|
|
837
|
+
this.advance();
|
|
838
|
+
if (this.peek().kind === TokenKind.OPEN_PAREN) return this.parseLambdaExpr(firstName, secondName, firstToken.position);
|
|
839
|
+
this.pos = savedPos;
|
|
840
|
+
}
|
|
841
|
+
this.advance();
|
|
842
|
+
const path = [firstName, secondName];
|
|
843
|
+
while (this.peek().kind === TokenKind.SLASH) {
|
|
844
|
+
this.advance();
|
|
845
|
+
const segToken = this.peek();
|
|
846
|
+
if (segToken.kind !== TokenKind.IDENTIFIER) throw new ODataParseError(`Expected identifier after '/' but got ${segToken.kind} at position ${segToken.position}`, segToken.position, segToken);
|
|
847
|
+
path.push(segToken.value);
|
|
848
|
+
this.advance();
|
|
849
|
+
}
|
|
850
|
+
return {
|
|
851
|
+
kind: "PropertyAccess",
|
|
852
|
+
path
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
return {
|
|
856
|
+
kind: "PropertyAccess",
|
|
857
|
+
path: [firstName]
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Parse a function call: name(arg1, arg2, ...)
|
|
862
|
+
* Validates against the known built-in function list.
|
|
863
|
+
*/
|
|
864
|
+
parseFunctionCall(name, position) {
|
|
865
|
+
if (!KNOWN_FUNCTIONS.has(name)) throw new ODataParseError(`Unknown function '${name}' at position ${position}. Known functions: ${Array.from(KNOWN_FUNCTIONS).join(", ")}`, position);
|
|
866
|
+
this.expect(TokenKind.OPEN_PAREN);
|
|
867
|
+
const args = [];
|
|
868
|
+
if (this.peek().kind !== TokenKind.CLOSE_PAREN) {
|
|
869
|
+
args.push(this.parseExpression(0));
|
|
870
|
+
while (this.peek().kind === TokenKind.COMMA) {
|
|
871
|
+
this.advance();
|
|
872
|
+
args.push(this.parseExpression(0));
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
this.expect(TokenKind.CLOSE_PAREN);
|
|
876
|
+
return {
|
|
877
|
+
kind: "FunctionCall",
|
|
878
|
+
name,
|
|
879
|
+
args
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Parse a lambda expression: collection/operator(variable:predicate)
|
|
884
|
+
* e.g., Tags/any(t:t/Name eq 'electronics')
|
|
885
|
+
* e.g., Tags/any() — no-predicate variant
|
|
886
|
+
*/
|
|
887
|
+
parseLambdaExpr(collection, operator, position) {
|
|
888
|
+
if (!LAMBDA_OPS.has(operator)) throw new ODataParseError(`Unknown lambda operator '${operator}' at position ${position}`, position);
|
|
889
|
+
this.expect(TokenKind.OPEN_PAREN);
|
|
890
|
+
if (this.peek().kind === TokenKind.CLOSE_PAREN) {
|
|
891
|
+
this.advance();
|
|
892
|
+
return {
|
|
893
|
+
kind: "LambdaExpr",
|
|
894
|
+
operator,
|
|
895
|
+
collection,
|
|
896
|
+
variable: null,
|
|
897
|
+
predicate: null
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
const variable = this.expect(TokenKind.IDENTIFIER).value;
|
|
901
|
+
this.expect(TokenKind.COLON);
|
|
902
|
+
const predicate = this.parseExpression(0);
|
|
903
|
+
this.expect(TokenKind.CLOSE_PAREN);
|
|
904
|
+
return {
|
|
905
|
+
kind: "LambdaExpr",
|
|
906
|
+
operator,
|
|
907
|
+
collection,
|
|
908
|
+
variable,
|
|
909
|
+
predicate
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Ensure we have consumed all tokens (no trailing garbage).
|
|
914
|
+
*/
|
|
915
|
+
expectEOF() {
|
|
916
|
+
const tok = this.peek();
|
|
917
|
+
if (tok.kind !== TokenKind.EOF) throw new ODataParseError(`Unexpected token ${tok.kind} ('${String(tok.value)}') at position ${tok.position} — expected end of expression`, tok.position, tok);
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
/**
|
|
921
|
+
* Parse a standalone OData $filter expression string into a FilterNode AST.
|
|
922
|
+
*
|
|
923
|
+
* @param input - Raw filter expression (e.g., "Price gt 5 and Active eq true")
|
|
924
|
+
* @returns Root FilterNode of the parsed expression
|
|
925
|
+
* @throws ODataParseError on syntax errors
|
|
926
|
+
*/
|
|
927
|
+
function parseFilter(input) {
|
|
928
|
+
if (!input.trim()) throw new ODataParseError("Filter expression must not be empty", 0);
|
|
929
|
+
const parser = new Parser(tokenize(input));
|
|
930
|
+
const node = parser.parseExpression(0);
|
|
931
|
+
parser.expectEOF();
|
|
932
|
+
return node;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Parse an OData query string into a QueryOptions object.
|
|
936
|
+
* Handles $filter, $orderby, $select, $top, $skip.
|
|
937
|
+
* Unknown query options are silently ignored.
|
|
938
|
+
*
|
|
939
|
+
* @param queryString - Raw query string, with or without leading '?'
|
|
940
|
+
* @returns Parsed QueryOptions (fields are optional — only present when found in query)
|
|
941
|
+
* @throws ODataParseError on syntax errors
|
|
942
|
+
*/
|
|
943
|
+
function parseQuery(queryString) {
|
|
944
|
+
const qs = queryString.startsWith("?") ? queryString.slice(1) : queryString;
|
|
945
|
+
if (!qs.trim()) return {};
|
|
946
|
+
const parts = qs.split("&");
|
|
947
|
+
let filter;
|
|
948
|
+
let orderBy;
|
|
949
|
+
let select;
|
|
950
|
+
let top;
|
|
951
|
+
let skip;
|
|
952
|
+
let expand;
|
|
953
|
+
for (const part of parts) {
|
|
954
|
+
const lpart = part.toLowerCase();
|
|
955
|
+
if (lpart.startsWith("$filter=")) filter = parseFilter(part.slice(8));
|
|
956
|
+
else if (lpart.startsWith("$orderby=")) orderBy = parseOrderBy(part.slice(9));
|
|
957
|
+
else if (lpart.startsWith("$select=")) select = parseSelect(part.slice(8));
|
|
958
|
+
else if (lpart.startsWith("$top=")) top = parseNonNegativeInt(part.slice(5), "$top", 0);
|
|
959
|
+
else if (lpart.startsWith("$skip=")) skip = parseNonNegativeInt(part.slice(6), "$skip", 0);
|
|
960
|
+
else if (lpart.startsWith("$expand=")) expand = parseExpand(part.slice(8));
|
|
961
|
+
}
|
|
962
|
+
return {
|
|
963
|
+
filter,
|
|
964
|
+
orderBy,
|
|
965
|
+
select,
|
|
966
|
+
top,
|
|
967
|
+
skip,
|
|
968
|
+
expand
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Parse the value of a $orderby query option.
|
|
973
|
+
* Format: expr [asc|desc] (, expr [asc|desc])*
|
|
974
|
+
*/
|
|
975
|
+
function parseOrderBy(value) {
|
|
976
|
+
if (!value.trim()) return [];
|
|
977
|
+
return value.split(",").map((item) => {
|
|
978
|
+
const trimmed = item.trim();
|
|
979
|
+
let exprStr = trimmed;
|
|
980
|
+
let direction = "asc";
|
|
981
|
+
const lower = trimmed.toLowerCase();
|
|
982
|
+
if (/ desc$/i.test(lower)) {
|
|
983
|
+
direction = "desc";
|
|
984
|
+
exprStr = trimmed.slice(0, -5).trimEnd();
|
|
985
|
+
} else if (/ asc$/i.test(lower)) {
|
|
986
|
+
direction = "asc";
|
|
987
|
+
exprStr = trimmed.slice(0, -4).trimEnd();
|
|
988
|
+
}
|
|
989
|
+
const parser = new Parser(tokenize(exprStr));
|
|
990
|
+
const expression = parser.parseExpression(0);
|
|
991
|
+
parser.expectEOF();
|
|
992
|
+
return {
|
|
993
|
+
expression,
|
|
994
|
+
direction
|
|
995
|
+
};
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Parse the value of a $select query option.
|
|
1000
|
+
* Format: * | path (, path)* where path is a slash-separated property name list
|
|
1001
|
+
*/
|
|
1002
|
+
function parseSelect(value) {
|
|
1003
|
+
if (value.trim() === "*") return { all: true };
|
|
1004
|
+
return { items: value.split(",").map((p) => {
|
|
1005
|
+
return { path: p.trim().split("/").filter(Boolean) };
|
|
1006
|
+
}) };
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Split an $expand value string by top-level commas only (not commas inside parentheses).
|
|
1010
|
+
* Tracks parenthesis depth to avoid splitting nested options like Items($filter=x),Customer.
|
|
1011
|
+
*/
|
|
1012
|
+
function splitTopLevelCommas$1(value) {
|
|
1013
|
+
const segments = [];
|
|
1014
|
+
let depth = 0;
|
|
1015
|
+
let start = 0;
|
|
1016
|
+
for (let i = 0; i < value.length; i++) {
|
|
1017
|
+
const ch = value[i];
|
|
1018
|
+
if (ch === "(") depth++;
|
|
1019
|
+
else if (ch === ")") depth--;
|
|
1020
|
+
else if (ch === "," && depth === 0) {
|
|
1021
|
+
segments.push(value.slice(start, i));
|
|
1022
|
+
start = i + 1;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
segments.push(value.slice(start));
|
|
1026
|
+
return segments;
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Parse the value of a $expand query option into an ExpandNode.
|
|
1030
|
+
* Handles simple (Customer), multi (Customer,Items), nested options (Items($filter=...)),
|
|
1031
|
+
* and recursive nested expand (Items($expand=Product)).
|
|
1032
|
+
*
|
|
1033
|
+
* Per D-07: Full nested $expand support.
|
|
1034
|
+
* Per D-08: Nested query options use semicolons as separators.
|
|
1035
|
+
* Per T-04-01: Inherits max nesting depth from recursive parseQuery calls.
|
|
1036
|
+
*/
|
|
1037
|
+
function parseExpand(value) {
|
|
1038
|
+
const trimmed = value.trim();
|
|
1039
|
+
if (!trimmed) return { items: [] };
|
|
1040
|
+
return { items: splitTopLevelCommas$1(trimmed).map((seg) => seg.trim()).filter((seg) => seg.length > 0).map((seg) => {
|
|
1041
|
+
const parenIdx = seg.indexOf("(");
|
|
1042
|
+
if (parenIdx === -1) return { navigationProperty: seg.trim() };
|
|
1043
|
+
const navigationProperty = seg.slice(0, parenIdx).trim();
|
|
1044
|
+
const nested = parseQuery(seg.slice(parenIdx + 1, seg.length - 1).replace(/;/g, "&"));
|
|
1045
|
+
return {
|
|
1046
|
+
navigationProperty,
|
|
1047
|
+
...nested.filter !== void 0 && { filter: nested.filter },
|
|
1048
|
+
...nested.select !== void 0 && { select: nested.select },
|
|
1049
|
+
...nested.orderBy !== void 0 && { orderBy: nested.orderBy },
|
|
1050
|
+
...nested.top !== void 0 && { top: nested.top },
|
|
1051
|
+
...nested.skip !== void 0 && { skip: nested.skip },
|
|
1052
|
+
...nested.expand !== void 0 && { expand: nested.expand }
|
|
1053
|
+
};
|
|
1054
|
+
}) };
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Parse and validate a non-negative integer value for $top or $skip.
|
|
1058
|
+
* @throws ODataParseError if the value is not a valid non-negative integer
|
|
1059
|
+
*/
|
|
1060
|
+
function parseNonNegativeInt(value, paramName, position) {
|
|
1061
|
+
const trimmed = value.trim();
|
|
1062
|
+
if (!/^\d+$/.test(trimmed)) throw new ODataParseError(`${paramName} must be a non-negative integer, got '${trimmed}'`, position);
|
|
1063
|
+
const n = parseInt(trimmed, 10);
|
|
1064
|
+
if (n < 0) throw new ODataParseError(`${paramName} must be a non-negative integer, got ${n}`, position);
|
|
1065
|
+
return n;
|
|
1066
|
+
}
|
|
1067
|
+
//#endregion
|
|
1068
|
+
//#region src/parser/search-parser.ts
|
|
1069
|
+
/** Maximum number of binary operations allowed in a search expression (DoS protection). */
|
|
1070
|
+
const MAX_SEARCH_DEPTH = 20;
|
|
1071
|
+
/**
|
|
1072
|
+
* Tokenizes a $search query string into a flat list of tokens.
|
|
1073
|
+
* Handles double-quoted phrases and keyword recognition.
|
|
1074
|
+
*/
|
|
1075
|
+
function tokenizeSearch(input) {
|
|
1076
|
+
const tokens = [];
|
|
1077
|
+
let i = 0;
|
|
1078
|
+
const trimmed = input.trim();
|
|
1079
|
+
while (i < trimmed.length) {
|
|
1080
|
+
while (i < trimmed.length && trimmed[i] === " ") i++;
|
|
1081
|
+
if (i >= trimmed.length) break;
|
|
1082
|
+
if (trimmed[i] === "\"") {
|
|
1083
|
+
i++;
|
|
1084
|
+
let phrase = "";
|
|
1085
|
+
while (i < trimmed.length && trimmed[i] !== "\"") phrase += trimmed[i++];
|
|
1086
|
+
if (i < trimmed.length) i++;
|
|
1087
|
+
tokens.push({
|
|
1088
|
+
type: "TERM",
|
|
1089
|
+
value: phrase,
|
|
1090
|
+
negated: false
|
|
1091
|
+
});
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
let word = "";
|
|
1095
|
+
while (i < trimmed.length && trimmed[i] !== " ") word += trimmed[i++];
|
|
1096
|
+
if (word === "AND") tokens.push({ type: "AND" });
|
|
1097
|
+
else if (word === "OR") tokens.push({ type: "OR" });
|
|
1098
|
+
else if (word === "NOT") {
|
|
1099
|
+
while (i < trimmed.length && trimmed[i] === " ") i++;
|
|
1100
|
+
if (i >= trimmed.length) throw new ODataParseError("NOT operator must be followed by a search term", i);
|
|
1101
|
+
if (trimmed[i] === "\"") {
|
|
1102
|
+
i++;
|
|
1103
|
+
let phrase = "";
|
|
1104
|
+
while (i < trimmed.length && trimmed[i] !== "\"") phrase += trimmed[i++];
|
|
1105
|
+
if (i < trimmed.length) i++;
|
|
1106
|
+
tokens.push({
|
|
1107
|
+
type: "TERM",
|
|
1108
|
+
value: phrase,
|
|
1109
|
+
negated: true
|
|
1110
|
+
});
|
|
1111
|
+
} else {
|
|
1112
|
+
let nextWord = "";
|
|
1113
|
+
while (i < trimmed.length && trimmed[i] !== " ") nextWord += trimmed[i++];
|
|
1114
|
+
tokens.push({
|
|
1115
|
+
type: "TERM",
|
|
1116
|
+
value: nextWord,
|
|
1117
|
+
negated: true
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
} else tokens.push({
|
|
1121
|
+
type: "TERM",
|
|
1122
|
+
value: word,
|
|
1123
|
+
negated: false
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
return tokens;
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Build a SearchNode from a flat token list using a recursive-descent approach.
|
|
1130
|
+
* OR has lower precedence than AND (AND binds tighter).
|
|
1131
|
+
*/
|
|
1132
|
+
function buildSearchNode(tokens, depth) {
|
|
1133
|
+
if (depth > MAX_SEARCH_DEPTH) throw new ODataParseError(`$search expression exceeds maximum depth of ${MAX_SEARCH_DEPTH} — expression too complex`, 0);
|
|
1134
|
+
if (tokens.length === 0) throw new ODataParseError("Empty $search expression — at least one search term is required", 0);
|
|
1135
|
+
const orIdx = findTopLevelOperator(tokens, "OR");
|
|
1136
|
+
if (orIdx !== -1) return {
|
|
1137
|
+
kind: "SearchBinary",
|
|
1138
|
+
operator: "OR",
|
|
1139
|
+
left: buildSearchNode(tokens.slice(0, orIdx), depth + 1),
|
|
1140
|
+
right: buildSearchNode(tokens.slice(orIdx + 1), depth + 1)
|
|
1141
|
+
};
|
|
1142
|
+
const andIdx = findTopLevelOperator(tokens, "AND");
|
|
1143
|
+
if (andIdx !== -1) return {
|
|
1144
|
+
kind: "SearchBinary",
|
|
1145
|
+
operator: "AND",
|
|
1146
|
+
left: buildSearchNode(tokens.slice(0, andIdx), depth + 1),
|
|
1147
|
+
right: buildSearchNode(tokens.slice(andIdx + 1), depth + 1)
|
|
1148
|
+
};
|
|
1149
|
+
const termTokens = tokens.filter((t) => t.type === "TERM");
|
|
1150
|
+
if (termTokens.length > 1) {
|
|
1151
|
+
let left = termToNode(termTokens[0]);
|
|
1152
|
+
for (let i = 1; i < termTokens.length; i++) {
|
|
1153
|
+
const right = termToNode(termTokens[i]);
|
|
1154
|
+
if (depth + i > MAX_SEARCH_DEPTH) throw new ODataParseError(`$search expression exceeds maximum depth of ${MAX_SEARCH_DEPTH}`, 0);
|
|
1155
|
+
left = {
|
|
1156
|
+
kind: "SearchBinary",
|
|
1157
|
+
operator: "AND",
|
|
1158
|
+
left,
|
|
1159
|
+
right
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
return left;
|
|
1163
|
+
}
|
|
1164
|
+
if (tokens.length === 1 && tokens[0].type === "TERM") return termToNode(tokens[0]);
|
|
1165
|
+
throw new ODataParseError(`Unexpected token sequence in $search expression`, 0);
|
|
1166
|
+
}
|
|
1167
|
+
function termToNode(token) {
|
|
1168
|
+
return token.negated ? {
|
|
1169
|
+
kind: "SearchTerm",
|
|
1170
|
+
value: token.value,
|
|
1171
|
+
negated: true
|
|
1172
|
+
} : {
|
|
1173
|
+
kind: "SearchTerm",
|
|
1174
|
+
value: token.value
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Find the index of the last top-level occurrence of the given operator type.
|
|
1179
|
+
* Returns -1 if not found.
|
|
1180
|
+
* "Top-level" means outside of any grouping (search doesn't use parens, so all are top-level).
|
|
1181
|
+
*/
|
|
1182
|
+
function findTopLevelOperator(tokens, opType) {
|
|
1183
|
+
let lastIdx = -1;
|
|
1184
|
+
for (let i = 0; i < tokens.length; i++) if (tokens[i].type === opType) lastIdx = i;
|
|
1185
|
+
return lastIdx;
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Parse an OData $search query string into a SearchNode AST.
|
|
1189
|
+
*
|
|
1190
|
+
* @param input - The raw $search query string value (without the $search= prefix)
|
|
1191
|
+
* @throws ODataParseError if the input is empty, malformed, or exceeds MAX_SEARCH_DEPTH
|
|
1192
|
+
*/
|
|
1193
|
+
function parseSearch(input) {
|
|
1194
|
+
const trimmed = input.trim();
|
|
1195
|
+
if (!trimmed) throw new ODataParseError("Empty $search expression — at least one search term is required", 0);
|
|
1196
|
+
const tokens = tokenizeSearch(trimmed);
|
|
1197
|
+
if (tokens.length === 0) throw new ODataParseError("Empty $search expression — at least one search term is required", 0);
|
|
1198
|
+
const operatorCount = tokens.filter((t) => t.type === "AND" || t.type === "OR").length;
|
|
1199
|
+
const termCount = tokens.filter((t) => t.type === "TERM").length;
|
|
1200
|
+
const explicitOps = operatorCount;
|
|
1201
|
+
if (explicitOps + (explicitOps === 0 && termCount > 1 ? termCount - 1 : 0) >= MAX_SEARCH_DEPTH) throw new ODataParseError(`$search expression exceeds maximum depth of ${MAX_SEARCH_DEPTH} — expression too complex`, 0);
|
|
1202
|
+
return buildSearchNode(tokens, 0);
|
|
1203
|
+
}
|
|
1204
|
+
//#endregion
|
|
1205
|
+
//#region src/parser/apply-parser.ts
|
|
1206
|
+
/** Regex for valid SQL-safe identifiers (prevents SQL injection via aliases). */
|
|
1207
|
+
const ALIAS_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1208
|
+
/**
|
|
1209
|
+
* Split a $apply string on '/' characters that are at parenthesis depth 0.
|
|
1210
|
+
* This correctly handles filter(a/b gt 0) without splitting inside the parens.
|
|
1211
|
+
*/
|
|
1212
|
+
function splitApplyPipeline(value) {
|
|
1213
|
+
const steps = [];
|
|
1214
|
+
let depth = 0;
|
|
1215
|
+
let current = "";
|
|
1216
|
+
for (let i = 0; i < value.length; i++) {
|
|
1217
|
+
const ch = value[i];
|
|
1218
|
+
if (ch === "(") {
|
|
1219
|
+
depth++;
|
|
1220
|
+
current += ch;
|
|
1221
|
+
} else if (ch === ")") {
|
|
1222
|
+
depth--;
|
|
1223
|
+
current += ch;
|
|
1224
|
+
} else if (ch === "/" && depth === 0) {
|
|
1225
|
+
steps.push(current.trim());
|
|
1226
|
+
current = "";
|
|
1227
|
+
} else current += ch;
|
|
1228
|
+
}
|
|
1229
|
+
if (current.trim()) steps.push(current.trim());
|
|
1230
|
+
return steps;
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Parse a comma-separated list of aggregate expressions.
|
|
1234
|
+
* Handles both:
|
|
1235
|
+
* - `property with method as alias`
|
|
1236
|
+
* - `$count as alias` (shorthand for count of records)
|
|
1237
|
+
*/
|
|
1238
|
+
function parseAggregateExpressions(inner) {
|
|
1239
|
+
return splitTopLevelCommas(inner).map((part) => parseOneAggregateExpression(part.trim()));
|
|
1240
|
+
}
|
|
1241
|
+
function parseOneAggregateExpression(expr) {
|
|
1242
|
+
const countMatch = /^\$count\s+as\s+(\S+)$/.exec(expr);
|
|
1243
|
+
if (countMatch) {
|
|
1244
|
+
const alias = countMatch[1];
|
|
1245
|
+
validateAlias(alias);
|
|
1246
|
+
return {
|
|
1247
|
+
property: "$count",
|
|
1248
|
+
method: "count",
|
|
1249
|
+
alias
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
const withMatch = /^(\S+)\s+with\s+(\w+)\s+as\s+(\S+)$/.exec(expr);
|
|
1253
|
+
if (withMatch) {
|
|
1254
|
+
const [, property, methodRaw, alias] = withMatch;
|
|
1255
|
+
const method = methodRaw.toLowerCase();
|
|
1256
|
+
if (!isValidMethod(method)) throw new ODataParseError(`Unknown aggregate method '${method}'. Valid methods: sum, count, avg, min, max, countdistinct`, 0);
|
|
1257
|
+
validateAlias(alias);
|
|
1258
|
+
return {
|
|
1259
|
+
property,
|
|
1260
|
+
method,
|
|
1261
|
+
alias
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
throw new ODataParseError(`Invalid aggregate expression '${expr}'. Expected 'property with method as alias' or '$count as alias'`, 0);
|
|
1265
|
+
}
|
|
1266
|
+
function isValidMethod(method) {
|
|
1267
|
+
return [
|
|
1268
|
+
"sum",
|
|
1269
|
+
"count",
|
|
1270
|
+
"avg",
|
|
1271
|
+
"min",
|
|
1272
|
+
"max",
|
|
1273
|
+
"countdistinct"
|
|
1274
|
+
].includes(method);
|
|
1275
|
+
}
|
|
1276
|
+
function validateAlias(alias) {
|
|
1277
|
+
if (!ALIAS_REGEX.test(alias)) throw new ODataParseError(`Invalid aggregate alias '${alias}'. Aliases must match /^[A-Za-z_][A-Za-z0-9_]*$/ to prevent SQL injection`, 0);
|
|
1278
|
+
}
|
|
1279
|
+
function parseFilterStep(inner) {
|
|
1280
|
+
return {
|
|
1281
|
+
kind: "ApplyFilter",
|
|
1282
|
+
filter: parseFilter(inner)
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
function parseAggregateStep(inner) {
|
|
1286
|
+
return {
|
|
1287
|
+
kind: "ApplyAggregate",
|
|
1288
|
+
expressions: parseAggregateExpressions(inner)
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
function parseGroupByStep(inner) {
|
|
1292
|
+
if (!inner.startsWith("(")) throw new ODataParseError(`Invalid groupby() syntax: expected '(' to start property list, got '${inner[0]}'`, 0);
|
|
1293
|
+
let depth = 0;
|
|
1294
|
+
let propEnd = -1;
|
|
1295
|
+
for (let i = 0; i < inner.length; i++) if (inner[i] === "(") depth++;
|
|
1296
|
+
else if (inner[i] === ")") {
|
|
1297
|
+
depth--;
|
|
1298
|
+
if (depth === 0) {
|
|
1299
|
+
propEnd = i;
|
|
1300
|
+
break;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
if (propEnd === -1) throw new ODataParseError("Unclosed parenthesis in groupby() property list", 0);
|
|
1304
|
+
const properties = inner.slice(1, propEnd).split(",").map((p) => p.trim()).filter(Boolean);
|
|
1305
|
+
const rest = inner.slice(propEnd + 1).trim();
|
|
1306
|
+
let aggregate;
|
|
1307
|
+
if (rest.startsWith(",aggregate(")) aggregate = parseAggregateExpressions(rest.slice(11, rest.length - 1));
|
|
1308
|
+
else if (rest.startsWith(",")) throw new ODataParseError(`Unexpected content after groupby() property list: '${rest}'`, 0);
|
|
1309
|
+
return {
|
|
1310
|
+
kind: "ApplyGroupBy",
|
|
1311
|
+
properties,
|
|
1312
|
+
aggregate
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Split a string on ',' characters at parenthesis depth 0.
|
|
1317
|
+
* Reusable utility — same algorithm as pipeline splitter but for commas.
|
|
1318
|
+
*/
|
|
1319
|
+
function splitTopLevelCommas(value) {
|
|
1320
|
+
const parts = [];
|
|
1321
|
+
let depth = 0;
|
|
1322
|
+
let current = "";
|
|
1323
|
+
for (let i = 0; i < value.length; i++) {
|
|
1324
|
+
const ch = value[i];
|
|
1325
|
+
if (ch === "(") {
|
|
1326
|
+
depth++;
|
|
1327
|
+
current += ch;
|
|
1328
|
+
} else if (ch === ")") {
|
|
1329
|
+
depth--;
|
|
1330
|
+
current += ch;
|
|
1331
|
+
} else if (ch === "," && depth === 0) {
|
|
1332
|
+
parts.push(current.trim());
|
|
1333
|
+
current = "";
|
|
1334
|
+
} else current += ch;
|
|
1335
|
+
}
|
|
1336
|
+
if (current.trim()) parts.push(current.trim());
|
|
1337
|
+
return parts;
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Parse an OData $apply transformation pipeline into an ApplyNode AST.
|
|
1341
|
+
*
|
|
1342
|
+
* Steps are separated by '/' at depth 0. Each step is one of:
|
|
1343
|
+
* - filter(...)
|
|
1344
|
+
* - aggregate(...)
|
|
1345
|
+
* - groupby((...),aggregate(...))
|
|
1346
|
+
*
|
|
1347
|
+
* @param input - The raw $apply query string value (without the $apply= prefix)
|
|
1348
|
+
* @throws ODataParseError for empty input, unrecognized steps, invalid aliases,
|
|
1349
|
+
* or post-groupby filter steps (HAVING not supported)
|
|
1350
|
+
*/
|
|
1351
|
+
function parseApply(input) {
|
|
1352
|
+
const trimmed = input.trim();
|
|
1353
|
+
if (!trimmed) throw new ODataParseError("Empty $apply expression — at least one transformation step is required", 0);
|
|
1354
|
+
const stepStrings = splitApplyPipeline(trimmed);
|
|
1355
|
+
const steps = [];
|
|
1356
|
+
let seenAggregation = false;
|
|
1357
|
+
for (const stepStr of stepStrings) if (stepStr.startsWith("filter(") && stepStr.endsWith(")")) {
|
|
1358
|
+
if (seenAggregation) throw new ODataParseError("Post-groupby filter (HAVING) is not supported in this version. Place filter() steps before groupby()", 0);
|
|
1359
|
+
const inner = stepStr.slice(7, stepStr.length - 1);
|
|
1360
|
+
steps.push(parseFilterStep(inner));
|
|
1361
|
+
} else if (stepStr.startsWith("aggregate(") && stepStr.endsWith(")")) {
|
|
1362
|
+
seenAggregation = true;
|
|
1363
|
+
const inner = stepStr.slice(10, stepStr.length - 1);
|
|
1364
|
+
steps.push(parseAggregateStep(inner));
|
|
1365
|
+
} else if (stepStr.startsWith("groupby(") && stepStr.endsWith(")")) {
|
|
1366
|
+
seenAggregation = true;
|
|
1367
|
+
const inner = stepStr.slice(8, stepStr.length - 1);
|
|
1368
|
+
steps.push(parseGroupByStep(inner));
|
|
1369
|
+
} else {
|
|
1370
|
+
const nameMatch = /^(\w+)\(/.exec(stepStr);
|
|
1371
|
+
throw new ODataParseError(`Unrecognized $apply transformation step '${nameMatch ? nameMatch[1] : stepStr}'. Supported steps: filter, aggregate, groupby`, 0);
|
|
1372
|
+
}
|
|
1373
|
+
return { steps };
|
|
1374
|
+
}
|
|
1375
|
+
//#endregion
|
|
1376
|
+
//#region \0@oxc-project+runtime@0.122.0/helpers/decorate.js
|
|
1377
|
+
function __decorate(decorators, target, key, desc) {
|
|
1378
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1379
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
1380
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
1381
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
1382
|
+
}
|
|
1383
|
+
//#endregion
|
|
1384
|
+
//#region src/edm/edm-registry.ts
|
|
1385
|
+
let EdmRegistry = class EdmRegistry {
|
|
1386
|
+
entityTypes = /* @__PURE__ */ new Map();
|
|
1387
|
+
entitySets = /* @__PURE__ */ new Map();
|
|
1388
|
+
/** Per-entity security option overrides keyed by entitySetName (per D-07) */
|
|
1389
|
+
entitySecurityOptions = /* @__PURE__ */ new Map();
|
|
1390
|
+
/**
|
|
1391
|
+
* Register an entity type and its corresponding entity set.
|
|
1392
|
+
*
|
|
1393
|
+
* Idempotent: if the same entity type name is already registered (e.g. from multiple
|
|
1394
|
+
* ODataTypeOrmModule.forFeature() calls in different feature modules), the registration
|
|
1395
|
+
* is silently skipped. This supports the common pattern of importing forFeature() in
|
|
1396
|
+
* both a root AppModule and a feature module for the same entity.
|
|
1397
|
+
*
|
|
1398
|
+
* Throws only if a DIFFERENT entity type is registered under the same name (name collision).
|
|
1399
|
+
*/
|
|
1400
|
+
register(entityType, entitySet) {
|
|
1401
|
+
if (this.entityTypes.has(entityType.name)) return;
|
|
1402
|
+
this.entityTypes.set(entityType.name, entityType);
|
|
1403
|
+
this.entitySets.set(entitySet.name, entitySet);
|
|
1404
|
+
}
|
|
1405
|
+
/** Retrieve a registered entity type by name. Returns undefined if not found. */
|
|
1406
|
+
getEntityType(name) {
|
|
1407
|
+
return this.entityTypes.get(name);
|
|
1408
|
+
}
|
|
1409
|
+
/** Retrieve a registered entity set by name. Returns undefined if not found. */
|
|
1410
|
+
getEntitySet(name) {
|
|
1411
|
+
return this.entitySets.get(name);
|
|
1412
|
+
}
|
|
1413
|
+
/** All registered entity types as a read-only map. */
|
|
1414
|
+
getEntityTypes() {
|
|
1415
|
+
return this.entityTypes;
|
|
1416
|
+
}
|
|
1417
|
+
/** All registered entity sets as a read-only map. */
|
|
1418
|
+
getEntitySets() {
|
|
1419
|
+
return this.entitySets;
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Store per-entity security option overrides for a given entity set name.
|
|
1423
|
+
* These override global ODataModuleResolvedOptions values (per D-07).
|
|
1424
|
+
*/
|
|
1425
|
+
setEntitySecurityOptions(entitySetName, options) {
|
|
1426
|
+
this.entitySecurityOptions.set(entitySetName, options);
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Retrieve per-entity security options for a given entity set name.
|
|
1430
|
+
* Returns undefined if no overrides have been set.
|
|
1431
|
+
*/
|
|
1432
|
+
getEntitySecurityOptions(entitySetName) {
|
|
1433
|
+
return this.entitySecurityOptions.get(entitySetName);
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
EdmRegistry = __decorate([(0, _nestjs_common.Injectable)()], EdmRegistry);
|
|
1437
|
+
//#endregion
|
|
1438
|
+
//#region src/edm/pluralize.ts
|
|
1439
|
+
/**
|
|
1440
|
+
* Pluralize an entity class name to produce an EntitySet name.
|
|
1441
|
+
* Uses the `pluralize` library which handles irregular forms (Person → People,
|
|
1442
|
+
* Category → Categories, Index → Indices, etc.).
|
|
1443
|
+
*
|
|
1444
|
+
* @param name - Entity class name in PascalCase (e.g., 'Product', 'OrderItem')
|
|
1445
|
+
* @returns Pluralized entity set name (e.g., 'Products', 'OrderItems')
|
|
1446
|
+
*/
|
|
1447
|
+
function pluralizeEntityName(name) {
|
|
1448
|
+
return (0, pluralize.default)(name);
|
|
1449
|
+
}
|
|
1450
|
+
//#endregion
|
|
1451
|
+
//#region src/interfaces/edm-deriver.interface.ts
|
|
1452
|
+
/** NestJS injection token for IEdmDeriver */
|
|
1453
|
+
const EDM_DERIVER = Symbol("EDM_DERIVER");
|
|
1454
|
+
//#endregion
|
|
1455
|
+
//#region src/interfaces/query-translator.interface.ts
|
|
1456
|
+
/** NestJS injection token for IQueryTranslator */
|
|
1457
|
+
const QUERY_TRANSLATOR = Symbol("QUERY_TRANSLATOR");
|
|
1458
|
+
//#endregion
|
|
1459
|
+
//#region src/interfaces/etag.interface.ts
|
|
1460
|
+
/**
|
|
1461
|
+
* Injection token for IETagProvider.
|
|
1462
|
+
* Use this token when registering or injecting the ETag provider via NestJS DI.
|
|
1463
|
+
*/
|
|
1464
|
+
const ETAG_PROVIDER = Symbol("ETAG_PROVIDER");
|
|
1465
|
+
//#endregion
|
|
1466
|
+
//#region src/interfaces/search.interface.ts
|
|
1467
|
+
/**
|
|
1468
|
+
* Injection token for ISearchProvider.
|
|
1469
|
+
* Use this token when registering or injecting the search provider via NestJS DI.
|
|
1470
|
+
*/
|
|
1471
|
+
const SEARCH_PROVIDER = Symbol("SEARCH_PROVIDER");
|
|
1472
|
+
//#endregion
|
|
1473
|
+
//#region src/tokens.ts
|
|
1474
|
+
/**
|
|
1475
|
+
* DI injection token for the array of EdmEntityConfig objects
|
|
1476
|
+
* registered via ODataModule.forFeature().
|
|
1477
|
+
*/
|
|
1478
|
+
const EDM_ENTITY_CONFIGS = Symbol("EDM_ENTITY_CONFIGS");
|
|
1479
|
+
/**
|
|
1480
|
+
* DI injection token for the fully-resolved ODataModuleOptions (with defaults applied).
|
|
1481
|
+
* Defined here (not in odata.module.ts) to avoid circular imports with metadata builders.
|
|
1482
|
+
*/
|
|
1483
|
+
const ODATA_MODULE_OPTIONS = Symbol("ODATA_MODULE_OPTIONS");
|
|
1484
|
+
//#endregion
|
|
1485
|
+
//#region \0@oxc-project+runtime@0.122.0/helpers/decorateMetadata.js
|
|
1486
|
+
function __decorateMetadata(k, v) {
|
|
1487
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
1488
|
+
}
|
|
1489
|
+
//#endregion
|
|
1490
|
+
//#region \0@oxc-project+runtime@0.122.0/helpers/decorateParam.js
|
|
1491
|
+
function __decorateParam(paramIndex, decorator) {
|
|
1492
|
+
return function(target, key) {
|
|
1493
|
+
decorator(target, key, paramIndex);
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
//#endregion
|
|
1497
|
+
//#region src/query/odata-query.pipe.ts
|
|
1498
|
+
var _ref$5;
|
|
1499
|
+
let ODataQueryPipe = class ODataQueryPipe {
|
|
1500
|
+
constructor(edmRegistry, options) {
|
|
1501
|
+
this.edmRegistry = edmRegistry;
|
|
1502
|
+
this.options = options;
|
|
1503
|
+
}
|
|
1504
|
+
transform(value, metadata) {
|
|
1505
|
+
const entitySetName = metadata.data ?? "";
|
|
1506
|
+
const parts = [];
|
|
1507
|
+
let count = false;
|
|
1508
|
+
let search;
|
|
1509
|
+
let apply;
|
|
1510
|
+
for (const [key, val] of Object.entries(value)) {
|
|
1511
|
+
const lkey = key.toLowerCase();
|
|
1512
|
+
if (lkey === "$count") count = val === "true";
|
|
1513
|
+
else if (lkey === "$search") search = parseSearch(decodeURIComponent(val));
|
|
1514
|
+
else if (lkey === "$apply") apply = parseApply(decodeURIComponent(val));
|
|
1515
|
+
else parts.push(`${key}=${val}`);
|
|
1516
|
+
}
|
|
1517
|
+
const parsed = parseQuery(parts.join("&"));
|
|
1518
|
+
const effectiveMaxTop = this.edmRegistry.getEntitySecurityOptions(entitySetName)?.maxTop ?? this.options.maxTop;
|
|
1519
|
+
const top = parsed.top;
|
|
1520
|
+
if (top !== void 0 && top > effectiveMaxTop) throw new ODataValidationError(`$top value ${top} exceeds maximum of ${effectiveMaxTop}`, entitySetName, "$top");
|
|
1521
|
+
if (entitySetName) {
|
|
1522
|
+
const entitySet = this.edmRegistry.getEntitySet(entitySetName);
|
|
1523
|
+
if (entitySet) {
|
|
1524
|
+
const entityType = this.edmRegistry.getEntityType(entitySet.entityTypeName);
|
|
1525
|
+
if (entityType) {
|
|
1526
|
+
if (parsed.filter) this.validateFilterNode(parsed.filter, entityType);
|
|
1527
|
+
if (parsed.select?.items) for (const item of parsed.select.items) this.validateSelectItem(item, entityType);
|
|
1528
|
+
if (parsed.orderBy) for (const orderItem of parsed.orderBy) this.validateFilterNode(orderItem.expression, entityType);
|
|
1529
|
+
if (parsed.expand?.items) this.validateExpandNode(parsed.expand, entityType);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
return {
|
|
1534
|
+
filter: parsed.filter,
|
|
1535
|
+
select: parsed.select,
|
|
1536
|
+
orderBy: parsed.orderBy,
|
|
1537
|
+
top,
|
|
1538
|
+
skip: parsed.skip,
|
|
1539
|
+
count: count || void 0,
|
|
1540
|
+
entitySetName,
|
|
1541
|
+
expand: parsed.expand,
|
|
1542
|
+
search,
|
|
1543
|
+
apply
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Recursively walk a FilterNode tree and throw ODataValidationError for any
|
|
1548
|
+
* PropertyAccessNode whose path[0] is not in entityType.properties.
|
|
1549
|
+
*
|
|
1550
|
+
* Per D-10: validates property references before any DB query is constructed.
|
|
1551
|
+
* Per T-03-01: unknown properties throw ODataValidationError (not a generic error).
|
|
1552
|
+
*/
|
|
1553
|
+
validateFilterNode(node, entityType) {
|
|
1554
|
+
switch (node.kind) {
|
|
1555
|
+
case "PropertyAccess": {
|
|
1556
|
+
const propertyName = node.path[0];
|
|
1557
|
+
if (!propertyName) break;
|
|
1558
|
+
const knownNames = entityType.properties.map((p) => p.name);
|
|
1559
|
+
if (!knownNames.includes(propertyName)) throw new ODataValidationError(`Property '${propertyName}' not found on entity '${entityType.name}'`, entityType.name, propertyName, knownNames);
|
|
1560
|
+
break;
|
|
1561
|
+
}
|
|
1562
|
+
case "BinaryExpr":
|
|
1563
|
+
this.validateFilterNode(node.left, entityType);
|
|
1564
|
+
this.validateFilterNode(node.right, entityType);
|
|
1565
|
+
break;
|
|
1566
|
+
case "UnaryExpr":
|
|
1567
|
+
this.validateFilterNode(node.operand, entityType);
|
|
1568
|
+
break;
|
|
1569
|
+
case "FunctionCall":
|
|
1570
|
+
for (const arg of node.args) this.validateFilterNode(arg, entityType);
|
|
1571
|
+
break;
|
|
1572
|
+
case "LambdaExpr":
|
|
1573
|
+
if (node.predicate) this.validateFilterNode(node.predicate, entityType);
|
|
1574
|
+
break;
|
|
1575
|
+
case "Literal": break;
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* Validate $expand navigation property names against the entity type's navigationProperties.
|
|
1580
|
+
* Validates top-level navigation properties only — nested expand validation is handled
|
|
1581
|
+
* by TypeOrmExpandVisitor at translation time with full EDM registry access.
|
|
1582
|
+
*
|
|
1583
|
+
* Per T-04-09: prevents users from expanding properties not declared as navigation properties.
|
|
1584
|
+
* Per D-10: validates property references before any DB query is constructed.
|
|
1585
|
+
*/
|
|
1586
|
+
validateExpandNode(expandNode, entityType) {
|
|
1587
|
+
for (const item of expandNode.items) {
|
|
1588
|
+
const navNames = entityType.navigationProperties.map((np) => np.name);
|
|
1589
|
+
if (!navNames.includes(item.navigationProperty)) throw new ODataValidationError(`Navigation property '${item.navigationProperty}' not found on entity '${entityType.name}'`, entityType.name, item.navigationProperty, navNames);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Validate a $select item path[0] against entityType.properties.
|
|
1594
|
+
* Throws ODataValidationError for unknown property names.
|
|
1595
|
+
*/
|
|
1596
|
+
validateSelectItem(item, entityType) {
|
|
1597
|
+
const propertyName = item.path[0];
|
|
1598
|
+
if (!propertyName) return;
|
|
1599
|
+
const knownNames = entityType.properties.map((p) => p.name);
|
|
1600
|
+
if (!knownNames.includes(propertyName)) throw new ODataValidationError(`Property '${propertyName}' not found on entity '${entityType.name}'`, entityType.name, propertyName, knownNames);
|
|
1601
|
+
}
|
|
1602
|
+
};
|
|
1603
|
+
ODataQueryPipe = __decorate([
|
|
1604
|
+
(0, _nestjs_common.Injectable)(),
|
|
1605
|
+
__decorateParam(1, (0, _nestjs_common.Inject)(ODATA_MODULE_OPTIONS)),
|
|
1606
|
+
__decorateMetadata("design:paramtypes", [typeof (_ref$5 = typeof EdmRegistry !== "undefined" && EdmRegistry) === "function" ? _ref$5 : Object, Object])
|
|
1607
|
+
], ODataQueryPipe);
|
|
1608
|
+
//#endregion
|
|
1609
|
+
//#region src/decorators/metadata-keys.ts
|
|
1610
|
+
/** Metadata key for @EdmType() property decorator — stores EdmTypeOptions per property */
|
|
1611
|
+
const EDM_TYPE_KEY = Symbol("nestjs-odata:edm-type");
|
|
1612
|
+
/** Metadata key for @ODataExclude() property decorator — stores Set<string> of excluded property names */
|
|
1613
|
+
const ODATA_EXCLUDE_KEY = Symbol("nestjs-odata:odata-exclude");
|
|
1614
|
+
/** Metadata key for @ODataEntitySet() class decorator — stores the entity set name string */
|
|
1615
|
+
const ODATA_ENTITY_SET_KEY = Symbol("nestjs-odata:entity-set");
|
|
1616
|
+
/** Metadata key for @ODataKey() property decorator — stores string[] of key property names */
|
|
1617
|
+
const ODATA_KEY_KEY = Symbol("nestjs-odata:odata-key");
|
|
1618
|
+
/** Metadata key for @ODataView() class decorator — stores ODataViewOptions */
|
|
1619
|
+
const ODATA_VIEW_KEY = Symbol("nestjs-odata:odata-view");
|
|
1620
|
+
/** Metadata key for @ODataGet() method decorator — marks a route as an OData route */
|
|
1621
|
+
const ODATA_ROUTE_KEY = Symbol("ODATA_ROUTE");
|
|
1622
|
+
/** Metadata key for @ODataController() class decorator — stores the entity set name string */
|
|
1623
|
+
const ODATA_CONTROLLER_KEY = Symbol("nestjs-odata:odata-controller");
|
|
1624
|
+
/** Metadata key for @ODataETag() property decorator — stores the ETag source property name */
|
|
1625
|
+
const ODATA_ETAG_KEY = Symbol("nestjs-odata:odata-etag");
|
|
1626
|
+
/** Metadata key for @ODataSearchable() property decorator — stores string[] of searchable property names */
|
|
1627
|
+
const ODATA_SEARCHABLE_KEY = Symbol("nestjs-odata:odata-searchable");
|
|
1628
|
+
//#endregion
|
|
1629
|
+
//#region src/decorators/odata-etag.decorator.ts
|
|
1630
|
+
/**
|
|
1631
|
+
* Marks a property as the ETag source for concurrency control.
|
|
1632
|
+
* Per D-03: opt-in per entity. Only entities with @ODataETag or @UpdateDateColumn get ETags.
|
|
1633
|
+
*
|
|
1634
|
+
* Usage:
|
|
1635
|
+
* class Product {
|
|
1636
|
+
* @ODataETag()
|
|
1637
|
+
* version: number
|
|
1638
|
+
* }
|
|
1639
|
+
*/
|
|
1640
|
+
function ODataETag() {
|
|
1641
|
+
return (target, propertyKey) => {
|
|
1642
|
+
Reflect.defineMetadata(ODATA_ETAG_KEY, String(propertyKey), target.constructor);
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Get the ETag property name for a given entity class.
|
|
1647
|
+
* Returns undefined if the entity does not have @ODataETag.
|
|
1648
|
+
*/
|
|
1649
|
+
function getETagProperty(target) {
|
|
1650
|
+
return Reflect.getMetadata(ODATA_ETAG_KEY, target);
|
|
1651
|
+
}
|
|
1652
|
+
//#endregion
|
|
1653
|
+
//#region src/decorators/odata-searchable.decorator.ts
|
|
1654
|
+
/**
|
|
1655
|
+
* Marks a property as searchable via OData $search.
|
|
1656
|
+
* Multiple properties on the same entity can be decorated — all are stored.
|
|
1657
|
+
*
|
|
1658
|
+
* Usage:
|
|
1659
|
+
* class Product {
|
|
1660
|
+
* @ODataSearchable()
|
|
1661
|
+
* name: string
|
|
1662
|
+
*
|
|
1663
|
+
* @ODataSearchable()
|
|
1664
|
+
* description: string
|
|
1665
|
+
* }
|
|
1666
|
+
*/
|
|
1667
|
+
function ODataSearchable() {
|
|
1668
|
+
return (target, propertyKey) => {
|
|
1669
|
+
const ctor = target.constructor;
|
|
1670
|
+
const existing = Reflect.getMetadata(ODATA_SEARCHABLE_KEY, ctor) ?? [];
|
|
1671
|
+
Reflect.defineMetadata(ODATA_SEARCHABLE_KEY, [...existing, String(propertyKey)], ctor);
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Get the list of searchable property names for a given entity class.
|
|
1676
|
+
* Returns an empty array if no properties are decorated with @ODataSearchable().
|
|
1677
|
+
*/
|
|
1678
|
+
function getSearchableProperties(target) {
|
|
1679
|
+
return Reflect.getMetadata(ODATA_SEARCHABLE_KEY, target) ?? [];
|
|
1680
|
+
}
|
|
1681
|
+
//#endregion
|
|
1682
|
+
//#region src/decorators/edm-type.decorator.ts
|
|
1683
|
+
/**
|
|
1684
|
+
* @EdmType() — override the EDM primitive type for a property.
|
|
1685
|
+
* Use when the TypeScript type cannot be automatically mapped, or when
|
|
1686
|
+
* precision/scale/maxLength constraints are needed (e.g., Edm.Decimal).
|
|
1687
|
+
*
|
|
1688
|
+
* Zero imports from typeorm, @nestjs/common, or any ORM. Pure reflect-metadata.
|
|
1689
|
+
*/
|
|
1690
|
+
function EdmType(options) {
|
|
1691
|
+
return (target, propertyKey) => {
|
|
1692
|
+
const constructor = target.constructor;
|
|
1693
|
+
const existing = Reflect.getMetadata(EDM_TYPE_KEY, constructor) ?? {};
|
|
1694
|
+
Reflect.defineMetadata(EDM_TYPE_KEY, {
|
|
1695
|
+
...existing,
|
|
1696
|
+
[propertyKey]: options
|
|
1697
|
+
}, constructor);
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
/** Read all @EdmType overrides registered on a class */
|
|
1701
|
+
function getEdmTypeOverrides(target) {
|
|
1702
|
+
return Reflect.getMetadata(EDM_TYPE_KEY, target) ?? {};
|
|
1703
|
+
}
|
|
1704
|
+
//#endregion
|
|
1705
|
+
//#region src/decorators/odata-exclude.decorator.ts
|
|
1706
|
+
/**
|
|
1707
|
+
* @ODataExclude() — marks a property to be excluded from the OData EDM.
|
|
1708
|
+
* The property will not appear in $metadata and will be stripped from responses.
|
|
1709
|
+
*
|
|
1710
|
+
* Zero imports from typeorm, @nestjs/common, or any ORM. Pure reflect-metadata.
|
|
1711
|
+
*/
|
|
1712
|
+
function ODataExclude() {
|
|
1713
|
+
return (target, propertyKey) => {
|
|
1714
|
+
const constructor = target.constructor;
|
|
1715
|
+
const existing = Reflect.getMetadata(ODATA_EXCLUDE_KEY, constructor) ?? /* @__PURE__ */ new Set();
|
|
1716
|
+
const updated = new Set(existing);
|
|
1717
|
+
updated.add(propertyKey);
|
|
1718
|
+
Reflect.defineMetadata(ODATA_EXCLUDE_KEY, updated, constructor);
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
/** Read all property names excluded via @ODataExclude() on a class */
|
|
1722
|
+
function getExcludedProperties(target) {
|
|
1723
|
+
const meta = Reflect.getMetadata(ODATA_EXCLUDE_KEY, target);
|
|
1724
|
+
if (!meta) return /* @__PURE__ */ new Set();
|
|
1725
|
+
const result = /* @__PURE__ */ new Set();
|
|
1726
|
+
for (const key of meta) if (typeof key === "string") result.add(key);
|
|
1727
|
+
return result;
|
|
1728
|
+
}
|
|
1729
|
+
//#endregion
|
|
1730
|
+
//#region src/decorators/odata-entity-set.decorator.ts
|
|
1731
|
+
/**
|
|
1732
|
+
* @ODataEntitySet(name) — override the entity set name for an entity class.
|
|
1733
|
+
* Without this decorator, the adapter will auto-pluralize the class name.
|
|
1734
|
+
*
|
|
1735
|
+
* Zero imports from typeorm, @nestjs/common, or any ORM. Pure reflect-metadata.
|
|
1736
|
+
*/
|
|
1737
|
+
function ODataEntitySet(name) {
|
|
1738
|
+
return (target) => {
|
|
1739
|
+
Reflect.defineMetadata(ODATA_ENTITY_SET_KEY, name, target);
|
|
1740
|
+
};
|
|
1741
|
+
}
|
|
1742
|
+
/** Read the entity set name set via @ODataEntitySet(), or undefined if not set */
|
|
1743
|
+
function getEntitySetName(target) {
|
|
1744
|
+
return Reflect.getMetadata(ODATA_ENTITY_SET_KEY, target);
|
|
1745
|
+
}
|
|
1746
|
+
//#endregion
|
|
1747
|
+
//#region src/decorators/odata-key.decorator.ts
|
|
1748
|
+
/**
|
|
1749
|
+
* @ODataKey() — marks a property as part of the entity key.
|
|
1750
|
+
* Can be applied to multiple properties for composite keys.
|
|
1751
|
+
*
|
|
1752
|
+
* Zero imports from typeorm, @nestjs/common, or any ORM. Pure reflect-metadata.
|
|
1753
|
+
*/
|
|
1754
|
+
function ODataKey() {
|
|
1755
|
+
return (target, propertyKey) => {
|
|
1756
|
+
const constructor = target.constructor;
|
|
1757
|
+
const existing = Reflect.getMetadata(ODATA_KEY_KEY, constructor) ?? [];
|
|
1758
|
+
if (typeof propertyKey === "string") Reflect.defineMetadata(ODATA_KEY_KEY, [...existing, propertyKey], constructor);
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
/** Read all key property names registered via @ODataKey() on a class */
|
|
1762
|
+
function getKeyProperties(target) {
|
|
1763
|
+
return Reflect.getMetadata(ODATA_KEY_KEY, target) ?? [];
|
|
1764
|
+
}
|
|
1765
|
+
//#endregion
|
|
1766
|
+
//#region src/decorators/odata-view.decorator.ts
|
|
1767
|
+
/**
|
|
1768
|
+
* @ODataView() — marks a class as a virtual OData view.
|
|
1769
|
+
* A virtual view projects a subset of an existing entity type's properties
|
|
1770
|
+
* as its own EntitySet without duplicating entity registration. Per D-22, D-23.
|
|
1771
|
+
*
|
|
1772
|
+
* Zero imports from typeorm, @nestjs/common, or any ORM. Pure reflect-metadata.
|
|
1773
|
+
*/
|
|
1774
|
+
function ODataView(options) {
|
|
1775
|
+
return (target) => {
|
|
1776
|
+
Reflect.defineMetadata(ODATA_VIEW_KEY, options, target);
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
/** Read the ODataView options from a class, or undefined if not decorated */
|
|
1780
|
+
function getODataViewOptions(target) {
|
|
1781
|
+
return Reflect.getMetadata(ODATA_VIEW_KEY, target);
|
|
1782
|
+
}
|
|
1783
|
+
//#endregion
|
|
1784
|
+
//#region src/response/odata-context-url.builder.ts
|
|
1785
|
+
/**
|
|
1786
|
+
* Builds the OData v4 @odata.context URL per spec section 10.
|
|
1787
|
+
*
|
|
1788
|
+
* Format: {serviceRoot}/$metadata#{entitySetName}[(field1,field2,...)]
|
|
1789
|
+
* Single-entity format: {serviceRoot}/$metadata#{entitySetName}/$entity
|
|
1790
|
+
*
|
|
1791
|
+
* Per D-07: when $select has specific items (not all, not undefined),
|
|
1792
|
+
* a select projection suffix is appended.
|
|
1793
|
+
* Per D-05: when isSingleEntity is true, /$entity suffix is appended.
|
|
1794
|
+
*
|
|
1795
|
+
* @param serviceRoot - The OData service root path (e.g. '/odata')
|
|
1796
|
+
* @param entitySetName - The name of the entity set (e.g. 'Products')
|
|
1797
|
+
* @param select - Optional SelectNode from the parsed query
|
|
1798
|
+
* @param isSingleEntity - When true, appends /$entity suffix for single-entity responses
|
|
1799
|
+
*/
|
|
1800
|
+
function buildContextUrl(serviceRoot, entitySetName, select, isSingleEntity) {
|
|
1801
|
+
const base = `${serviceRoot.endsWith("/") ? serviceRoot.slice(0, -1) : serviceRoot}/$metadata#${entitySetName}`;
|
|
1802
|
+
if (isSingleEntity) return `${base}/$entity`;
|
|
1803
|
+
if (select?.items?.length) return `${base}(${select.items.map((item) => item.path.join("/")).join(",")})`;
|
|
1804
|
+
return base;
|
|
1805
|
+
}
|
|
1806
|
+
//#endregion
|
|
1807
|
+
//#region src/response/odata-annotation.builder.ts
|
|
1808
|
+
/**
|
|
1809
|
+
* Build the canonical key string for a given entity and its key properties.
|
|
1810
|
+
*
|
|
1811
|
+
* - Single key, integer value: (1)
|
|
1812
|
+
* - Single key, string value: ('abc')
|
|
1813
|
+
* - Composite key: (orderId=1,productId=2)
|
|
1814
|
+
*
|
|
1815
|
+
* @param entity - The entity object
|
|
1816
|
+
* @param keyProperties - The key property names from the EDM entity type
|
|
1817
|
+
*/
|
|
1818
|
+
function buildKeyString(entity, keyProperties) {
|
|
1819
|
+
if (keyProperties.length === 1) {
|
|
1820
|
+
const keyValue = entity[keyProperties[0]];
|
|
1821
|
+
if (typeof keyValue === "string") return `('${keyValue}')`;
|
|
1822
|
+
return `(${String(keyValue)})`;
|
|
1823
|
+
}
|
|
1824
|
+
return `(${keyProperties.map((keyName) => {
|
|
1825
|
+
const keyValue = entity[keyName];
|
|
1826
|
+
if (typeof keyValue === "string") return `${keyName}='${keyValue}'`;
|
|
1827
|
+
return `${keyName}=${String(keyValue)}`;
|
|
1828
|
+
}).join(",")})`;
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* Annotate a single entity object with OData v4 metadata annotations.
|
|
1832
|
+
*
|
|
1833
|
+
* Adds:
|
|
1834
|
+
* - @odata.id — canonical URL for the entity (RESP-04)
|
|
1835
|
+
* - @odata.type — qualified type name with namespace prefix (RESP-05)
|
|
1836
|
+
* - {navProp}@odata.navigationLink — for each navigation property (RESP-06)
|
|
1837
|
+
*
|
|
1838
|
+
* Returns the entity unchanged if it is null, undefined, or not an object.
|
|
1839
|
+
*
|
|
1840
|
+
* This function is pure — it never mutates the input entity.
|
|
1841
|
+
*
|
|
1842
|
+
* @param entity - The entity object to annotate
|
|
1843
|
+
* @param ctx - The annotation context (service root, entity set name, entity type, namespace)
|
|
1844
|
+
*/
|
|
1845
|
+
function annotateEntity(entity, ctx) {
|
|
1846
|
+
if (entity === null || entity === void 0 || typeof entity !== "object") return entity;
|
|
1847
|
+
const { serviceRoot, entitySetName, entityType, namespace } = ctx;
|
|
1848
|
+
const odataId = `${serviceRoot.endsWith("/") ? serviceRoot.slice(0, -1) : serviceRoot}/${entitySetName}${buildKeyString(entity, entityType.keyProperties)}`;
|
|
1849
|
+
const odataType = `#${namespace}.${entityType.name}`;
|
|
1850
|
+
const navLinks = {};
|
|
1851
|
+
for (const navProp of entityType.navigationProperties) navLinks[`${navProp.name}@odata.navigationLink`] = `${odataId}/${navProp.name}`;
|
|
1852
|
+
return {
|
|
1853
|
+
...entity,
|
|
1854
|
+
"@odata.id": odataId,
|
|
1855
|
+
"@odata.type": odataType,
|
|
1856
|
+
...navLinks
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* Annotate an array of entity objects with OData v4 metadata annotations.
|
|
1861
|
+
*
|
|
1862
|
+
* Applies `annotateEntity` to every item in the array and returns a new array.
|
|
1863
|
+
* Does not mutate the input array or any items.
|
|
1864
|
+
*
|
|
1865
|
+
* @param items - The array of entity objects to annotate
|
|
1866
|
+
* @param ctx - The annotation context
|
|
1867
|
+
*/
|
|
1868
|
+
function annotateEntities(items, ctx) {
|
|
1869
|
+
return items.map((item) => annotateEntity(item, ctx));
|
|
1870
|
+
}
|
|
1871
|
+
//#endregion
|
|
1872
|
+
//#region src/response/odata-response.interceptor.ts
|
|
1873
|
+
var _ref$4, _ref2$1;
|
|
1874
|
+
let ODataResponseInterceptor = class ODataResponseInterceptor {
|
|
1875
|
+
constructor(reflector, options, edmRegistry) {
|
|
1876
|
+
this.reflector = reflector;
|
|
1877
|
+
this.options = options;
|
|
1878
|
+
this.edmRegistry = edmRegistry;
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Resolve the AnnotationContext for an entity set name.
|
|
1882
|
+
* Returns null when the entity set or its type is not registered (graceful degradation).
|
|
1883
|
+
*/
|
|
1884
|
+
resolveAnnotationContext(entitySetName) {
|
|
1885
|
+
const entitySet = this.edmRegistry.getEntitySet(entitySetName);
|
|
1886
|
+
if (!entitySet) return null;
|
|
1887
|
+
const entityType = this.edmRegistry.getEntityType(entitySet.entityTypeName);
|
|
1888
|
+
if (!entityType) return null;
|
|
1889
|
+
return {
|
|
1890
|
+
serviceRoot: this.options.serviceRoot,
|
|
1891
|
+
entitySetName,
|
|
1892
|
+
entityType,
|
|
1893
|
+
namespace: this.options.namespace
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
intercept(context, next) {
|
|
1897
|
+
const metadata = this.reflector.get(ODATA_ROUTE_KEY, context.getHandler());
|
|
1898
|
+
if (!metadata) return next.handle();
|
|
1899
|
+
const { entitySetName, operation, isSingleEntity } = metadata;
|
|
1900
|
+
return next.handle().pipe((0, rxjs_operators.map)((result) => {
|
|
1901
|
+
if (operation === "create" && result !== null && typeof result === "object") {
|
|
1902
|
+
const createResult = result;
|
|
1903
|
+
if (createResult.locationUrl) context.switchToHttp().getResponse().setHeader("Location", createResult.locationUrl);
|
|
1904
|
+
const entity = createResult.entity ?? result;
|
|
1905
|
+
const contextUrl = buildContextUrl(this.options.serviceRoot, entitySetName, void 0, true);
|
|
1906
|
+
const annotationCtx = this.resolveAnnotationContext(entitySetName);
|
|
1907
|
+
const annotatedEntity = annotationCtx ? annotateEntity(entity, annotationCtx) : entity;
|
|
1908
|
+
return {
|
|
1909
|
+
"@odata.context": contextUrl,
|
|
1910
|
+
...annotatedEntity
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
if (isSingleEntity) {
|
|
1914
|
+
const entityResult = result;
|
|
1915
|
+
if (entityResult["__notModified"] === true) {
|
|
1916
|
+
const etag = entityResult["etag"];
|
|
1917
|
+
const httpResponse = context.switchToHttp().getResponse();
|
|
1918
|
+
httpResponse.setHeader("ETag", etag);
|
|
1919
|
+
httpResponse.status(304).end();
|
|
1920
|
+
return null;
|
|
1921
|
+
}
|
|
1922
|
+
const contextUrl = buildContextUrl(this.options.serviceRoot, entitySetName, void 0, true);
|
|
1923
|
+
const annotationCtx = this.resolveAnnotationContext(entitySetName);
|
|
1924
|
+
let entityToAnnotate = entityResult;
|
|
1925
|
+
if (entityResult["__etag"]) {
|
|
1926
|
+
const etag = entityResult["__etag"];
|
|
1927
|
+
context.switchToHttp().getResponse().setHeader("ETag", etag);
|
|
1928
|
+
const rest = {};
|
|
1929
|
+
for (const key of Object.keys(entityResult)) if (key !== "__etag") rest[key] = entityResult[key];
|
|
1930
|
+
entityToAnnotate = {
|
|
1931
|
+
"@odata.etag": etag,
|
|
1932
|
+
...rest
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
const annotatedEntity = annotationCtx ? annotateEntity(entityToAnnotate, annotationCtx) : entityToAnnotate;
|
|
1936
|
+
return {
|
|
1937
|
+
"@odata.context": contextUrl,
|
|
1938
|
+
...annotatedEntity
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
const queryResult = result;
|
|
1942
|
+
if (queryResult.isAggregated) {
|
|
1943
|
+
const projectedSelect = queryResult.applyProperties?.length ? { items: queryResult.applyProperties.map((p) => ({ path: [p] })) } : void 0;
|
|
1944
|
+
const aggContextUrl = buildContextUrl(this.options.serviceRoot, entitySetName, projectedSelect);
|
|
1945
|
+
const aggResponse = {
|
|
1946
|
+
"@odata.context": aggContextUrl,
|
|
1947
|
+
value: queryResult.items
|
|
1948
|
+
};
|
|
1949
|
+
if (queryResult.count !== void 0) aggResponse["@odata.count"] = queryResult.count;
|
|
1950
|
+
return aggResponse;
|
|
1951
|
+
}
|
|
1952
|
+
const contextUrl = buildContextUrl(this.options.serviceRoot, entitySetName, queryResult.select);
|
|
1953
|
+
const annotationCtx = this.resolveAnnotationContext(entitySetName);
|
|
1954
|
+
const items = annotationCtx ? annotateEntities(queryResult.items, annotationCtx) : queryResult.items;
|
|
1955
|
+
const response = {
|
|
1956
|
+
"@odata.context": contextUrl,
|
|
1957
|
+
value: items
|
|
1958
|
+
};
|
|
1959
|
+
if (queryResult.count !== void 0) response["@odata.count"] = queryResult.count;
|
|
1960
|
+
if (queryResult.nextLink !== void 0) response["@odata.nextLink"] = queryResult.nextLink;
|
|
1961
|
+
return response;
|
|
1962
|
+
}));
|
|
1963
|
+
}
|
|
1964
|
+
};
|
|
1965
|
+
ODataResponseInterceptor = __decorate([
|
|
1966
|
+
(0, _nestjs_common.Injectable)(),
|
|
1967
|
+
__decorateParam(1, (0, _nestjs_common.Inject)(ODATA_MODULE_OPTIONS)),
|
|
1968
|
+
__decorateMetadata("design:paramtypes", [
|
|
1969
|
+
typeof (_ref$4 = typeof _nestjs_core.Reflector !== "undefined" && _nestjs_core.Reflector) === "function" ? _ref$4 : Object,
|
|
1970
|
+
Object,
|
|
1971
|
+
typeof (_ref2$1 = typeof EdmRegistry !== "undefined" && EdmRegistry) === "function" ? _ref2$1 : Object
|
|
1972
|
+
])
|
|
1973
|
+
], ODataResponseInterceptor);
|
|
1974
|
+
//#endregion
|
|
1975
|
+
//#region src/response/odata-exception.filter.ts
|
|
1976
|
+
let ODataExceptionFilter = class ODataExceptionFilter {
|
|
1977
|
+
catch(exception, host) {
|
|
1978
|
+
const response = host.switchToHttp().getResponse();
|
|
1979
|
+
let status;
|
|
1980
|
+
let body;
|
|
1981
|
+
if (exception instanceof ODataParseError) {
|
|
1982
|
+
status = _nestjs_common.HttpStatus.BAD_REQUEST;
|
|
1983
|
+
const details = exception.queryContext ? [{ target: exception.queryContext }] : [];
|
|
1984
|
+
body = { error: {
|
|
1985
|
+
code: "BadRequest",
|
|
1986
|
+
message: exception.message,
|
|
1987
|
+
details
|
|
1988
|
+
} };
|
|
1989
|
+
} else if (exception instanceof ODataValidationError) {
|
|
1990
|
+
status = _nestjs_common.HttpStatus.BAD_REQUEST;
|
|
1991
|
+
const details = exception.availableProperties && exception.availableProperties.length > 0 ? [{
|
|
1992
|
+
target: "availableProperties",
|
|
1993
|
+
value: [...exception.availableProperties]
|
|
1994
|
+
}] : [];
|
|
1995
|
+
body = { error: {
|
|
1996
|
+
code: "BadRequest",
|
|
1997
|
+
message: exception.message,
|
|
1998
|
+
details
|
|
1999
|
+
} };
|
|
2000
|
+
} else if (exception instanceof _nestjs_common.HttpException) {
|
|
2001
|
+
status = exception.getStatus();
|
|
2002
|
+
body = { error: {
|
|
2003
|
+
code: httpStatusToODataCode(status),
|
|
2004
|
+
message: exception.message,
|
|
2005
|
+
details: []
|
|
2006
|
+
} };
|
|
2007
|
+
} else {
|
|
2008
|
+
status = _nestjs_common.HttpStatus.INTERNAL_SERVER_ERROR;
|
|
2009
|
+
body = { error: {
|
|
2010
|
+
code: "InternalServerError",
|
|
2011
|
+
message: "An unexpected error occurred.",
|
|
2012
|
+
details: []
|
|
2013
|
+
} };
|
|
2014
|
+
}
|
|
2015
|
+
response.status(status).json(body);
|
|
2016
|
+
}
|
|
2017
|
+
};
|
|
2018
|
+
ODataExceptionFilter = __decorate([(0, _nestjs_common.Catch)()], ODataExceptionFilter);
|
|
2019
|
+
/**
|
|
2020
|
+
* Map HTTP status code to an OData error code string.
|
|
2021
|
+
* Only covers the most common HTTP status codes.
|
|
2022
|
+
*/
|
|
2023
|
+
function httpStatusToODataCode(status) {
|
|
2024
|
+
return {
|
|
2025
|
+
400: "BadRequest",
|
|
2026
|
+
401: "Unauthorized",
|
|
2027
|
+
403: "Forbidden",
|
|
2028
|
+
404: "NotFound",
|
|
2029
|
+
405: "MethodNotAllowed",
|
|
2030
|
+
409: "Conflict",
|
|
2031
|
+
410: "Gone",
|
|
2032
|
+
422: "UnprocessableEntity",
|
|
2033
|
+
429: "TooManyRequests",
|
|
2034
|
+
500: "InternalServerError",
|
|
2035
|
+
501: "NotImplemented",
|
|
2036
|
+
503: "ServiceUnavailable"
|
|
2037
|
+
}[status] ?? "Error";
|
|
2038
|
+
}
|
|
2039
|
+
//#endregion
|
|
2040
|
+
//#region src/decorators/odata-get.decorator.ts
|
|
2041
|
+
/**
|
|
2042
|
+
* Composite method decorator for OData GET endpoints.
|
|
2043
|
+
*
|
|
2044
|
+
* Composes:
|
|
2045
|
+
* 1. Get(path) — NestJS route decorator
|
|
2046
|
+
* 2. SetMetadata(ODATA_ROUTE_KEY, { entitySetName }) — marks the route as OData
|
|
2047
|
+
* 3. UseInterceptors(ODataResponseInterceptor) — wraps result in OData JSON envelope
|
|
2048
|
+
* 4. UseFilters(ODataExceptionFilter) — formats errors as OData v4 error bodies
|
|
2049
|
+
*
|
|
2050
|
+
* @param entitySetName - The OData entity set name (e.g. 'Products'). Used to build
|
|
2051
|
+
* the @odata.context URL and for response formatting.
|
|
2052
|
+
* @param options - Optional configuration
|
|
2053
|
+
*
|
|
2054
|
+
* Per D-12, D-13, RESEARCH.md Pattern 5.
|
|
2055
|
+
* Zero TypeORM imports — per PKG-01 architecture constraint.
|
|
2056
|
+
*/
|
|
2057
|
+
function ODataGet(entitySetName, options) {
|
|
2058
|
+
return (0, _nestjs_common.applyDecorators)((0, _nestjs_common.Get)(options?.path ?? entitySetName), (0, _nestjs_common.SetMetadata)(ODATA_ROUTE_KEY, {
|
|
2059
|
+
entitySetName,
|
|
2060
|
+
autoHandler: options?.autoHandler ?? false
|
|
2061
|
+
}), (0, _nestjs_common.UseInterceptors)(ODataResponseInterceptor), (0, _nestjs_common.UseFilters)(ODataExceptionFilter));
|
|
2062
|
+
}
|
|
2063
|
+
//#endregion
|
|
2064
|
+
//#region src/decorators/odata-query.decorator.ts
|
|
2065
|
+
/**
|
|
2066
|
+
* Internal param decorator that extracts req.query from the HTTP execution context.
|
|
2067
|
+
* Not exported — consumers use the ODataQueryParam wrapper which auto-attaches ODataQueryPipe.
|
|
2068
|
+
*/
|
|
2069
|
+
const RawQuery = (0, _nestjs_common.createParamDecorator)((_data, ctx) => {
|
|
2070
|
+
return ctx.switchToHttp().getRequest().query;
|
|
2071
|
+
});
|
|
2072
|
+
/**
|
|
2073
|
+
* Parameter decorator that extracts req.query and auto-applies ODataQueryPipe.
|
|
2074
|
+
*
|
|
2075
|
+
* Usage:
|
|
2076
|
+
* @Get()
|
|
2077
|
+
* async getProducts(@ODataQueryParam('Products') query: ODataQuery) { ... }
|
|
2078
|
+
*
|
|
2079
|
+
* The decorator passes the entitySetName as `data` so that the ODataQueryPipe
|
|
2080
|
+
* can inject it into the query object for context URL construction and field validation.
|
|
2081
|
+
*
|
|
2082
|
+
* Per D-04: auto-applies ODataQueryPipe — no @UsePipes(ODataQueryPipe) needed.
|
|
2083
|
+
* Per D-05: ODataQueryPipe remains exported for advanced use cases.
|
|
2084
|
+
* Per D-06: eliminates silent validation bypass when forgetting @UsePipes.
|
|
2085
|
+
*
|
|
2086
|
+
* NestJS resolves ODataQueryPipe from DI automatically when a class reference
|
|
2087
|
+
* is passed as the pipe argument to a createParamDecorator result.
|
|
2088
|
+
*
|
|
2089
|
+
* Zero TypeORM imports — per PKG-01 architecture constraint.
|
|
2090
|
+
*/
|
|
2091
|
+
const ODataQueryParam = (entitySetName) => RawQuery(entitySetName, ODataQueryPipe);
|
|
2092
|
+
//#endregion
|
|
2093
|
+
//#region src/decorators/odata-post.decorator.ts
|
|
2094
|
+
/**
|
|
2095
|
+
* Composite method decorator for OData POST (create) endpoints.
|
|
2096
|
+
*
|
|
2097
|
+
* Composes:
|
|
2098
|
+
* 1. Post(path) — NestJS route decorator
|
|
2099
|
+
* 2. HttpCode(201) — POST returns 201 Created per D-03
|
|
2100
|
+
* 3. SetMetadata(ODATA_ROUTE_KEY, { entitySetName, operation: 'create' })
|
|
2101
|
+
* 4. UseInterceptors(ODataResponseInterceptor) — wraps result in OData JSON envelope
|
|
2102
|
+
* 5. UseFilters(ODataExceptionFilter) — formats errors as OData v4 error bodies
|
|
2103
|
+
*
|
|
2104
|
+
* Per D-03: POST returns 201. Per D-12: explicit opt-in per operation.
|
|
2105
|
+
* Zero TypeORM imports — per PKG-01 architecture constraint.
|
|
2106
|
+
*/
|
|
2107
|
+
function ODataPost(entitySetName, options) {
|
|
2108
|
+
return (0, _nestjs_common.applyDecorators)((0, _nestjs_common.Post)(options?.path ?? ""), (0, _nestjs_common.HttpCode)(201), (0, _nestjs_common.SetMetadata)(ODATA_ROUTE_KEY, {
|
|
2109
|
+
entitySetName,
|
|
2110
|
+
operation: "create"
|
|
2111
|
+
}), (0, _nestjs_common.UseInterceptors)(ODataResponseInterceptor), (0, _nestjs_common.UseFilters)(ODataExceptionFilter));
|
|
2112
|
+
}
|
|
2113
|
+
//#endregion
|
|
2114
|
+
//#region src/decorators/odata-patch.decorator.ts
|
|
2115
|
+
/**
|
|
2116
|
+
* Composite method decorator for OData PATCH (update) endpoints.
|
|
2117
|
+
*
|
|
2118
|
+
* Composes:
|
|
2119
|
+
* 1. Patch(path) — NestJS route decorator, defaults to ':key'
|
|
2120
|
+
* 2. SetMetadata(ODATA_ROUTE_KEY, { entitySetName, operation: 'update', isSingleEntity: true })
|
|
2121
|
+
* 3. UseInterceptors(ODataResponseInterceptor) — wraps result in single-entity OData envelope
|
|
2122
|
+
* 4. UseFilters(ODataExceptionFilter) — formats errors as OData v4 error bodies
|
|
2123
|
+
*
|
|
2124
|
+
* Per D-01: merge-patch semantics. Per D-02: parenthetical key via ':key' param.
|
|
2125
|
+
* Zero TypeORM imports — per PKG-01 architecture constraint.
|
|
2126
|
+
*/
|
|
2127
|
+
function ODataPatch(entitySetName, options) {
|
|
2128
|
+
return (0, _nestjs_common.applyDecorators)((0, _nestjs_common.Patch)(options?.path ?? ":key"), (0, _nestjs_common.SetMetadata)(ODATA_ROUTE_KEY, {
|
|
2129
|
+
entitySetName,
|
|
2130
|
+
operation: "update",
|
|
2131
|
+
isSingleEntity: true
|
|
2132
|
+
}), (0, _nestjs_common.UseInterceptors)(ODataResponseInterceptor), (0, _nestjs_common.UseFilters)(ODataExceptionFilter));
|
|
2133
|
+
}
|
|
2134
|
+
//#endregion
|
|
2135
|
+
//#region src/decorators/odata-put.decorator.ts
|
|
2136
|
+
/**
|
|
2137
|
+
* Composite method decorator for OData PUT (full entity replacement) endpoints.
|
|
2138
|
+
*
|
|
2139
|
+
* Composes:
|
|
2140
|
+
* 1. Put(path) — NestJS route decorator, defaults to ':key'
|
|
2141
|
+
* 2. SetMetadata(ODATA_ROUTE_KEY, { entitySetName, operation: 'replace', isSingleEntity: true })
|
|
2142
|
+
* 3. UseInterceptors(ODataResponseInterceptor) — wraps result in single-entity OData envelope
|
|
2143
|
+
* 4. UseFilters(ODataExceptionFilter) — formats errors as OData v4 error bodies
|
|
2144
|
+
*
|
|
2145
|
+
* Per D-01: PUT semantics — full entity replacement. All unspecified fields reset to
|
|
2146
|
+
* column defaults (null for nullable, default value for columns with defaults).
|
|
2147
|
+
* Per D-02: parenthetical key via ':key' param.
|
|
2148
|
+
* Zero TypeORM imports — per PKG-01 architecture constraint.
|
|
2149
|
+
*/
|
|
2150
|
+
function ODataPut(entitySetName, options) {
|
|
2151
|
+
return (0, _nestjs_common.applyDecorators)((0, _nestjs_common.Put)(options?.path ?? ":key"), (0, _nestjs_common.SetMetadata)(ODATA_ROUTE_KEY, {
|
|
2152
|
+
entitySetName,
|
|
2153
|
+
operation: "replace",
|
|
2154
|
+
isSingleEntity: true
|
|
2155
|
+
}), (0, _nestjs_common.UseInterceptors)(ODataResponseInterceptor), (0, _nestjs_common.UseFilters)(ODataExceptionFilter));
|
|
2156
|
+
}
|
|
2157
|
+
//#endregion
|
|
2158
|
+
//#region src/decorators/odata-delete.decorator.ts
|
|
2159
|
+
/**
|
|
2160
|
+
* Composite method decorator for OData DELETE endpoints.
|
|
2161
|
+
*
|
|
2162
|
+
* Composes:
|
|
2163
|
+
* 1. Delete(path) — NestJS route decorator, defaults to ':key'
|
|
2164
|
+
* 2. HttpCode(204) — DELETE returns 204 No Content per D-04
|
|
2165
|
+
* 3. SetMetadata(ODATA_ROUTE_KEY, { entitySetName, operation: 'delete' })
|
|
2166
|
+
* 4. UseFilters(ODataExceptionFilter) — formats errors as OData v4 error bodies
|
|
2167
|
+
*
|
|
2168
|
+
* Per D-04: DELETE returns 204 with no body. No ODataResponseInterceptor — no body.
|
|
2169
|
+
* Zero TypeORM imports — per PKG-01 architecture constraint.
|
|
2170
|
+
*/
|
|
2171
|
+
function ODataDelete(entitySetName, options) {
|
|
2172
|
+
return (0, _nestjs_common.applyDecorators)((0, _nestjs_common.Delete)(options?.path ?? ":key"), (0, _nestjs_common.HttpCode)(204), (0, _nestjs_common.SetMetadata)(ODATA_ROUTE_KEY, {
|
|
2173
|
+
entitySetName,
|
|
2174
|
+
operation: "delete"
|
|
2175
|
+
}), (0, _nestjs_common.UseFilters)(ODataExceptionFilter));
|
|
2176
|
+
}
|
|
2177
|
+
//#endregion
|
|
2178
|
+
//#region src/decorators/odata-get-by-key.decorator.ts
|
|
2179
|
+
/**
|
|
2180
|
+
* Composite method decorator for OData GET-by-key (single entity) endpoints.
|
|
2181
|
+
*
|
|
2182
|
+
* Composes:
|
|
2183
|
+
* 1. Get(path) — NestJS route decorator, defaults to ':key'
|
|
2184
|
+
* 2. SetMetadata(ODATA_ROUTE_KEY, { entitySetName, operation: 'getByKey', isSingleEntity: true })
|
|
2185
|
+
* 3. UseInterceptors(ODataResponseInterceptor) — returns entity directly (not wrapped in value array)
|
|
2186
|
+
* 4. UseFilters(ODataExceptionFilter) — formats errors as OData v4 error bodies
|
|
2187
|
+
*
|
|
2188
|
+
* Per D-05: Returns single entity, not wrapped in value array.
|
|
2189
|
+
* Context URL uses /$entity suffix for single-entity responses.
|
|
2190
|
+
* Zero TypeORM imports — per PKG-01 architecture constraint.
|
|
2191
|
+
*/
|
|
2192
|
+
function ODataGetByKey(entitySetName, options) {
|
|
2193
|
+
return (0, _nestjs_common.applyDecorators)((0, _nestjs_common.Get)(options?.path ?? ":key"), (0, _nestjs_common.SetMetadata)(ODATA_ROUTE_KEY, {
|
|
2194
|
+
entitySetName,
|
|
2195
|
+
operation: "getByKey",
|
|
2196
|
+
isSingleEntity: true
|
|
2197
|
+
}), (0, _nestjs_common.UseInterceptors)(ODataResponseInterceptor), (0, _nestjs_common.UseFilters)(ODataExceptionFilter));
|
|
2198
|
+
}
|
|
2199
|
+
//#endregion
|
|
2200
|
+
//#region src/decorators/odata-controller.decorator.ts
|
|
2201
|
+
/**
|
|
2202
|
+
* Class decorator for OData controllers.
|
|
2203
|
+
*
|
|
2204
|
+
* Composes:
|
|
2205
|
+
* 1. Controller(path) — NestJS route prefix, defaults to entitySetName
|
|
2206
|
+
* 2. SetMetadata(ODATA_CONTROLLER_KEY, entitySetName) — marks as OData controller
|
|
2207
|
+
* (used by ODataModule.forRoot() to patch PATH_METADATA with serviceRoot)
|
|
2208
|
+
*
|
|
2209
|
+
* Per D-11: Separate from @Controller() — sets entity context and route prefix.
|
|
2210
|
+
* Per D-17: The service root prefix is applied by ODataModule.forRoot({ controllers })
|
|
2211
|
+
* via Reflect.defineMetadata(PATH_METADATA) — the same pattern used by MetadataController.
|
|
2212
|
+
* The controller is initially registered with just the entitySetName as path;
|
|
2213
|
+
* the module prepends serviceRoot during forRoot().
|
|
2214
|
+
*
|
|
2215
|
+
* Note: ODataResponseInterceptor and ODataExceptionFilter are NOT applied at class level.
|
|
2216
|
+
* Each CRUD method decorator (@ODataGet, @ODataPost, @ODataPatch, @ODataDelete, @ODataGetByKey)
|
|
2217
|
+
* applies these per-method. This avoids double-wrapping when method and class decorators
|
|
2218
|
+
* are both used.
|
|
2219
|
+
*
|
|
2220
|
+
* @param entitySetName - The OData entity set name (e.g. 'Products')
|
|
2221
|
+
* @param options - Optional configuration
|
|
2222
|
+
*
|
|
2223
|
+
* Zero TypeORM imports — per PKG-01 architecture constraint.
|
|
2224
|
+
*/
|
|
2225
|
+
function ODataController(entitySetName, options) {
|
|
2226
|
+
return (0, _nestjs_common.applyDecorators)((0, _nestjs_common.Controller)(options?.path ?? entitySetName), (0, _nestjs_common.SetMetadata)(ODATA_CONTROLLER_KEY, entitySetName));
|
|
2227
|
+
}
|
|
2228
|
+
//#endregion
|
|
2229
|
+
//#region src/edm/edm-feature-initializer.ts
|
|
2230
|
+
var _ref$3;
|
|
2231
|
+
let EdmFeatureInitializer = class EdmFeatureInitializer {
|
|
2232
|
+
constructor(edmRegistry, options, entityConfigs) {
|
|
2233
|
+
this.edmRegistry = edmRegistry;
|
|
2234
|
+
this.options = options;
|
|
2235
|
+
this.entityConfigs = entityConfigs;
|
|
2236
|
+
}
|
|
2237
|
+
onModuleInit() {
|
|
2238
|
+
const namespace = this.options.namespace;
|
|
2239
|
+
for (const config of this.entityConfigs) {
|
|
2240
|
+
const entityType = {
|
|
2241
|
+
name: config.entityTypeName,
|
|
2242
|
+
namespace,
|
|
2243
|
+
properties: config.properties,
|
|
2244
|
+
navigationProperties: config.navigationProperties,
|
|
2245
|
+
keyProperties: config.keyProperties,
|
|
2246
|
+
isReadOnly: config.isReadOnly
|
|
2247
|
+
};
|
|
2248
|
+
const entitySet = {
|
|
2249
|
+
name: config.entitySetName,
|
|
2250
|
+
entityTypeName: config.entityTypeName,
|
|
2251
|
+
namespace,
|
|
2252
|
+
isReadOnly: config.isReadOnly
|
|
2253
|
+
};
|
|
2254
|
+
this.edmRegistry.register(entityType, entitySet);
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
};
|
|
2258
|
+
EdmFeatureInitializer = __decorate([
|
|
2259
|
+
(0, _nestjs_common.Injectable)(),
|
|
2260
|
+
__decorateParam(1, (0, _nestjs_common.Inject)(ODATA_MODULE_OPTIONS)),
|
|
2261
|
+
__decorateParam(2, (0, _nestjs_common.Inject)(EDM_ENTITY_CONFIGS)),
|
|
2262
|
+
__decorateMetadata("design:paramtypes", [
|
|
2263
|
+
typeof (_ref$3 = typeof EdmRegistry !== "undefined" && EdmRegistry) === "function" ? _ref$3 : Object,
|
|
2264
|
+
Object,
|
|
2265
|
+
Array
|
|
2266
|
+
])
|
|
2267
|
+
], EdmFeatureInitializer);
|
|
2268
|
+
//#endregion
|
|
2269
|
+
//#region src/metadata/csdl-builder.ts
|
|
2270
|
+
var _ref$2;
|
|
2271
|
+
let CsdlBuilder = class CsdlBuilder {
|
|
2272
|
+
cachedXml = null;
|
|
2273
|
+
constructor(edmRegistry, options) {
|
|
2274
|
+
this.edmRegistry = edmRegistry;
|
|
2275
|
+
this.options = options;
|
|
2276
|
+
}
|
|
2277
|
+
/**
|
|
2278
|
+
* Build and return the CSDL XML string.
|
|
2279
|
+
* Builds once on first call; returns cached string on subsequent calls.
|
|
2280
|
+
*/
|
|
2281
|
+
buildCsdlXml() {
|
|
2282
|
+
if (this.cachedXml !== null) return this.cachedXml;
|
|
2283
|
+
this.cachedXml = this.buildXml();
|
|
2284
|
+
return this.cachedXml;
|
|
2285
|
+
}
|
|
2286
|
+
buildXml() {
|
|
2287
|
+
const namespace = this.options.namespace ?? "Default";
|
|
2288
|
+
const entityTypes = Array.from(this.edmRegistry.getEntityTypes().values());
|
|
2289
|
+
const entitySets = Array.from(this.edmRegistry.getEntitySets().values());
|
|
2290
|
+
const parts = [];
|
|
2291
|
+
parts.push("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
|
|
2292
|
+
parts.push("<edmx:Edmx xmlns:edmx=\"http://docs.oasis-open.org/odata/ns/edmx\" Version=\"4.0\">");
|
|
2293
|
+
parts.push(" <edmx:DataServices>");
|
|
2294
|
+
parts.push(` <Schema xmlns="http://docs.oasis-open.org/odata/ns/edm" Namespace="${namespace}">`);
|
|
2295
|
+
for (const entityType of entityTypes) parts.push(this.buildEntityTypeXml(entityType));
|
|
2296
|
+
parts.push(" <EntityContainer Name=\"Container\">");
|
|
2297
|
+
for (const entitySet of entitySets) parts.push(this.buildEntitySetXml(entitySet, namespace));
|
|
2298
|
+
parts.push(" </EntityContainer>");
|
|
2299
|
+
parts.push(" </Schema>");
|
|
2300
|
+
parts.push(" </edmx:DataServices>");
|
|
2301
|
+
parts.push("</edmx:Edmx>");
|
|
2302
|
+
return parts.join("\n");
|
|
2303
|
+
}
|
|
2304
|
+
buildEntityTypeXml(entityType) {
|
|
2305
|
+
const lines = [];
|
|
2306
|
+
lines.push(` <EntityType Name="${entityType.name}">`);
|
|
2307
|
+
if (entityType.keyProperties.length > 0) {
|
|
2308
|
+
lines.push(" <Key>");
|
|
2309
|
+
for (const keyProp of entityType.keyProperties) lines.push(` <PropertyRef Name="${keyProp}"/>`);
|
|
2310
|
+
lines.push(" </Key>");
|
|
2311
|
+
}
|
|
2312
|
+
for (const prop of entityType.properties) lines.push(this.buildPropertyXml(prop));
|
|
2313
|
+
for (const nav of entityType.navigationProperties) lines.push(this.buildNavigationPropertyXml(nav));
|
|
2314
|
+
lines.push(" </EntityType>");
|
|
2315
|
+
return lines.join("\n");
|
|
2316
|
+
}
|
|
2317
|
+
buildPropertyXml(prop) {
|
|
2318
|
+
let xml = ` <Property Name="${prop.name}" Type="${prop.type}" Nullable="${prop.nullable}"`;
|
|
2319
|
+
if (prop.precision !== void 0) xml += ` Precision="${prop.precision}"`;
|
|
2320
|
+
if (prop.scale !== void 0) xml += ` Scale="${prop.scale}"`;
|
|
2321
|
+
if (prop.maxLength !== void 0) xml += ` MaxLength="${prop.maxLength}"`;
|
|
2322
|
+
xml += "/>";
|
|
2323
|
+
return xml;
|
|
2324
|
+
}
|
|
2325
|
+
buildNavigationPropertyXml(nav) {
|
|
2326
|
+
if (nav.isCollection) return ` <NavigationProperty Name="${nav.name}" Type="${nav.type}"/>`;
|
|
2327
|
+
return ` <NavigationProperty Name="${nav.name}" Type="${nav.type}" Nullable="${nav.nullable}"/>`;
|
|
2328
|
+
}
|
|
2329
|
+
buildEntitySetXml(entitySet, namespace) {
|
|
2330
|
+
return ` <EntitySet Name="${entitySet.name}" EntityType="${namespace}.${entitySet.entityTypeName}"/>`;
|
|
2331
|
+
}
|
|
2332
|
+
};
|
|
2333
|
+
CsdlBuilder = __decorate([
|
|
2334
|
+
(0, _nestjs_common.Injectable)(),
|
|
2335
|
+
__decorateParam(1, (0, _nestjs_common.Inject)(ODATA_MODULE_OPTIONS)),
|
|
2336
|
+
__decorateMetadata("design:paramtypes", [typeof (_ref$2 = typeof EdmRegistry !== "undefined" && EdmRegistry) === "function" ? _ref$2 : Object, Object])
|
|
2337
|
+
], CsdlBuilder);
|
|
2338
|
+
//#endregion
|
|
2339
|
+
//#region src/metadata/service-document-builder.ts
|
|
2340
|
+
var _ref$1;
|
|
2341
|
+
let ServiceDocumentBuilder = class ServiceDocumentBuilder {
|
|
2342
|
+
constructor(edmRegistry, options) {
|
|
2343
|
+
this.edmRegistry = edmRegistry;
|
|
2344
|
+
this.options = options;
|
|
2345
|
+
}
|
|
2346
|
+
/**
|
|
2347
|
+
* Build the OData service document JSON object.
|
|
2348
|
+
* Returns @odata.context pointing to $metadata and a value array with all EntitySets.
|
|
2349
|
+
*/
|
|
2350
|
+
buildServiceDocument() {
|
|
2351
|
+
const serviceRoot = this.options.serviceRoot;
|
|
2352
|
+
const value = Array.from(this.edmRegistry.getEntitySets().values()).map((es) => ({
|
|
2353
|
+
name: es.name,
|
|
2354
|
+
url: es.name,
|
|
2355
|
+
kind: "EntitySet"
|
|
2356
|
+
}));
|
|
2357
|
+
return {
|
|
2358
|
+
"@odata.context": `${serviceRoot}/$metadata`,
|
|
2359
|
+
value
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
};
|
|
2363
|
+
ServiceDocumentBuilder = __decorate([
|
|
2364
|
+
(0, _nestjs_common.Injectable)(),
|
|
2365
|
+
__decorateParam(1, (0, _nestjs_common.Inject)(ODATA_MODULE_OPTIONS)),
|
|
2366
|
+
__decorateMetadata("design:paramtypes", [typeof (_ref$1 = typeof EdmRegistry !== "undefined" && EdmRegistry) === "function" ? _ref$1 : Object, Object])
|
|
2367
|
+
], ServiceDocumentBuilder);
|
|
2368
|
+
//#endregion
|
|
2369
|
+
//#region src/metadata/metadata.controller.ts
|
|
2370
|
+
var _ref, _ref2;
|
|
2371
|
+
let MetadataController = class MetadataController {
|
|
2372
|
+
constructor(csdlBuilder, serviceDocumentBuilder, options) {
|
|
2373
|
+
this.csdlBuilder = csdlBuilder;
|
|
2374
|
+
this.serviceDocumentBuilder = serviceDocumentBuilder;
|
|
2375
|
+
this.options = options;
|
|
2376
|
+
}
|
|
2377
|
+
/**
|
|
2378
|
+
* GET {serviceRoot}/$metadata
|
|
2379
|
+
* Returns OData v4 CSDL XML document.
|
|
2380
|
+
* Content-Type: application/xml per OData spec.
|
|
2381
|
+
*/
|
|
2382
|
+
getMetadata() {
|
|
2383
|
+
return this.csdlBuilder.buildCsdlXml();
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* GET {serviceRoot}/ (service document)
|
|
2387
|
+
* Returns OData service document listing all available EntitySets.
|
|
2388
|
+
* Content-Type: application/json per OData spec.
|
|
2389
|
+
*/
|
|
2390
|
+
getServiceDocument() {
|
|
2391
|
+
return this.serviceDocumentBuilder.buildServiceDocument();
|
|
2392
|
+
}
|
|
2393
|
+
};
|
|
2394
|
+
__decorate([
|
|
2395
|
+
(0, _nestjs_common.Get)("$metadata"),
|
|
2396
|
+
(0, _nestjs_common.Header)("Content-Type", "application/xml"),
|
|
2397
|
+
__decorateMetadata("design:type", Function),
|
|
2398
|
+
__decorateMetadata("design:paramtypes", []),
|
|
2399
|
+
__decorateMetadata("design:returntype", String)
|
|
2400
|
+
], MetadataController.prototype, "getMetadata", null);
|
|
2401
|
+
__decorate([
|
|
2402
|
+
(0, _nestjs_common.Get)(""),
|
|
2403
|
+
(0, _nestjs_common.Header)("Content-Type", "application/json"),
|
|
2404
|
+
__decorateMetadata("design:type", Function),
|
|
2405
|
+
__decorateMetadata("design:paramtypes", []),
|
|
2406
|
+
__decorateMetadata("design:returntype", Object)
|
|
2407
|
+
], MetadataController.prototype, "getServiceDocument", null);
|
|
2408
|
+
MetadataController = __decorate([
|
|
2409
|
+
(0, _nestjs_common.Controller)(),
|
|
2410
|
+
__decorateParam(2, (0, _nestjs_common.Inject)(ODATA_MODULE_OPTIONS)),
|
|
2411
|
+
__decorateMetadata("design:paramtypes", [
|
|
2412
|
+
typeof (_ref = typeof CsdlBuilder !== "undefined" && CsdlBuilder) === "function" ? _ref : Object,
|
|
2413
|
+
typeof (_ref2 = typeof ServiceDocumentBuilder !== "undefined" && ServiceDocumentBuilder) === "function" ? _ref2 : Object,
|
|
2414
|
+
Object
|
|
2415
|
+
])
|
|
2416
|
+
], MetadataController);
|
|
2417
|
+
//#endregion
|
|
2418
|
+
//#region src/odata.module.ts
|
|
2419
|
+
var _ODataModule;
|
|
2420
|
+
const DEFAULT_OPTIONS = {
|
|
2421
|
+
namespace: "Default",
|
|
2422
|
+
maxTop: 1e3,
|
|
2423
|
+
maxExpandDepth: 2,
|
|
2424
|
+
maxFilterDepth: 10,
|
|
2425
|
+
unmappedTypeStrategy: "skip",
|
|
2426
|
+
maxDeepInsertDepth: 5
|
|
2427
|
+
};
|
|
2428
|
+
const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new _nestjs_common.ConfigurableModuleBuilder().setClassMethodName("forRoot").setFactoryMethodName("create").build();
|
|
2429
|
+
/** Provider that applies defaults to the raw options and exposes them under ODATA_MODULE_OPTIONS */
|
|
2430
|
+
const resolvedOptionsProvider = {
|
|
2431
|
+
provide: ODATA_MODULE_OPTIONS,
|
|
2432
|
+
useFactory: (raw) => ({
|
|
2433
|
+
...DEFAULT_OPTIONS,
|
|
2434
|
+
...raw
|
|
2435
|
+
}),
|
|
2436
|
+
inject: [MODULE_OPTIONS_TOKEN]
|
|
2437
|
+
};
|
|
2438
|
+
/**
|
|
2439
|
+
* Patch the MetadataController's PATH_METADATA to use the given serviceRoot.
|
|
2440
|
+
* This allows the controller to serve routes under the correct path without
|
|
2441
|
+
* hardcoding the serviceRoot.
|
|
2442
|
+
*
|
|
2443
|
+
* Uses Reflect.defineMetadata to set the NestJS controller path at module
|
|
2444
|
+
* registration time (before the DI container compiles the module).
|
|
2445
|
+
*/
|
|
2446
|
+
function createMetadataControllerWithPath(serviceRoot) {
|
|
2447
|
+
const path = serviceRoot.startsWith("/") ? serviceRoot.slice(1) : serviceRoot;
|
|
2448
|
+
Reflect.defineMetadata(_nestjs_common_constants_js.PATH_METADATA, path, MetadataController);
|
|
2449
|
+
return MetadataController;
|
|
2450
|
+
}
|
|
2451
|
+
let ODataModule = class ODataModule extends ConfigurableModuleClass {
|
|
2452
|
+
static {
|
|
2453
|
+
_ODataModule = this;
|
|
2454
|
+
}
|
|
2455
|
+
/** Static storage for serviceRoot, set by forRoot() and read by forFeature() adapters. */
|
|
2456
|
+
static _serviceRoot = "";
|
|
2457
|
+
/** Returns the serviceRoot registered via forRoot(). Used by adapter modules (e.g. ODataTypeOrmModule). */
|
|
2458
|
+
static get registeredServiceRoot() {
|
|
2459
|
+
return _ODataModule._serviceRoot;
|
|
2460
|
+
}
|
|
2461
|
+
/** Override forRoot to inject the resolved-options provider into the dynamic module. */
|
|
2462
|
+
static forRoot(options) {
|
|
2463
|
+
if (!options.serviceRoot || options.serviceRoot.trim() === "") throw new Error("ODataModule.forRoot(): serviceRoot must be a non-empty string");
|
|
2464
|
+
_ODataModule._serviceRoot = options.serviceRoot;
|
|
2465
|
+
const parent = super.forRoot(options);
|
|
2466
|
+
const metadataController = createMetadataControllerWithPath(options.serviceRoot);
|
|
2467
|
+
const odataControllers = options.controllers ?? [];
|
|
2468
|
+
if (odataControllers.length > 0) {
|
|
2469
|
+
const root = options.serviceRoot.startsWith("/") ? options.serviceRoot.slice(1) : options.serviceRoot;
|
|
2470
|
+
for (const ctrl of odataControllers) {
|
|
2471
|
+
const entitySetName = Reflect.getMetadata(ODATA_CONTROLLER_KEY, ctrl);
|
|
2472
|
+
if (entitySetName) {
|
|
2473
|
+
const fullPath = root ? `${root}/${entitySetName}` : entitySetName;
|
|
2474
|
+
Reflect.defineMetadata(_nestjs_common_constants_js.PATH_METADATA, fullPath, ctrl);
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
return {
|
|
2479
|
+
...parent,
|
|
2480
|
+
providers: [
|
|
2481
|
+
...parent.providers ?? [],
|
|
2482
|
+
resolvedOptionsProvider,
|
|
2483
|
+
CsdlBuilder,
|
|
2484
|
+
ServiceDocumentBuilder
|
|
2485
|
+
],
|
|
2486
|
+
controllers: [...parent.controllers ?? [], metadataController],
|
|
2487
|
+
exports: [
|
|
2488
|
+
...parent.exports ?? [],
|
|
2489
|
+
ODATA_MODULE_OPTIONS,
|
|
2490
|
+
CsdlBuilder
|
|
2491
|
+
]
|
|
2492
|
+
};
|
|
2493
|
+
}
|
|
2494
|
+
/** Override forRootAsync to inject the resolved-options provider into the dynamic module. */
|
|
2495
|
+
static forRootAsync(options) {
|
|
2496
|
+
const parent = super.forRootAsync(options);
|
|
2497
|
+
const controller = MetadataController;
|
|
2498
|
+
return {
|
|
2499
|
+
...parent,
|
|
2500
|
+
providers: [
|
|
2501
|
+
...parent.providers ?? [],
|
|
2502
|
+
resolvedOptionsProvider,
|
|
2503
|
+
CsdlBuilder,
|
|
2504
|
+
ServiceDocumentBuilder
|
|
2505
|
+
],
|
|
2506
|
+
controllers: [...parent.controllers ?? [], controller],
|
|
2507
|
+
exports: [
|
|
2508
|
+
...parent.exports ?? [],
|
|
2509
|
+
ODATA_MODULE_OPTIONS,
|
|
2510
|
+
CsdlBuilder
|
|
2511
|
+
]
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
/**
|
|
2515
|
+
* Register EDM entity configurations from a feature module.
|
|
2516
|
+
* Entities provided here are available via the EDM_ENTITY_CONFIGS injection token.
|
|
2517
|
+
*/
|
|
2518
|
+
static forFeature(entityConfigs) {
|
|
2519
|
+
return {
|
|
2520
|
+
module: _ODataModule,
|
|
2521
|
+
providers: [{
|
|
2522
|
+
provide: EDM_ENTITY_CONFIGS,
|
|
2523
|
+
useValue: entityConfigs
|
|
2524
|
+
}, EdmFeatureInitializer],
|
|
2525
|
+
exports: [EDM_ENTITY_CONFIGS]
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
};
|
|
2529
|
+
ODataModule = _ODataModule = __decorate([(0, _nestjs_common.Global)(), (0, _nestjs_common.Module)({
|
|
2530
|
+
providers: [EdmRegistry],
|
|
2531
|
+
exports: [EdmRegistry]
|
|
2532
|
+
})], ODataModule);
|
|
2533
|
+
//#endregion
|
|
2534
|
+
//#region src/utils/odata-key-parser.ts
|
|
2535
|
+
/**
|
|
2536
|
+
* Parse an OData parenthetical key string into a Record of key-value pairs.
|
|
2537
|
+
*
|
|
2538
|
+
* Formats per D-02:
|
|
2539
|
+
* Simple: '42' -> { Id: 42 }
|
|
2540
|
+
* String: "'hello'" -> { Name: 'hello' }
|
|
2541
|
+
* Composite: 'OrderId=1,ItemId=3' -> { OrderId: 1, ItemId: 3 }
|
|
2542
|
+
*
|
|
2543
|
+
* Per T-04-02: Key values are returned as typed JS values (number, string, boolean)
|
|
2544
|
+
* — never interpolated into SQL. Safe for parameterized ORM queries.
|
|
2545
|
+
*/
|
|
2546
|
+
/**
|
|
2547
|
+
* Coerce an OData key value string to the appropriate JS type.
|
|
2548
|
+
* - Strips surrounding single quotes (OData string literal)
|
|
2549
|
+
* - Parses 'true'/'false' as boolean
|
|
2550
|
+
* - Parses numeric strings as numbers
|
|
2551
|
+
* - Otherwise returns as string
|
|
2552
|
+
*/
|
|
2553
|
+
function coerceKeyValue(val) {
|
|
2554
|
+
const trimmed = val.trim();
|
|
2555
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1);
|
|
2556
|
+
if (trimmed === "true") return true;
|
|
2557
|
+
if (trimmed === "false") return false;
|
|
2558
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
2559
|
+
return trimmed;
|
|
2560
|
+
}
|
|
2561
|
+
/**
|
|
2562
|
+
* Parse an OData parenthetical key string into a Record of key-value pairs.
|
|
2563
|
+
*
|
|
2564
|
+
* @param keyStr - The key string extracted from parentheses (e.g., '42', "'hello'", 'OrderId=1,ItemId=3')
|
|
2565
|
+
* @param keyProperties - Ordered list of key property names (used for simple keys)
|
|
2566
|
+
* @returns Record mapping property names to coerced values
|
|
2567
|
+
* @throws Error if keyStr is empty
|
|
2568
|
+
*/
|
|
2569
|
+
function parseODataKey(keyStr, keyProperties) {
|
|
2570
|
+
if (!keyStr) throw new Error("OData key cannot be empty");
|
|
2571
|
+
if (keyStr.includes("=")) {
|
|
2572
|
+
const pairs = keyStr.split(",");
|
|
2573
|
+
const result = {};
|
|
2574
|
+
for (const pair of pairs) {
|
|
2575
|
+
const eqIdx = pair.indexOf("=");
|
|
2576
|
+
const name = pair.slice(0, eqIdx).trim();
|
|
2577
|
+
result[name] = coerceKeyValue(pair.slice(eqIdx + 1).trim());
|
|
2578
|
+
}
|
|
2579
|
+
return result;
|
|
2580
|
+
}
|
|
2581
|
+
return { [keyProperties[0]]: coerceKeyValue(keyStr) };
|
|
2582
|
+
}
|
|
2583
|
+
//#endregion
|
|
2584
|
+
//#region src/index.ts
|
|
2585
|
+
const VERSION = "0.0.1";
|
|
2586
|
+
//#endregion
|
|
2587
|
+
Object.defineProperty(exports, "CsdlBuilder", {
|
|
2588
|
+
enumerable: true,
|
|
2589
|
+
get: function() {
|
|
2590
|
+
return CsdlBuilder;
|
|
2591
|
+
}
|
|
2592
|
+
});
|
|
2593
|
+
exports.EDM_DERIVER = EDM_DERIVER;
|
|
2594
|
+
exports.EDM_ENTITY_CONFIGS = EDM_ENTITY_CONFIGS;
|
|
2595
|
+
exports.EDM_TYPE_KEY = EDM_TYPE_KEY;
|
|
2596
|
+
exports.ETAG_PROVIDER = ETAG_PROVIDER;
|
|
2597
|
+
Object.defineProperty(exports, "EdmRegistry", {
|
|
2598
|
+
enumerable: true,
|
|
2599
|
+
get: function() {
|
|
2600
|
+
return EdmRegistry;
|
|
2601
|
+
}
|
|
2602
|
+
});
|
|
2603
|
+
exports.EdmType = EdmType;
|
|
2604
|
+
exports.MAX_BATCH_OPERATIONS = MAX_BATCH_OPERATIONS;
|
|
2605
|
+
Object.defineProperty(exports, "MetadataController", {
|
|
2606
|
+
enumerable: true,
|
|
2607
|
+
get: function() {
|
|
2608
|
+
return MetadataController;
|
|
2609
|
+
}
|
|
2610
|
+
});
|
|
2611
|
+
exports.ODATA_CONTROLLER_KEY = ODATA_CONTROLLER_KEY;
|
|
2612
|
+
exports.ODATA_ENTITY_SET_KEY = ODATA_ENTITY_SET_KEY;
|
|
2613
|
+
exports.ODATA_ETAG_KEY = ODATA_ETAG_KEY;
|
|
2614
|
+
exports.ODATA_EXCLUDE_KEY = ODATA_EXCLUDE_KEY;
|
|
2615
|
+
exports.ODATA_KEY_KEY = ODATA_KEY_KEY;
|
|
2616
|
+
exports.ODATA_MODULE_OPTIONS = ODATA_MODULE_OPTIONS;
|
|
2617
|
+
exports.ODATA_ROUTE_KEY = ODATA_ROUTE_KEY;
|
|
2618
|
+
exports.ODATA_SEARCHABLE_KEY = ODATA_SEARCHABLE_KEY;
|
|
2619
|
+
exports.ODATA_VIEW_KEY = ODATA_VIEW_KEY;
|
|
2620
|
+
exports.ODataController = ODataController;
|
|
2621
|
+
exports.ODataDelete = ODataDelete;
|
|
2622
|
+
exports.ODataETag = ODataETag;
|
|
2623
|
+
exports.ODataEntitySet = ODataEntitySet;
|
|
2624
|
+
Object.defineProperty(exports, "ODataExceptionFilter", {
|
|
2625
|
+
enumerable: true,
|
|
2626
|
+
get: function() {
|
|
2627
|
+
return ODataExceptionFilter;
|
|
2628
|
+
}
|
|
2629
|
+
});
|
|
2630
|
+
exports.ODataExclude = ODataExclude;
|
|
2631
|
+
exports.ODataGet = ODataGet;
|
|
2632
|
+
exports.ODataGetByKey = ODataGetByKey;
|
|
2633
|
+
exports.ODataKey = ODataKey;
|
|
2634
|
+
Object.defineProperty(exports, "ODataModule", {
|
|
2635
|
+
enumerable: true,
|
|
2636
|
+
get: function() {
|
|
2637
|
+
return ODataModule;
|
|
2638
|
+
}
|
|
2639
|
+
});
|
|
2640
|
+
exports.ODataParseError = ODataParseError;
|
|
2641
|
+
exports.ODataPatch = ODataPatch;
|
|
2642
|
+
exports.ODataPost = ODataPost;
|
|
2643
|
+
exports.ODataPut = ODataPut;
|
|
2644
|
+
exports.ODataQueryParam = ODataQueryParam;
|
|
2645
|
+
Object.defineProperty(exports, "ODataQueryPipe", {
|
|
2646
|
+
enumerable: true,
|
|
2647
|
+
get: function() {
|
|
2648
|
+
return ODataQueryPipe;
|
|
2649
|
+
}
|
|
2650
|
+
});
|
|
2651
|
+
Object.defineProperty(exports, "ODataResponseInterceptor", {
|
|
2652
|
+
enumerable: true,
|
|
2653
|
+
get: function() {
|
|
2654
|
+
return ODataResponseInterceptor;
|
|
2655
|
+
}
|
|
2656
|
+
});
|
|
2657
|
+
exports.ODataSearchable = ODataSearchable;
|
|
2658
|
+
exports.ODataValidationError = ODataValidationError;
|
|
2659
|
+
exports.ODataView = ODataView;
|
|
2660
|
+
exports.QUERY_TRANSLATOR = QUERY_TRANSLATOR;
|
|
2661
|
+
exports.SEARCH_PROVIDER = SEARCH_PROVIDER;
|
|
2662
|
+
Object.defineProperty(exports, "ServiceDocumentBuilder", {
|
|
2663
|
+
enumerable: true,
|
|
2664
|
+
get: function() {
|
|
2665
|
+
return ServiceDocumentBuilder;
|
|
2666
|
+
}
|
|
2667
|
+
});
|
|
2668
|
+
exports.VERSION = VERSION;
|
|
2669
|
+
exports.acceptVisitor = acceptVisitor;
|
|
2670
|
+
exports.annotateEntities = annotateEntities;
|
|
2671
|
+
exports.annotateEntity = annotateEntity;
|
|
2672
|
+
exports.buildContextUrl = buildContextUrl;
|
|
2673
|
+
exports.extractBoundary = extractBoundary;
|
|
2674
|
+
exports.getETagProperty = getETagProperty;
|
|
2675
|
+
exports.getEdmTypeOverrides = getEdmTypeOverrides;
|
|
2676
|
+
exports.getEntitySetName = getEntitySetName;
|
|
2677
|
+
exports.getExcludedProperties = getExcludedProperties;
|
|
2678
|
+
exports.getKeyProperties = getKeyProperties;
|
|
2679
|
+
exports.getODataViewOptions = getODataViewOptions;
|
|
2680
|
+
exports.getSearchableProperties = getSearchableProperties;
|
|
2681
|
+
exports.parseApply = parseApply;
|
|
2682
|
+
exports.parseBatchBody = parseBatchBody;
|
|
2683
|
+
exports.parseFilter = parseFilter;
|
|
2684
|
+
exports.parseODataKey = parseODataKey;
|
|
2685
|
+
exports.parseQuery = parseQuery;
|
|
2686
|
+
exports.parseSearch = parseSearch;
|
|
2687
|
+
exports.pluralizeEntityName = pluralizeEntityName;
|
|
2688
|
+
exports.tokenize = tokenize;
|