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