@nestjs-odata/typeorm 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.cjs ADDED
@@ -0,0 +1,2258 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _nestjs_common = require("@nestjs/common");
3
+ let typeorm = require("typeorm");
4
+ let _nestjs_odata_core = require("@nestjs-odata/core");
5
+ let crypto = require("crypto");
6
+ let _nestjs_common_constants_js = require("@nestjs/common/constants.js");
7
+ let _nestjs_typeorm = require("@nestjs/typeorm");
8
+ //#region src/batch/batch-response-builder.ts
9
+ /**
10
+ * OData v4 $batch response builder.
11
+ *
12
+ * Builds a multipart/mixed HTTP response body from per-operation results.
13
+ * Each operation gets its own HTTP status code and body (D-03).
14
+ *
15
+ * Per T-05-05: failed operation responses use OData error format,
16
+ * no stack traces or internal details.
17
+ *
18
+ * Wire format per OData v4 Part 1 section 11:
19
+ * --{boundary}\r\n
20
+ * Content-Type: application/http\r\n
21
+ * Content-Transfer-Encoding: binary\r\n
22
+ * \r\n
23
+ * HTTP/1.1 {status} {statusText}\r\n
24
+ * Content-Type: application/json\r\n
25
+ * \r\n
26
+ * {body}\r\n
27
+ * --{boundary}--\r\n
28
+ */
29
+ /**
30
+ * Map of HTTP status codes to reason phrases.
31
+ */
32
+ const STATUS_TEXTS = {
33
+ 200: "OK",
34
+ 201: "Created",
35
+ 204: "No Content",
36
+ 400: "Bad Request",
37
+ 401: "Unauthorized",
38
+ 403: "Forbidden",
39
+ 404: "Not Found",
40
+ 405: "Method Not Allowed",
41
+ 409: "Conflict",
42
+ 422: "Unprocessable Entity",
43
+ 429: "Too Many Requests",
44
+ 500: "Internal Server Error",
45
+ 501: "Not Implemented",
46
+ 503: "Service Unavailable"
47
+ };
48
+ /**
49
+ * Build a multipart/mixed batch response from individual operation results.
50
+ *
51
+ * @param parts - Array of per-operation response descriptors
52
+ * @returns Object with `contentType` (for Content-Type header) and `body` (response body string)
53
+ */
54
+ function buildBatchResponse(parts) {
55
+ const boundary = `batch_${(0, crypto.randomUUID)().replace(/-/g, "").slice(0, 16)}`;
56
+ const lines = [];
57
+ for (const part of parts) {
58
+ const statusText = STATUS_TEXTS[part.statusCode] ?? "Unknown";
59
+ lines.push(`--${boundary}`);
60
+ lines.push("Content-Type: application/http");
61
+ lines.push("Content-Transfer-Encoding: binary");
62
+ if (part.contentId !== void 0) lines.push(`Content-ID: ${part.contentId}`);
63
+ lines.push("");
64
+ lines.push(`HTTP/1.1 ${part.statusCode} ${statusText}`);
65
+ if (part.body !== void 0) {
66
+ lines.push("Content-Type: application/json");
67
+ lines.push(`Content-Length: ${Buffer.byteLength(part.body, "utf-8")}`);
68
+ }
69
+ lines.push("");
70
+ if (part.body !== void 0) lines.push(part.body);
71
+ }
72
+ lines.push(`--${boundary}--`);
73
+ lines.push("");
74
+ const body = lines.join("\r\n");
75
+ return {
76
+ contentType: `multipart/mixed; boundary=${boundary}`,
77
+ body
78
+ };
79
+ }
80
+ //#endregion
81
+ //#region src/deriver/typeorm-type-mapper.ts
82
+ /**
83
+ * Complete mapping from TypeORM column types to OData v4 EDM primitive types.
84
+ * Note: Edm.DateTime does NOT exist in OData v4 — use Edm.DateTimeOffset.
85
+ * Source: TypeORM ColumnTypes + OData v4 OASIS spec.
86
+ */
87
+ const COLUMN_TYPE_MAP = {
88
+ int: "Edm.Int32",
89
+ int2: "Edm.Int32",
90
+ int4: "Edm.Int32",
91
+ integer: "Edm.Int32",
92
+ tinyint: "Edm.Int32",
93
+ smallint: "Edm.Int32",
94
+ mediumint: "Edm.Int32",
95
+ bigint: "Edm.Int64",
96
+ int8: "Edm.Int64",
97
+ int64: "Edm.Int64",
98
+ "unsigned big int": "Edm.Int64",
99
+ float: "Edm.Double",
100
+ float4: "Edm.Double",
101
+ float8: "Edm.Double",
102
+ float64: "Edm.Double",
103
+ double: "Edm.Double",
104
+ real: "Edm.Double",
105
+ "double precision": "Edm.Double",
106
+ decimal: "Edm.Decimal",
107
+ numeric: "Edm.Decimal",
108
+ money: "Edm.Decimal",
109
+ smallmoney: "Edm.Decimal",
110
+ varchar: "Edm.String",
111
+ nvarchar: "Edm.String",
112
+ char: "Edm.String",
113
+ nchar: "Edm.String",
114
+ text: "Edm.String",
115
+ ntext: "Edm.String",
116
+ tinytext: "Edm.String",
117
+ mediumtext: "Edm.String",
118
+ longtext: "Edm.String",
119
+ clob: "Edm.String",
120
+ nclob: "Edm.String",
121
+ citext: "Edm.String",
122
+ shorttext: "Edm.String",
123
+ alphanum: "Edm.String",
124
+ long: "Edm.String",
125
+ boolean: "Edm.Boolean",
126
+ bool: "Edm.Boolean",
127
+ date: "Edm.Date",
128
+ datetime: "Edm.DateTimeOffset",
129
+ datetime2: "Edm.DateTimeOffset",
130
+ datetimeoffset: "Edm.DateTimeOffset",
131
+ timestamp: "Edm.DateTimeOffset",
132
+ timestamptz: "Edm.DateTimeOffset",
133
+ "timestamp with local time zone": "Edm.DateTimeOffset",
134
+ smalldatetime: "Edm.DateTimeOffset",
135
+ time: "Edm.TimeOfDay",
136
+ timetz: "Edm.TimeOfDay",
137
+ uuid: "Edm.Guid",
138
+ uniqueidentifier: "Edm.Guid",
139
+ blob: "Edm.Binary",
140
+ tinyblob: "Edm.Binary",
141
+ mediumblob: "Edm.Binary",
142
+ longblob: "Edm.Binary",
143
+ bytea: "Edm.Binary",
144
+ bytes: "Edm.Binary",
145
+ binary: "Edm.Binary",
146
+ varbinary: "Edm.Binary",
147
+ image: "Edm.Binary",
148
+ bfile: "Edm.Binary",
149
+ raw: "Edm.Binary",
150
+ "long raw": "Edm.Binary"
151
+ };
152
+ /**
153
+ * Unmapped column types — require strategy handling rather than direct mapping.
154
+ * These types cannot be automatically represented as an OData v4 primitive type.
155
+ */
156
+ const UNMAPPED_COLUMN_TYPES = new Set([
157
+ "json",
158
+ "jsonb",
159
+ "simple-json",
160
+ "simple-array",
161
+ "simple-enum",
162
+ "enum",
163
+ "set"
164
+ ]);
165
+ /**
166
+ * Map a TypeORM column type to an OData v4 EDM primitive type.
167
+ *
168
+ * Resolution order:
169
+ * 1. Direct lookup in the column type map
170
+ * 2. Unmapped type — apply unmappedTypeStrategy
171
+ * 3. JS design type fallback (Number, String, Boolean, Date)
172
+ * 4. Unknown type — apply unmappedTypeStrategy
173
+ *
174
+ * CRITICAL: Edm.DateTime does NOT exist in OData v4. All datetime-like
175
+ * types produce Edm.DateTimeOffset per the OASIS OData v4 specification.
176
+ *
177
+ * @param columnType - TypeORM column type string (e.g., 'varchar', 'int', 'datetime')
178
+ * @param designType - JavaScript design-time type constructor (Number, String, Boolean, Date)
179
+ * @param unmappedTypeStrategy - how to handle types not in the mapping table
180
+ */
181
+ function mapColumnTypeToEdm(columnType, designType, unmappedTypeStrategy) {
182
+ const normalized = columnType.toLowerCase();
183
+ const mapped = COLUMN_TYPE_MAP[normalized];
184
+ if (mapped !== void 0) return mapped;
185
+ if (UNMAPPED_COLUMN_TYPES.has(normalized)) return applyUnmappedStrategy(columnType, unmappedTypeStrategy);
186
+ if (designType === Number) return "Edm.Int32";
187
+ if (designType === String) return "Edm.String";
188
+ if (designType === Boolean) return "Edm.Boolean";
189
+ if (designType === Date) return "Edm.DateTimeOffset";
190
+ return applyUnmappedStrategy(columnType, unmappedTypeStrategy);
191
+ }
192
+ function applyUnmappedStrategy(columnType, strategy) {
193
+ if (strategy === "skip") return;
194
+ if (strategy === "string-fallback") return "Edm.String";
195
+ throw new Error(`Column type '${columnType}' cannot be mapped to an OData v4 EDM primitive type. Use @EdmType() decorator to specify the type explicitly, or change the unmappedTypeStrategy.`);
196
+ }
197
+ //#endregion
198
+ //#region src/deriver/typeorm-edm-deriver.ts
199
+ /**
200
+ * TypeOrmEdmDeriver — derives OData v4 EdmEntityConfig[] from TypeORM EntityMetadata.
201
+ *
202
+ * Respects OData decorators: @EdmType, @ODataExclude, @ODataEntitySet.
203
+ * Detects ViewEntity and marks them read-only.
204
+ * Navigation property types are always namespace-qualified (e.g., 'Default.Category').
205
+ *
206
+ * CRITICAL: Never produces Edm.DateTime — all datetime-like types produce Edm.DateTimeOffset.
207
+ *
208
+ * Usage (runtime):
209
+ * const deriver = new TypeOrmEdmDeriver('Default', 'skip')
210
+ * // When called by the module, metadatas come from the registered DataSource
211
+ *
212
+ * Usage (testing):
213
+ * deriver.deriveEntityTypes([Product], dataSource.entityMetadatas)
214
+ */
215
+ var TypeOrmEdmDeriver = class {
216
+ constructor(namespace, unmappedTypeStrategy) {
217
+ this.namespace = namespace;
218
+ this.unmappedTypeStrategy = unmappedTypeStrategy;
219
+ }
220
+ deriveEntityTypes(entityClasses, entityMetadatas = []) {
221
+ return entityClasses.map((entityClass) => this.deriveEntity(entityClass, entityMetadatas)).filter((config) => config !== null);
222
+ }
223
+ deriveEntity(entityClass, entityMetadatas) {
224
+ const meta = entityMetadatas.find((m) => m.target === entityClass);
225
+ if (!meta) return null;
226
+ const excludedProps = (0, _nestjs_odata_core.getExcludedProperties)(entityClass);
227
+ const edmTypeOverrides = (0, _nestjs_odata_core.getEdmTypeOverrides)(entityClass);
228
+ const customSetName = (0, _nestjs_odata_core.getEntitySetName)(entityClass);
229
+ const properties = this.deriveProperties(meta, excludedProps, edmTypeOverrides);
230
+ const navigationProperties = this.deriveNavigationProperties(meta);
231
+ const keyProperties = meta.primaryColumns.map((c) => c.propertyName);
232
+ const entitySetName = customSetName ?? (0, _nestjs_odata_core.pluralizeEntityName)(meta.name);
233
+ const isReadOnly = meta.tableType === "view";
234
+ return {
235
+ entityTypeName: meta.name,
236
+ entitySetName,
237
+ properties,
238
+ navigationProperties,
239
+ keyProperties,
240
+ isReadOnly
241
+ };
242
+ }
243
+ deriveProperties(meta, excludedProps, edmTypeOverrides) {
244
+ return meta.columns.filter((col) => !excludedProps.has(col.propertyName)).reduce((acc, col) => {
245
+ const override = edmTypeOverrides[col.propertyName];
246
+ if (override) {
247
+ acc.push({
248
+ name: col.propertyName,
249
+ type: override.type,
250
+ nullable: col.isNullable,
251
+ ...override.precision !== void 0 ? { precision: override.precision } : {},
252
+ ...override.scale !== void 0 ? { scale: override.scale } : {},
253
+ ...override.maxLength !== void 0 ? { maxLength: override.maxLength } : {}
254
+ });
255
+ return acc;
256
+ }
257
+ const isConstructor = typeof col.type === "function";
258
+ const edmType = mapColumnTypeToEdm(typeof col.type === "string" ? col.type : "", isConstructor ? col.type : void 0, this.unmappedTypeStrategy);
259
+ if (edmType === void 0) return acc;
260
+ acc.push({
261
+ name: col.propertyName,
262
+ type: edmType,
263
+ nullable: col.isNullable,
264
+ ...col.precision !== void 0 && col.precision !== null ? { precision: col.precision } : {},
265
+ ...col.scale !== void 0 && col.scale !== null ? { scale: col.scale } : {},
266
+ ...col.length ? { maxLength: typeof col.length === "string" ? parseInt(col.length, 10) : col.length } : {}
267
+ });
268
+ return acc;
269
+ }, []);
270
+ }
271
+ deriveNavigationProperties(meta) {
272
+ return meta.relations.map((rel) => {
273
+ const isCollection = rel.isOneToMany || rel.isManyToMany;
274
+ const targetName = typeof rel.type === "string" ? rel.type : rel.type.name ?? String(rel.type);
275
+ const type = isCollection ? `Collection(${this.namespace}.${targetName})` : `${this.namespace}.${targetName}`;
276
+ return {
277
+ name: rel.propertyName,
278
+ type,
279
+ nullable: !isCollection,
280
+ isCollection
281
+ };
282
+ });
283
+ }
284
+ };
285
+ //#endregion
286
+ //#region src/translator/filter-visitor.ts
287
+ /** Map of OData comparison operators to SQL operators */
288
+ const COMPARISON_OPS = {
289
+ eq: "=",
290
+ ne: "!=",
291
+ lt: "<",
292
+ le: "<=",
293
+ gt: ">",
294
+ ge: ">="
295
+ };
296
+ /** Map of OData scalar function names to SQL function names */
297
+ const SCALAR_FUNCTIONS = {
298
+ length: "LENGTH",
299
+ tolower: "LOWER",
300
+ toupper: "UPPER",
301
+ trim: "TRIM"
302
+ };
303
+ /** Map of OData arithmetic operators to SQL operators */
304
+ const ARITH_OPS = {
305
+ add: "+",
306
+ sub: "-",
307
+ mul: "*",
308
+ div: "/",
309
+ divby: "/",
310
+ mod: "%"
311
+ };
312
+ /** SQLite strftime format strings for date/time functions */
313
+ const DATE_STRFTIME = {
314
+ year: "%Y",
315
+ month: "%m",
316
+ day: "%d",
317
+ hour: "%H",
318
+ minute: "%M",
319
+ second: "%S"
320
+ };
321
+ /** ANSI/Postgres EXTRACT field names for date/time functions */
322
+ const DATE_EXTRACT = {
323
+ year: "YEAR",
324
+ month: "MONTH",
325
+ day: "DAY",
326
+ hour: "HOUR",
327
+ minute: "MINUTE",
328
+ second: "SECOND"
329
+ };
330
+ /**
331
+ * TypeOrmFilterVisitor — translates an OData filter AST into TypeORM
332
+ * SelectQueryBuilder.andWhere() calls with named parameters.
333
+ *
334
+ * All literal values are bound via named parameters (:p1, :p2, ...) — zero
335
+ * string interpolation in WHERE clauses (T-03-04 mitigation).
336
+ *
337
+ * LIKE special characters (%, _) are escaped before use in contains/startswith/endswith
338
+ * to prevent wildcard injection (T-03-05 mitigation).
339
+ *
340
+ * Per SEC-04: Tracks filter AST nesting depth and throws ODataValidationError when
341
+ * depth exceeds maxFilterDepth. Prevents pathological filter expressions.
342
+ */
343
+ var TypeOrmFilterVisitor = class TypeOrmFilterVisitor {
344
+ /** Shared param counter — passed between sibling visitors for unique names */
345
+ paramCount = 0;
346
+ /** Current nesting depth — propagated to sub-visitors for or branches */
347
+ currentDepth = 0;
348
+ /** Accumulated params from resolveExpression (e.g. arithmetic operands) to merge into andWhere */
349
+ pendingParams = {};
350
+ constructor(qb, alias, entityType, maxFilterDepth = 10, dialect = "ansi", repo) {
351
+ this.qb = qb;
352
+ this.alias = alias;
353
+ this.entityType = entityType;
354
+ this.maxFilterDepth = maxFilterDepth;
355
+ this.dialect = dialect;
356
+ this.repo = repo;
357
+ }
358
+ /** Entry point: dispatch the root FilterNode to the appropriate visit method */
359
+ visit(node) {
360
+ (0, _nestjs_odata_core.acceptVisitor)(node, this);
361
+ }
362
+ visitBinaryExpr(node) {
363
+ this.currentDepth++;
364
+ if (this.currentDepth > this.maxFilterDepth) throw new _nestjs_odata_core.ODataValidationError(`$filter nesting depth ${this.currentDepth} exceeds maximum of ${this.maxFilterDepth}`, this.entityType.name, "$filter");
365
+ const { operator, left, right } = node;
366
+ if (operator === "and") {
367
+ (0, _nestjs_odata_core.acceptVisitor)(left, this);
368
+ (0, _nestjs_odata_core.acceptVisitor)(right, this);
369
+ this.currentDepth--;
370
+ return;
371
+ }
372
+ if (operator === "or") {
373
+ const depthAtEntry = this.currentDepth;
374
+ this.qb.andWhere(new typeorm.Brackets((qb) => {
375
+ const leftVisitor = new TypeOrmFilterVisitor(qb, this.alias, this.entityType, this.maxFilterDepth, this.dialect, this.repo);
376
+ leftVisitor.paramCount = this.paramCount;
377
+ leftVisitor.currentDepth = depthAtEntry;
378
+ (0, _nestjs_odata_core.acceptVisitor)(left, leftVisitor);
379
+ this.paramCount = leftVisitor.paramCount;
380
+ const rightVisitor = new TypeOrmFilterVisitor(qb, this.alias, this.entityType, this.maxFilterDepth, this.dialect, this.repo);
381
+ rightVisitor.paramCount = this.paramCount;
382
+ rightVisitor.currentDepth = depthAtEntry;
383
+ (0, _nestjs_odata_core.acceptVisitor)(right, rightVisitor);
384
+ this.paramCount = rightVisitor.paramCount;
385
+ }));
386
+ this.currentDepth--;
387
+ return;
388
+ }
389
+ const sqlOp = COMPARISON_OPS[operator];
390
+ if (sqlOp) {
391
+ this.pendingParams = {};
392
+ const leftExpr = this.resolveExpression(left);
393
+ const paramName = this.nextParam();
394
+ const literalValue = this.extractLiteralValue(right);
395
+ const allParams = {
396
+ ...this.pendingParams,
397
+ [paramName]: literalValue
398
+ };
399
+ this.pendingParams = {};
400
+ this.qb.andWhere(`${leftExpr} ${sqlOp} :${paramName}`, allParams);
401
+ this.currentDepth--;
402
+ return;
403
+ }
404
+ this.currentDepth--;
405
+ }
406
+ visitUnaryExpr(node) {
407
+ if (node.operator === "not") {
408
+ const { expr, params, finalCount } = new InnerFilterExprBuilder(this.alias, this.entityType, this.paramCount, this.dialect).build(node.operand);
409
+ this.paramCount = finalCount;
410
+ this.qb.andWhere(`NOT (${expr})`, params);
411
+ }
412
+ }
413
+ visitFunctionCall(node) {
414
+ const name = node.name.toLowerCase();
415
+ if (name === "contains" || name === "startswith" || name === "endswith") {
416
+ this.applyLikeFunction(name, node);
417
+ return;
418
+ }
419
+ }
420
+ visitLambdaExpr(node) {
421
+ if (!this.repo) return;
422
+ const relation = this.repo.metadata.relations.find((r) => r.propertyName.toLowerCase() === node.collection.toLowerCase());
423
+ if (!relation) return;
424
+ if (relation.relationType === "many-to-many") throw new _nestjs_odata_core.ODataValidationError(`Lambda expressions on ManyToMany relations are not supported`, this.entityType.name, "$filter");
425
+ if (node.operator === "all" && !node.predicate) return;
426
+ this.paramCount++;
427
+ const subAlias = `${node.variable ?? "sub"}_lambda_${this.paramCount}`;
428
+ const targetTable = relation.inverseEntityMetadata.tableName;
429
+ const fkCol = relation.inverseRelation?.joinColumns[0]?.databaseName ?? relation.joinColumns[0]?.databaseName ?? "id";
430
+ const pkCol = this.repo.metadata.primaryColumns[0]?.databaseName ?? "id";
431
+ if (node.operator === "any") this.buildAnyClause(node, subAlias, targetTable, fkCol, pkCol);
432
+ else this.buildAllClause(node, subAlias, targetTable, fkCol, pkCol);
433
+ }
434
+ buildAnyClause(node, subAlias, targetTable, fkCol, pkCol) {
435
+ const correlate = `${subAlias}.${fkCol} = ${this.alias}.${pkCol}`;
436
+ if (!node.predicate) {
437
+ this.qb.andWhere(`EXISTS (SELECT 1 FROM "${targetTable}" ${subAlias} WHERE ${correlate})`);
438
+ return;
439
+ }
440
+ const result = new InnerFilterExprBuilder(subAlias, this.entityType, this.paramCount, this.dialect).build(node.predicate);
441
+ this.paramCount = result.finalCount;
442
+ this.qb.andWhere(`EXISTS (SELECT 1 FROM "${targetTable}" ${subAlias} WHERE ${correlate} AND ${result.expr})`, result.params);
443
+ }
444
+ buildAllClause(node, subAlias, targetTable, fkCol, pkCol) {
445
+ const result = new InnerFilterExprBuilder(subAlias, this.entityType, this.paramCount, this.dialect).build(node.predicate);
446
+ this.paramCount = result.finalCount;
447
+ const correlate = `${subAlias}.${fkCol} = ${this.alias}.${pkCol}`;
448
+ this.qb.andWhere(`NOT EXISTS (SELECT 1 FROM "${targetTable}" ${subAlias} WHERE ${correlate} AND NOT (${result.expr}))`, result.params);
449
+ }
450
+ visitPropertyAccess(_node) {}
451
+ visitLiteral(_node) {}
452
+ nextParam() {
453
+ this.paramCount++;
454
+ return `p${this.paramCount}`;
455
+ }
456
+ /**
457
+ * Resolve a FilterNode to an SQL expression string (left side of comparison).
458
+ * Handles PropertyAccessNode (column ref) and scalar FunctionCallNode (LENGTH, LOWER, etc.).
459
+ */
460
+ resolveExpression(node) {
461
+ if (node.kind === "PropertyAccess") return `${this.alias}.${node.path[0]}`;
462
+ if (node.kind === "BinaryExpr") {
463
+ const sqlArith = ARITH_OPS[node.operator];
464
+ if (sqlArith) return `(${this.resolveExpression(node.left)} ${sqlArith} ${this.resolveArithOperand(node.right)})`;
465
+ }
466
+ if (node.kind === "FunctionCall") {
467
+ const fnName = node.name.toLowerCase();
468
+ const dateResult = this.resolveDateFunction(fnName, node);
469
+ if (dateResult !== null) return dateResult;
470
+ const strResult = this.resolveStringFunction(fnName, node);
471
+ if (strResult !== null) return strResult;
472
+ const sqlFn = SCALAR_FUNCTIONS[fnName];
473
+ if (sqlFn && node.args.length >= 1) return `${sqlFn}(${this.resolveExpression(node.args[0])})`;
474
+ }
475
+ if (node.kind === "Literal") {
476
+ const paramName = this.nextParam();
477
+ this.pendingParams[paramName] = node.value;
478
+ return `:${paramName}`;
479
+ }
480
+ return "";
481
+ }
482
+ /** Resolve date/time function to dialect-correct SQL. Returns null if not a date function. */
483
+ resolveDateFunction(fnName, node) {
484
+ const strftimeFmt = DATE_STRFTIME[fnName];
485
+ const extractField = DATE_EXTRACT[fnName];
486
+ if (!strftimeFmt || !extractField) return null;
487
+ const inner = this.resolveExpression(node.args[0]);
488
+ if (this.dialect === "sqlite") return `CAST(strftime('${strftimeFmt}', ${inner}) AS INTEGER)`;
489
+ return `EXTRACT(${extractField} FROM ${inner})`;
490
+ }
491
+ /** Bind an arithmetic operand: Literal nodes become named params; others recurse. */
492
+ resolveArithOperand(node) {
493
+ if (node.kind === "Literal") {
494
+ const paramName = this.nextParam();
495
+ this.pendingParams[paramName] = node.value;
496
+ return `:${paramName}`;
497
+ }
498
+ return this.resolveExpression(node);
499
+ }
500
+ /** Resolve indexof/substring/concat to parameterized SQL. Returns null if not a string fn. */
501
+ resolveStringFunction(fnName, node) {
502
+ if (fnName === "indexof" && node.args.length >= 2) {
503
+ const str = this.resolveExpression(node.args[0]);
504
+ const subParam = this.nextParam();
505
+ this.pendingParams[subParam] = node.args[1].value;
506
+ return `${this.dialect === "sqlite" ? "INSTR" : "STRPOS"}(${str}, :${subParam}) - 1`;
507
+ }
508
+ if (fnName === "substring" && node.args.length >= 2) {
509
+ const str = this.resolveExpression(node.args[0]);
510
+ const startParam = this.nextParam();
511
+ this.pendingParams[startParam] = node.args[1].value + 1;
512
+ if (node.args.length >= 3) {
513
+ const lenParam = this.nextParam();
514
+ this.pendingParams[lenParam] = node.args[2].value;
515
+ return `SUBSTR(${str}, :${startParam}, :${lenParam})`;
516
+ }
517
+ return `SUBSTR(${str}, :${startParam})`;
518
+ }
519
+ if (fnName === "concat" && node.args.length >= 2) return `${this.resolveExpression(node.args[0])} || ${this.resolveExpression(node.args[1])}`;
520
+ return null;
521
+ }
522
+ /** Extract the raw JS value from a LiteralNode */
523
+ extractLiteralValue(node) {
524
+ if (node.kind === "Literal") return node.value;
525
+ return null;
526
+ }
527
+ /** Apply a LIKE function (contains, startswith, endswith) with escaped value */
528
+ applyLikeFunction(name, node) {
529
+ const prop = this.resolveExpression(node.args[0]);
530
+ const rawValue = String(this.extractLiteralValue(node.args[1]) ?? "");
531
+ const escaped = this.escapeLike(rawValue);
532
+ let pattern;
533
+ if (name === "contains") pattern = `%${escaped}%`;
534
+ else if (name === "startswith") pattern = `${escaped}%`;
535
+ else pattern = `%${escaped}`;
536
+ const paramName = this.nextParam();
537
+ this.qb.andWhere(`${prop} LIKE :${paramName}`, { [paramName]: pattern });
538
+ }
539
+ /**
540
+ * Escape LIKE special characters to prevent wildcard injection (T-03-05).
541
+ * % -> \%, _ -> \_
542
+ */
543
+ escapeLike(value) {
544
+ return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
545
+ }
546
+ };
547
+ /**
548
+ * Helper class that builds an SQL expression string from a FilterNode subtree
549
+ * without directly calling qb.andWhere(). Used for NOT(...) wrapping.
550
+ */
551
+ var InnerFilterExprBuilder = class {
552
+ paramCount;
553
+ params = {};
554
+ constructor(alias, entityType, startCount, dialect = "ansi") {
555
+ this.alias = alias;
556
+ this.entityType = entityType;
557
+ this.dialect = dialect;
558
+ this.paramCount = startCount;
559
+ }
560
+ build(node) {
561
+ return {
562
+ expr: this.buildExpr(node),
563
+ params: this.params,
564
+ finalCount: this.paramCount
565
+ };
566
+ }
567
+ buildExpr(node) {
568
+ if (node.kind === "BinaryExpr") {
569
+ const sqlArith = ARITH_OPS[node.operator];
570
+ if (sqlArith) return `(${this.buildExpr(node.left)} ${sqlArith} ${this.resolveArithOperand(node.right)})`;
571
+ const sqlOp = COMPARISON_OPS[node.operator];
572
+ if (sqlOp) return `${this.buildExpr(node.left)} ${sqlOp} ${this.buildExpr(node.right)}`;
573
+ }
574
+ if (node.kind === "FunctionCall") {
575
+ const name = node.name.toLowerCase();
576
+ if (name === "contains" || name === "startswith" || name === "endswith") {
577
+ const prop = this.buildExpr(node.args[0]);
578
+ const escaped = String(node.args[1]?.kind === "Literal" ? node.args[1].value : "").replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
579
+ let pattern;
580
+ if (name === "contains") pattern = `%${escaped}%`;
581
+ else if (name === "startswith") pattern = `${escaped}%`;
582
+ else pattern = `%${escaped}`;
583
+ this.paramCount++;
584
+ const paramName = `p${this.paramCount}`;
585
+ this.params[paramName] = pattern;
586
+ return `${prop} LIKE :${paramName}`;
587
+ }
588
+ const dateResult = this.resolveDateFunction(name, node);
589
+ if (dateResult !== null) return dateResult;
590
+ const strResult = this.resolveStringFunction(name, node);
591
+ if (strResult !== null) return strResult;
592
+ const sqlFn = SCALAR_FUNCTIONS[name];
593
+ if (sqlFn && node.args.length >= 1) return `${sqlFn}(${this.buildExpr(node.args[0])})`;
594
+ }
595
+ if (node.kind === "PropertyAccess") return `${this.alias}.${node.path[0]}`;
596
+ if (node.kind === "Literal") {
597
+ this.paramCount++;
598
+ const paramName = `p${this.paramCount}`;
599
+ this.params[paramName] = node.value;
600
+ return `:${paramName}`;
601
+ }
602
+ return "";
603
+ }
604
+ /** Resolve date/time function to dialect-correct SQL. Returns null if not a date function. */
605
+ resolveDateFunction(fnName, node) {
606
+ const strftimeFmt = DATE_STRFTIME[fnName];
607
+ const extractField = DATE_EXTRACT[fnName];
608
+ if (!strftimeFmt || !extractField) return null;
609
+ const inner = this.buildExpr(node.args[0]);
610
+ if (this.dialect === "sqlite") return `CAST(strftime('${strftimeFmt}', ${inner}) AS INTEGER)`;
611
+ return `EXTRACT(${extractField} FROM ${inner})`;
612
+ }
613
+ /** Resolve indexof/substring/concat to parameterized SQL. Returns null if not a string fn. */
614
+ resolveStringFunction(fnName, node) {
615
+ if (fnName === "indexof" && node.args.length >= 2) {
616
+ const str = this.buildExpr(node.args[0]);
617
+ this.paramCount++;
618
+ const subParam = `p${this.paramCount}`;
619
+ this.params[subParam] = node.args[1].value;
620
+ return `${this.dialect === "sqlite" ? "INSTR" : "STRPOS"}(${str}, :${subParam}) - 1`;
621
+ }
622
+ if (fnName === "substring" && node.args.length >= 2) {
623
+ const str = this.buildExpr(node.args[0]);
624
+ this.paramCount++;
625
+ const startParam = `p${this.paramCount}`;
626
+ this.params[startParam] = node.args[1].value + 1;
627
+ if (node.args.length >= 3) {
628
+ this.paramCount++;
629
+ const lenParam = `p${this.paramCount}`;
630
+ this.params[lenParam] = node.args[2].value;
631
+ return `SUBSTR(${str}, :${startParam}, :${lenParam})`;
632
+ }
633
+ return `SUBSTR(${str}, :${startParam})`;
634
+ }
635
+ if (fnName === "concat" && node.args.length >= 2) return `${this.buildExpr(node.args[0])} || ${this.buildExpr(node.args[1])}`;
636
+ return null;
637
+ }
638
+ /** Bind an arithmetic operand: Literal nodes become named params; others recurse via buildExpr. */
639
+ resolveArithOperand(node) {
640
+ if (node.kind === "Literal") {
641
+ this.paramCount++;
642
+ const paramName = `p${this.paramCount}`;
643
+ this.params[paramName] = node.value;
644
+ return `:${paramName}`;
645
+ }
646
+ return this.buildExpr(node);
647
+ }
648
+ };
649
+ //#endregion
650
+ //#region src/translator/select-visitor.ts
651
+ /**
652
+ * TypeOrmSelectVisitor — translates an OData $select node into a TypeORM
653
+ * SelectQueryBuilder.select() call.
654
+ *
655
+ * Key properties (primary keys) are always included in the projection even if
656
+ * not in the $select list (T-03-06 mitigation — avoids identity column gaps).
657
+ */
658
+ var TypeOrmSelectVisitor = class {
659
+ constructor(qb, alias, entityType) {
660
+ this.qb = qb;
661
+ this.alias = alias;
662
+ this.entityType = entityType;
663
+ }
664
+ /**
665
+ * Apply the $select projection to the QueryBuilder.
666
+ *
667
+ * - If select.all is true or select.items is absent/empty, do nothing (return all columns).
668
+ * - Otherwise, build a deduplicated column list and call qb.select().
669
+ */
670
+ apply(select) {
671
+ if (select.all || !select.items?.length) return;
672
+ const columns = select.items.map((item) => `${this.alias}.${item.path[0]}`);
673
+ for (const key of this.entityType.keyProperties) columns.push(`${this.alias}.${key}`);
674
+ const seen = /* @__PURE__ */ new Set();
675
+ const deduplicated = columns.filter((col) => {
676
+ if (seen.has(col)) return false;
677
+ seen.add(col);
678
+ return true;
679
+ });
680
+ this.qb.select(deduplicated);
681
+ }
682
+ };
683
+ //#endregion
684
+ //#region src/translator/orderby-visitor.ts
685
+ /**
686
+ * TypeOrmOrderByVisitor — translates OData $orderby items into TypeORM
687
+ * QueryBuilder.orderBy() / addOrderBy() calls.
688
+ */
689
+ var TypeOrmOrderByVisitor = class {
690
+ constructor(qb, alias) {
691
+ this.qb = qb;
692
+ this.alias = alias;
693
+ }
694
+ /**
695
+ * Apply $orderby items to the QueryBuilder.
696
+ * First item uses orderBy(), subsequent items use addOrderBy().
697
+ */
698
+ apply(items) {
699
+ if (!items.length) return;
700
+ for (let i = 0; i < items.length; i++) {
701
+ const item = items[i];
702
+ const propertyName = this.resolvePropertyName(item);
703
+ const direction = item.direction.toUpperCase();
704
+ const column = `${this.alias}.${propertyName}`;
705
+ if (i === 0) this.qb.orderBy(column, direction);
706
+ else this.qb.addOrderBy(column, direction);
707
+ }
708
+ }
709
+ resolvePropertyName(item) {
710
+ const expr = item.expression;
711
+ if (expr.kind === "PropertyAccess") return expr.path[0];
712
+ return "";
713
+ }
714
+ };
715
+ //#endregion
716
+ //#region src/translator/pagination-visitor.ts
717
+ /**
718
+ * TypeOrmPaginationVisitor — applies OData $top/$skip pagination to a TypeORM
719
+ * SelectQueryBuilder via take() and skip().
720
+ */
721
+ var TypeOrmPaginationVisitor = class {
722
+ constructor(qb) {
723
+ this.qb = qb;
724
+ }
725
+ /**
726
+ * Apply pagination to the QueryBuilder.
727
+ *
728
+ * - top=0 is valid (returns empty result set) — applies take(0)
729
+ * - undefined values are skipped (no call made)
730
+ */
731
+ paginate(top, skip) {
732
+ if (top !== void 0) this.qb.take(top);
733
+ if (skip !== void 0) this.qb.skip(skip);
734
+ }
735
+ };
736
+ //#endregion
737
+ //#region src/translator/expand-visitor.ts
738
+ /**
739
+ * TypeOrmExpandVisitor — translates $expand AST nodes into TypeORM
740
+ * leftJoinAndSelect calls. Supports nested expand with depth tracking.
741
+ *
742
+ * Per D-09: Uses JOINs, NOT lazy loading — one SQL query.
743
+ * Per D-07: Enforces maxExpandDepth.
744
+ * Per D-10: Validates navigation property names against EDM.
745
+ * Per D-13: Records $top/$skip per expand item in expandPaginationMap for
746
+ * post-JOIN in-memory slicing by TypeOrmQueryTranslator after getMany().
747
+ *
748
+ * Alias convention: `{parentAlias}_{navigationProperty}` — unique per level
749
+ * to prevent TypeORM alias collision when the same nav prop appears at
750
+ * different tree paths.
751
+ */
752
+ var TypeOrmExpandVisitor = class {
753
+ /**
754
+ * Records per-navigation-property $top/$skip values for post-JOIN slicing.
755
+ * Populated during apply() calls. Consumed by TypeOrmQueryTranslator after
756
+ * getMany() via applyExpandPagination().
757
+ */
758
+ expandPaginationMap = /* @__PURE__ */ new Map();
759
+ constructor(qb, edmRegistry, maxExpandDepth) {
760
+ this.qb = qb;
761
+ this.edmRegistry = edmRegistry;
762
+ this.maxExpandDepth = maxExpandDepth;
763
+ }
764
+ /**
765
+ * Apply an ExpandNode to the SelectQueryBuilder starting from parentAlias.
766
+ *
767
+ * @param expandNode - The $expand AST node to process
768
+ * @param parentAlias - The QueryBuilder alias for the parent entity (e.g. 'entity')
769
+ * @param entityType - EDM entity type of the parent (for validation)
770
+ * @param currentDepth - Current recursion depth (0 = top-level expand)
771
+ */
772
+ apply(expandNode, parentAlias, entityType, currentDepth) {
773
+ if (currentDepth >= this.maxExpandDepth) throw new _nestjs_odata_core.ODataValidationError(`$expand depth limit of ${this.maxExpandDepth} exceeded`, entityType.name, "$expand");
774
+ for (const item of expandNode.items) this.applyExpandItem(item, parentAlias, entityType, currentDepth);
775
+ }
776
+ applyExpandItem(item, parentAlias, entityType, currentDepth) {
777
+ const navProp = entityType.navigationProperties.find((np) => np.name === item.navigationProperty);
778
+ if (!navProp) throw new _nestjs_odata_core.ODataValidationError(`'${item.navigationProperty}' is not a navigation property of '${entityType.name}'`, entityType.name, item.navigationProperty);
779
+ const joinAlias = `${parentAlias}_${item.navigationProperty}`;
780
+ this.qb.leftJoinAndSelect(`${parentAlias}.${item.navigationProperty}`, joinAlias);
781
+ const targetEntityType = this.edmRegistry.getEntityType(navProp.type);
782
+ if (item.filter && targetEntityType) new TypeOrmFilterVisitor(this.qb, joinAlias, targetEntityType).visit(item.filter);
783
+ if (item.select && targetEntityType) new TypeOrmSelectVisitor(this.qb, joinAlias, targetEntityType).apply(item.select);
784
+ if (item.orderBy?.length) new TypeOrmOrderByVisitor(this.qb, joinAlias).apply(item.orderBy);
785
+ if ((item.top !== void 0 || item.skip !== void 0) && navProp.isCollection) this.expandPaginationMap.set(item.navigationProperty, {
786
+ skip: item.skip,
787
+ top: item.top
788
+ });
789
+ if (item.expand && targetEntityType) this.apply(item.expand, joinAlias, targetEntityType, currentDepth + 1);
790
+ }
791
+ };
792
+ //#endregion
793
+ //#region src/translator/apply-visitor.ts
794
+ /**
795
+ * TypeOrmApplyVisitor — translates an OData $apply pipeline AST into
796
+ * TypeORM SelectQueryBuilder GROUP BY / aggregate SQL.
797
+ *
798
+ * Handles three step types in pipeline order:
799
+ * - ApplyFilter: delegates to TypeOrmFilterVisitor (adds WHERE clause)
800
+ * - ApplyGroupBy: adds GROUP BY columns + aggregate SELECT expressions
801
+ * - ApplyAggregate: adds aggregate SELECT expressions (no GROUP BY)
802
+ *
803
+ * Security: aggregate property names validated against AggregateExpression.method
804
+ * enum (constrained by parser); aliases validated by parser regex before SQL use.
805
+ * No user input is directly interpolated into SQL — property/alias names come
806
+ * from the validated AST (T-11-05 mitigation).
807
+ *
808
+ * Returns projected column names (for @odata.context URL construction).
809
+ */
810
+ var TypeOrmApplyVisitor = class {
811
+ constructor(qb, alias, entityType, maxFilterDepth = 10, dialect = "ansi", repo) {
812
+ this.qb = qb;
813
+ this.alias = alias;
814
+ this.entityType = entityType;
815
+ this.maxFilterDepth = maxFilterDepth;
816
+ this.dialect = dialect;
817
+ this.repo = repo;
818
+ }
819
+ /**
820
+ * Process all steps in an $apply pipeline in order.
821
+ * Returns all projected column names for @odata.context construction.
822
+ */
823
+ apply(applyNode) {
824
+ const projectedColumns = [];
825
+ for (const step of applyNode.steps) {
826
+ const stepColumns = this.applyStep(step);
827
+ projectedColumns.push(...stepColumns);
828
+ }
829
+ return projectedColumns;
830
+ }
831
+ applyStep(step) {
832
+ switch (step.kind) {
833
+ case "ApplyFilter": return this.applyFilterStep(step.filter);
834
+ case "ApplyGroupBy": return this.applyGroupByStep(step.properties, step.aggregate);
835
+ case "ApplyAggregate": return this.applyAggregateStep(step.expressions);
836
+ }
837
+ }
838
+ /**
839
+ * ApplyFilter step — delegates to TypeOrmFilterVisitor.
840
+ * Adds WHERE clause to the query. Returns no projected columns.
841
+ */
842
+ applyFilterStep(filter) {
843
+ new TypeOrmFilterVisitor(this.qb, this.alias, this.entityType, this.maxFilterDepth, this.dialect, this.repo).visit(filter);
844
+ return [];
845
+ }
846
+ /**
847
+ * ApplyGroupBy step — adds GROUP BY columns and optional aggregate expressions.
848
+ * Clears existing SELECT first (replaces entity.* with explicit projections).
849
+ */
850
+ applyGroupByStep(properties, aggregate) {
851
+ const projectedColumns = [];
852
+ this.qb.select([]);
853
+ for (const prop of properties) {
854
+ this.qb.addSelect(`${this.alias}.${prop}`, prop);
855
+ this.qb.addGroupBy(`${this.alias}.${prop}`);
856
+ projectedColumns.push(prop);
857
+ }
858
+ if (aggregate) for (const expr of aggregate) {
859
+ const sqlExpr = this.buildAggregateSql(expr);
860
+ this.qb.addSelect(sqlExpr, expr.alias);
861
+ projectedColumns.push(expr.alias);
862
+ }
863
+ return projectedColumns;
864
+ }
865
+ /**
866
+ * ApplyAggregate step (no GROUP BY) — adds aggregate SELECT expressions.
867
+ * Clears existing SELECT first (replaces entity.* with explicit projections).
868
+ */
869
+ applyAggregateStep(expressions) {
870
+ const projectedColumns = [];
871
+ this.qb.select([]);
872
+ for (const expr of expressions) {
873
+ const sqlExpr = this.buildAggregateSql(expr);
874
+ this.qb.addSelect(sqlExpr, expr.alias);
875
+ projectedColumns.push(expr.alias);
876
+ }
877
+ return projectedColumns;
878
+ }
879
+ /**
880
+ * Build the SQL aggregate function expression for a single AggregateExpression.
881
+ *
882
+ * Method mapping:
883
+ * - count with $count property => COUNT(*)
884
+ * - count with field => COUNT(alias.field)
885
+ * - sum => SUM(alias.field)
886
+ * - avg => AVG(alias.field)
887
+ * - min => MIN(alias.field)
888
+ * - max => MAX(alias.field)
889
+ * - countdistinct => COUNT(DISTINCT alias.field)
890
+ */
891
+ buildAggregateSql(expr) {
892
+ const { property, method } = expr;
893
+ switch (method) {
894
+ case "count": return property === "$count" ? "COUNT(*)" : `COUNT(${this.alias}.${property})`;
895
+ case "sum": return `SUM(${this.alias}.${property})`;
896
+ case "avg": return `AVG(${this.alias}.${property})`;
897
+ case "min": return `MIN(${this.alias}.${property})`;
898
+ case "max": return `MAX(${this.alias}.${property})`;
899
+ case "countdistinct": return `COUNT(DISTINCT ${this.alias}.${property})`;
900
+ }
901
+ }
902
+ };
903
+ //#endregion
904
+ //#region src/translator/expand-pagination.ts
905
+ /**
906
+ * Apply post-JOIN pagination to expanded collections.
907
+ *
908
+ * TypeORM hydrates relations as JavaScript arrays. After getMany(),
909
+ * this function slices each expanded collection by the per-expand-item
910
+ * $top/$skip values recorded during ExpandVisitor traversal.
911
+ *
912
+ * Per D-13: In-memory slicing is the v1 approach. Acceptable for
913
+ * single-level expand pagination. Total result set is already bounded
914
+ * by maxTop and maxExpandDepth before slicing occurs (T-05-10 accept).
915
+ *
916
+ * Mutates items in-place — the hydrated array is replaced with the sliced
917
+ * version. This avoids allocating a new top-level array.
918
+ */
919
+ function applyExpandPagination(items, paginationMap) {
920
+ for (const [navProp, { skip = 0, top }] of paginationMap.entries()) for (const item of items) {
921
+ const related = item[navProp];
922
+ if (Array.isArray(related)) item[navProp] = related.slice(skip, top !== void 0 ? skip + top : void 0);
923
+ }
924
+ }
925
+ //#endregion
926
+ //#region \0@oxc-project+runtime@0.122.0/helpers/decorateMetadata.js
927
+ function __decorateMetadata(k, v) {
928
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
929
+ }
930
+ //#endregion
931
+ //#region \0@oxc-project+runtime@0.122.0/helpers/decorateParam.js
932
+ function __decorateParam(paramIndex, decorator) {
933
+ return function(target, key) {
934
+ decorator(target, key, paramIndex);
935
+ };
936
+ }
937
+ //#endregion
938
+ //#region \0@oxc-project+runtime@0.122.0/helpers/decorate.js
939
+ function __decorate(decorators, target, key, desc) {
940
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
941
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
942
+ 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;
943
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
944
+ }
945
+ //#endregion
946
+ //#region src/translator/typeorm-query-translator.ts
947
+ var _ref$5;
948
+ let TypeOrmQueryTranslator = class TypeOrmQueryTranslator {
949
+ constructor(repo, edmRegistry, options, searchProvider) {
950
+ this.repo = repo;
951
+ this.edmRegistry = edmRegistry;
952
+ this.options = options;
953
+ this.searchProvider = searchProvider;
954
+ }
955
+ /**
956
+ * Translate an ODataQuery AST into a TypeORM SelectQueryBuilder.
957
+ * Also returns the expandPaginationMap for post-JOIN slicing (D-13).
958
+ * Does not execute the query — call execute() for DB access.
959
+ */
960
+ translate(query, entityType) {
961
+ const alias = "entity";
962
+ const qb = this.repo.createQueryBuilder(alias);
963
+ const dbType = this.repo.manager.connection.options.type;
964
+ const dialect = dbType === "sqlite" || dbType === "postgres" ? dbType : "ansi";
965
+ if (query.filter) new TypeOrmFilterVisitor(qb, alias, entityType, this.options.maxFilterDepth, dialect, this.repo).visit(query.filter);
966
+ if (query.search && this.searchProvider) {
967
+ const searchResult = this.searchProvider.buildSearchCondition(query.search, query.entitySetName, alias);
968
+ if (searchResult) qb.andWhere(searchResult.condition, searchResult.params);
969
+ }
970
+ let applyProperties;
971
+ if (query.apply) applyProperties = new TypeOrmApplyVisitor(qb, alias, entityType, this.options.maxFilterDepth, dialect, this.repo).apply(query.apply);
972
+ if (!query.apply) {
973
+ if (query.select) new TypeOrmSelectVisitor(qb, alias, entityType).apply(query.select);
974
+ if (query.orderBy?.length) new TypeOrmOrderByVisitor(qb, alias).apply(query.orderBy);
975
+ }
976
+ new TypeOrmPaginationVisitor(qb).paginate(query.top, query.skip);
977
+ let expandPaginationMap = /* @__PURE__ */ new Map();
978
+ if (!query.apply && query.expand) {
979
+ const expandVisitor = new TypeOrmExpandVisitor(qb, this.edmRegistry, this.options.maxExpandDepth);
980
+ expandVisitor.apply(query.expand, alias, entityType, 0);
981
+ expandPaginationMap = expandVisitor.expandPaginationMap;
982
+ }
983
+ return {
984
+ qb,
985
+ expandPaginationMap,
986
+ applyProperties
987
+ };
988
+ }
989
+ /**
990
+ * Execute the translated SelectQueryBuilder and return structured results.
991
+ * Applies post-JOIN in-memory expand pagination after getMany() (D-13).
992
+ * When $apply is present, uses getRawMany() to preserve aggregate aliases.
993
+ *
994
+ * @param translateResult - The result from translate() (qb + expandPaginationMap)
995
+ * @param includeCount - If true, uses getManyAndCount() to include total count
996
+ */
997
+ async execute(translateResult, includeCount) {
998
+ let qb;
999
+ let expandPaginationMap;
1000
+ let applyProperties;
1001
+ if ("qb" in translateResult && "expandPaginationMap" in translateResult) {
1002
+ qb = translateResult.qb;
1003
+ expandPaginationMap = translateResult.expandPaginationMap;
1004
+ applyProperties = translateResult.applyProperties;
1005
+ } else {
1006
+ qb = translateResult;
1007
+ expandPaginationMap = /* @__PURE__ */ new Map();
1008
+ }
1009
+ if (applyProperties !== void 0) {
1010
+ if (includeCount) {
1011
+ const items = await qb.getRawMany();
1012
+ return {
1013
+ items,
1014
+ count: items.length,
1015
+ isAggregated: true,
1016
+ applyProperties
1017
+ };
1018
+ }
1019
+ return {
1020
+ items: await qb.getRawMany(),
1021
+ isAggregated: true,
1022
+ applyProperties
1023
+ };
1024
+ }
1025
+ if (includeCount) {
1026
+ const [items, count] = await qb.getManyAndCount();
1027
+ applyExpandPagination(items, expandPaginationMap);
1028
+ return {
1029
+ items,
1030
+ count
1031
+ };
1032
+ }
1033
+ const items = await qb.getMany();
1034
+ applyExpandPagination(items, expandPaginationMap);
1035
+ return { items };
1036
+ }
1037
+ };
1038
+ TypeOrmQueryTranslator = __decorate([
1039
+ (0, _nestjs_common.Injectable)(),
1040
+ __decorateParam(2, (0, _nestjs_common.Inject)(_nestjs_odata_core.ODATA_MODULE_OPTIONS)),
1041
+ __decorateParam(3, (0, _nestjs_common.Optional)()),
1042
+ __decorateParam(3, (0, _nestjs_common.Inject)(_nestjs_odata_core.SEARCH_PROVIDER)),
1043
+ __decorateMetadata("design:paramtypes", [
1044
+ Object,
1045
+ typeof (_ref$5 = typeof _nestjs_odata_core.EdmRegistry !== "undefined" && _nestjs_odata_core.EdmRegistry) === "function" ? _ref$5 : Object,
1046
+ Object,
1047
+ Object
1048
+ ])
1049
+ ], TypeOrmQueryTranslator);
1050
+ //#endregion
1051
+ //#region src/translator/typeorm-auto-handler.ts
1052
+ var _ref$4, _ref2$2;
1053
+ let TypeOrmAutoHandler = class TypeOrmAutoHandler {
1054
+ constructor(translator, edmRegistry, options, repo, etagProvider, dataSource) {
1055
+ this.translator = translator;
1056
+ this.edmRegistry = edmRegistry;
1057
+ this.options = options;
1058
+ this.repo = repo;
1059
+ this.etagProvider = etagProvider;
1060
+ this.dataSource = dataSource;
1061
+ }
1062
+ /**
1063
+ * Resolve EdmEntityType from the registry at request time.
1064
+ * Throws if the entity set is not registered.
1065
+ */
1066
+ resolveEntityType(entitySetName) {
1067
+ const entitySet = this.edmRegistry.getEntitySet(entitySetName);
1068
+ if (!entitySet) throw new Error(`ODataAutoHandler: entity set '${entitySetName}' not found in EdmRegistry`);
1069
+ const entityType = this.edmRegistry.getEntityType(entitySet.entityTypeName);
1070
+ if (!entityType) throw new Error(`ODataAutoHandler: entity type '${entitySet.entityTypeName}' not found in EdmRegistry`);
1071
+ return entityType;
1072
+ }
1073
+ /**
1074
+ * Handle an OData GET collection request.
1075
+ *
1076
+ * Strategy:
1077
+ * 1. Resolve EdmEntityType via EdmRegistry
1078
+ * 2. Determine effectiveTop (query.top ?? maxTop), clamped to maxTop
1079
+ * 3. Build a modified query with top=effectiveTop+1 to detect next page
1080
+ * 4. Translate to SelectQueryBuilder
1081
+ * 5. Execute (with or without count per query.count)
1082
+ * 6. Check if items.length > effectiveTop (has more pages)
1083
+ * 7. If yes: slice to effectiveTop, build nextLink; otherwise: no nextLink
1084
+ *
1085
+ * @param query - Validated ODataQuery from ODataQueryPipe
1086
+ * @param requestUrl - Base URL of the request for nextLink construction
1087
+ */
1088
+ async handleGet(query, requestUrl) {
1089
+ const entityType = this.resolveEntityType(query.entitySetName);
1090
+ const maxTop = this.options.maxTop ?? 1e3;
1091
+ const effectiveTop = Math.min(query.top ?? maxTop, maxTop);
1092
+ const fetchQuery = {
1093
+ ...query,
1094
+ top: effectiveTop + 1
1095
+ };
1096
+ const translateResult = this.translator.translate(fetchQuery, entityType);
1097
+ const includeCount = query.count === true;
1098
+ const rawResult = await this.translator.execute(translateResult, includeCount);
1099
+ const items = rawResult.items;
1100
+ const hasMore = items.length > effectiveTop;
1101
+ const slicedItems = hasMore ? items.slice(0, effectiveTop) : items;
1102
+ let nextLink;
1103
+ if (hasMore) {
1104
+ const currentSkip = query.skip ?? 0;
1105
+ nextLink = this.buildNextLink(requestUrl, currentSkip + effectiveTop, effectiveTop);
1106
+ }
1107
+ return {
1108
+ items: slicedItems,
1109
+ count: rawResult.count,
1110
+ nextLink,
1111
+ select: query.select,
1112
+ isAggregated: rawResult.isAggregated,
1113
+ applyProperties: rawResult.applyProperties
1114
+ };
1115
+ }
1116
+ /**
1117
+ * Handle an OData $count route.
1118
+ *
1119
+ * Per Pitfall 3: strips $top/$skip/$orderby/$select from the query so count
1120
+ * returns total matching rows for the filter, not just the current page count.
1121
+ *
1122
+ * @param query - Validated ODataQuery from ODataQueryPipe
1123
+ */
1124
+ async handleCount(query) {
1125
+ const entityType = this.resolveEntityType(query.entitySetName);
1126
+ const countQuery = {
1127
+ entitySetName: query.entitySetName,
1128
+ filter: query.filter
1129
+ };
1130
+ const { qb } = this.translator.translate(countQuery, entityType);
1131
+ return qb.getCount();
1132
+ }
1133
+ /**
1134
+ * Build an OData nextLink URL with updated $skip and $top parameters.
1135
+ *
1136
+ * @param requestUrl - The current request URL (may contain existing query params)
1137
+ * @param newSkip - The new $skip value for the next page
1138
+ * @param top - The page size ($top value)
1139
+ */
1140
+ buildNextLink(requestUrl, newSkip, top) {
1141
+ let baseUrl;
1142
+ let searchStr;
1143
+ const qIndex = requestUrl.indexOf("?");
1144
+ if (qIndex !== -1) {
1145
+ baseUrl = requestUrl.slice(0, qIndex);
1146
+ searchStr = requestUrl.slice(qIndex + 1);
1147
+ } else {
1148
+ baseUrl = requestUrl;
1149
+ searchStr = "";
1150
+ }
1151
+ const params = /* @__PURE__ */ new Map();
1152
+ if (searchStr) for (const part of searchStr.split("&")) {
1153
+ const eqIdx = part.indexOf("=");
1154
+ if (eqIdx !== -1) params.set(decodeURIComponent(part.slice(0, eqIdx)), decodeURIComponent(part.slice(eqIdx + 1)));
1155
+ }
1156
+ params.set("$skip", String(newSkip));
1157
+ params.set("$top", String(top));
1158
+ const queryParts = [];
1159
+ for (const [key, val] of params.entries()) queryParts.push(`${key}=${encodeURIComponent(val)}`);
1160
+ return `${baseUrl}?${queryParts.join("&")}`;
1161
+ }
1162
+ /**
1163
+ * Handle OData GET by key — single entity retrieval.
1164
+ *
1165
+ * Per D-05: returns single entity, throws NotFoundException if not found.
1166
+ * Per D-02: key is parsed from parenthetical format.
1167
+ * Per T-04-08: parseODataKey returns typed values used in parameterized where clause.
1168
+ * Per T-09-05: If-None-Match header supported — returns { __notModified, etag } signal when matched.
1169
+ */
1170
+ async handleGetByKey(keyStr, entitySetName, ifNoneMatchHeader) {
1171
+ const where = (0, _nestjs_odata_core.parseODataKey)(keyStr, this.resolveEntityType(entitySetName).keyProperties);
1172
+ const entity = await this.repo.findOne({ where });
1173
+ if (!entity) throw new _nestjs_common.NotFoundException(`Entity '${entitySetName}' with key '${keyStr}' not found`);
1174
+ if (this.etagProvider) {
1175
+ const etagColumn = this.etagProvider.getETagColumn(entitySetName);
1176
+ if (etagColumn) {
1177
+ const currentEtag = this.etagProvider.computeETag(entity, etagColumn);
1178
+ const entityWithEtag = {
1179
+ ...entity,
1180
+ __etag: currentEtag
1181
+ };
1182
+ if (ifNoneMatchHeader && this.etagProvider.validateIfMatch(ifNoneMatchHeader, entity, etagColumn)) return {
1183
+ __notModified: true,
1184
+ etag: currentEtag
1185
+ };
1186
+ return entityWithEtag;
1187
+ }
1188
+ }
1189
+ return entity;
1190
+ }
1191
+ /**
1192
+ * Handle OData POST — create entity.
1193
+ *
1194
+ * Per D-03: returns 201 + Location header + created entity.
1195
+ * Returns { entity, locationUrl } for the interceptor to set the Location header.
1196
+ * Per T-04-07: TypeORM repo.create() only maps declared columns — unknown fields ignored.
1197
+ * Per T-04-11: entity class acts as whitelist, preventing mass assignment.
1198
+ */
1199
+ async handleCreate(body, entitySetName) {
1200
+ const entityType = this.resolveEntityType(entitySetName);
1201
+ const created = this.repo.create(body);
1202
+ const saved = await this.repo.save(created);
1203
+ const keyStr = entityType.keyProperties.map((kp) => {
1204
+ const val = saved[kp];
1205
+ return entityType.keyProperties.length === 1 ? String(val) : `${kp}=${String(val)}`;
1206
+ }).join(",");
1207
+ return {
1208
+ entity: saved,
1209
+ locationUrl: `${this.options.serviceRoot}/${entitySetName}(${keyStr})`
1210
+ };
1211
+ }
1212
+ /**
1213
+ * Handle OData POST with deep insert — create parent and nested children atomically.
1214
+ *
1215
+ * Per D-02 (deep insert): navigation property keys in the body identify child collections.
1216
+ * All saves occur within the provided EntityManager (transaction scope).
1217
+ *
1218
+ * Per T-10-05: depth is checked before recursion — throws 400 if depth >= maxDeepInsertDepth.
1219
+ * Per T-10-06: TypeORM repo.create() only maps declared columns — mass assignment safe.
1220
+ *
1221
+ * @param body - Request body (scalar fields + optional navigation property arrays)
1222
+ * @param entitySetName - OData entity set name (e.g. 'Orders')
1223
+ * @param manager - Transaction-scoped EntityManager
1224
+ * @param depth - Current recursion depth (default 0)
1225
+ */
1226
+ async handleDeepCreate(body, entitySetName, manager, depth = 0) {
1227
+ const maxDepth = this.options.maxDeepInsertDepth ?? 5;
1228
+ if (depth >= maxDepth) throw new _nestjs_common.HttpException({ error: {
1229
+ code: "400",
1230
+ message: `Deep insert exceeds maxDeepInsertDepth (${maxDepth})`
1231
+ } }, 400);
1232
+ const entityType = this.resolveEntityType(entitySetName);
1233
+ const navPropNames = new Set(entityType.navigationProperties.map((p) => p.name));
1234
+ const scalarBody = {};
1235
+ const navData = {};
1236
+ for (const [key, value] of Object.entries(body)) if (navPropNames.has(key) && Array.isArray(value)) navData[key] = value;
1237
+ else if (!navPropNames.has(key)) scalarBody[key] = value;
1238
+ const parentRepo = manager.getRepository(this.repo.target);
1239
+ const parentCreated = parentRepo.create(scalarBody);
1240
+ const savedParent = await parentRepo.save(parentCreated);
1241
+ const parentKeyValue = savedParent[entityType.keyProperties[0] ?? "id"];
1242
+ for (const [navPropName, childItems] of Object.entries(navData)) {
1243
+ const navProp = entityType.navigationProperties.find((p) => p.name === navPropName);
1244
+ if (!navProp) continue;
1245
+ let rawType = navProp.type;
1246
+ if (rawType.startsWith("Collection(") && rawType.endsWith(")")) rawType = rawType.slice(11, -1);
1247
+ const childTypeName = rawType.split(".").pop() ?? rawType;
1248
+ if (![...this.edmRegistry.getEntitySets().values()].find((es) => es.entityTypeName === childTypeName)) throw new _nestjs_common.HttpException({ error: {
1249
+ code: "400",
1250
+ message: `Deep insert: cannot resolve entity set for navigation property '${navPropName}' (type '${childTypeName}')`
1251
+ } }, 400);
1252
+ let fkColumnName;
1253
+ if (this.dataSource) try {
1254
+ const relation = this.dataSource.getMetadata(this.repo.target).relations.find((r) => r.propertyName === navPropName);
1255
+ if (relation?.inverseRelation?.joinColumns?.[0]?.propertyName) fkColumnName = relation.inverseRelation.joinColumns[0].propertyName;
1256
+ } catch {}
1257
+ if (!fkColumnName) {
1258
+ const singularParent = entitySetName.endsWith("s") ? entitySetName.slice(0, -1) : entitySetName;
1259
+ fkColumnName = `${singularParent.charAt(0).toLowerCase()}${singularParent.slice(1)}Id`;
1260
+ }
1261
+ const childEntityClass = this.dataSource ? this.resolveEntityClassFromDataSource(childTypeName) : void 0;
1262
+ for (let index = 0; index < childItems.length; index++) {
1263
+ const childBody = {
1264
+ ...childItems[index],
1265
+ [fkColumnName]: parentKeyValue
1266
+ };
1267
+ try {
1268
+ if (childEntityClass) {
1269
+ const childRepo = manager.getRepository(childEntityClass);
1270
+ const childCreated = childRepo.create(childBody);
1271
+ await childRepo.save(childCreated);
1272
+ } else throw new Error(`Cannot resolve entity class for '${childTypeName}' — DataSource not available`);
1273
+ } catch (err) {
1274
+ const message = err instanceof Error ? err.message : String(err);
1275
+ throw new _nestjs_common.HttpException({ error: {
1276
+ code: "400",
1277
+ message: `Deep insert failed at '${navPropName}[${index}]': ${message}`
1278
+ } }, 400);
1279
+ }
1280
+ }
1281
+ }
1282
+ const keyStr = entityType.keyProperties.map((kp) => {
1283
+ const val = savedParent[kp];
1284
+ return entityType.keyProperties.length === 1 ? String(val) : `${kp}=${String(val)}`;
1285
+ }).join(",");
1286
+ return {
1287
+ entity: savedParent,
1288
+ locationUrl: `${this.options.serviceRoot}/${entitySetName}(${keyStr})`
1289
+ };
1290
+ }
1291
+ /**
1292
+ * Resolve a TypeORM entity class from an EDM entity type name.
1293
+ * Requires DataSource to be provided.
1294
+ */
1295
+ resolveEntityClassFromDataSource(entityTypeName) {
1296
+ if (!this.dataSource) return void 0;
1297
+ for (const meta of this.dataSource.entityMetadatas) if (meta.name === entityTypeName) return meta.target;
1298
+ }
1299
+ /**
1300
+ * Handle OData PATCH — partial update (merge-patch semantics).
1301
+ *
1302
+ * Per D-01: send only changed fields, server merges with existing entity.
1303
+ * Per Pitfall 4: checks preload result for undefined (entity not found case).
1304
+ * Per T-04-08: parseODataKey returns typed values for safe parameterized queries.
1305
+ * Per T-09-04: If-Match header enforces optimistic concurrency — 412 on stale ETag.
1306
+ */
1307
+ async handleUpdate(keyStr, body, entitySetName, ifMatchHeader) {
1308
+ const where = (0, _nestjs_odata_core.parseODataKey)(keyStr, this.resolveEntityType(entitySetName).keyProperties);
1309
+ if (this.etagProvider && ifMatchHeader) {
1310
+ const etagColumn = this.etagProvider.getETagColumn(entitySetName);
1311
+ if (etagColumn) {
1312
+ const current = await this.repo.findOne({ where });
1313
+ if (!current) throw new _nestjs_common.NotFoundException(`Entity '${entitySetName}' with key '${keyStr}' not found`);
1314
+ if (!this.etagProvider.validateIfMatch(ifMatchHeader, current, etagColumn)) throw new _nestjs_common.HttpException({ error: {
1315
+ code: "412",
1316
+ message: "The ETag value does not match the current version of the entity"
1317
+ } }, 412);
1318
+ }
1319
+ }
1320
+ const merged = {
1321
+ ...where,
1322
+ ...body
1323
+ };
1324
+ const preloaded = await this.repo.preload(merged);
1325
+ if (!preloaded) throw new _nestjs_common.NotFoundException(`Entity '${entitySetName}' with key '${keyStr}' not found`);
1326
+ return this.repo.save(preloaded);
1327
+ }
1328
+ /**
1329
+ * Handle OData PUT — full entity replacement.
1330
+ *
1331
+ * Per D-01 (OData v4 spec): PUT replaces ALL entity properties.
1332
+ * Unspecified fields reset to column defaults (null for nullable, default value otherwise).
1333
+ * This is distinct from PATCH merge semantics.
1334
+ *
1335
+ * Implementation:
1336
+ * 1. Parse key from URL
1337
+ * 2. Validate body key matches URL key (reject 400 on mismatch)
1338
+ * 3. Find existing entity — throw 404 if not found
1339
+ * 4. Validate ETag If-Match (412 on mismatch)
1340
+ * 5. Build full replacement using repo.metadata.columns:
1341
+ * - Skip isPrimary, isCreateDate, isUpdateDate, isVersion columns
1342
+ * - body value > column default > null (if nullable) > absent
1343
+ * 6. Save and return
1344
+ *
1345
+ * Per T-10-01: body key mismatch rejected before DB write.
1346
+ * Per T-10-02: repo.create() only maps declared entity columns — mass assignment safe.
1347
+ * Per T-10-03: managed columns (PK, timestamps, version) skipped.
1348
+ * Per T-10-04: ETag If-Match enforcement identical to handleUpdate().
1349
+ */
1350
+ async handleReplace(keyStr, body, entitySetName, ifMatchHeader) {
1351
+ const entityType = this.resolveEntityType(entitySetName);
1352
+ const where = (0, _nestjs_odata_core.parseODataKey)(keyStr, entityType.keyProperties);
1353
+ for (const kp of entityType.keyProperties) {
1354
+ const bodyKeyValue = body[kp];
1355
+ const urlKeyValue = where[kp];
1356
+ if (bodyKeyValue !== void 0 && bodyKeyValue !== urlKeyValue) throw new _nestjs_common.HttpException({ error: {
1357
+ code: "400",
1358
+ message: "Key in body does not match URL key"
1359
+ } }, 400);
1360
+ }
1361
+ const existing = await this.repo.findOne({ where });
1362
+ if (!existing) throw new _nestjs_common.NotFoundException(`Entity '${entitySetName}' with key '${keyStr}' not found`);
1363
+ if (this.etagProvider && ifMatchHeader) {
1364
+ const etagColumn = this.etagProvider.getETagColumn(entitySetName);
1365
+ if (etagColumn) {
1366
+ if (!this.etagProvider.validateIfMatch(ifMatchHeader, existing, etagColumn)) throw new _nestjs_common.HttpException({ error: {
1367
+ code: "412",
1368
+ message: "The ETag value does not match the current version of the entity"
1369
+ } }, 412);
1370
+ }
1371
+ }
1372
+ const replacement = {};
1373
+ for (const kp of entityType.keyProperties) replacement[kp] = where[kp];
1374
+ for (const col of this.repo.metadata.columns) {
1375
+ if (col.isPrimary) continue;
1376
+ if (col.isCreateDate || col.isUpdateDate || col.isVersion) continue;
1377
+ const propName = col.propertyName;
1378
+ if (Object.prototype.hasOwnProperty.call(body, propName)) replacement[propName] = body[propName];
1379
+ else if (col.default !== void 0) replacement[propName] = col.default;
1380
+ else if (col.isNullable) replacement[propName] = null;
1381
+ }
1382
+ const entity = this.repo.create(replacement);
1383
+ return this.repo.save(entity);
1384
+ }
1385
+ /**
1386
+ * Handle OData DELETE — remove entity.
1387
+ *
1388
+ * Per D-04: returns 204 No Content (void return value).
1389
+ * Per T-04-08: parseODataKey returns typed values for safe parameterized queries.
1390
+ * Per T-09-04: If-Match header enforces optimistic concurrency — 412 on stale ETag.
1391
+ */
1392
+ async handleDelete(keyStr, entitySetName, ifMatchHeader) {
1393
+ const where = (0, _nestjs_odata_core.parseODataKey)(keyStr, this.resolveEntityType(entitySetName).keyProperties);
1394
+ if (this.etagProvider && ifMatchHeader) {
1395
+ const etagColumn = this.etagProvider.getETagColumn(entitySetName);
1396
+ if (etagColumn) {
1397
+ const current = await this.repo.findOne({ where });
1398
+ if (!current) throw new _nestjs_common.NotFoundException(`Entity '${entitySetName}' with key '${keyStr}' not found`);
1399
+ if (!this.etagProvider.validateIfMatch(ifMatchHeader, current, etagColumn)) throw new _nestjs_common.HttpException({ error: {
1400
+ code: "412",
1401
+ message: "The ETag value does not match the current version of the entity"
1402
+ } }, 412);
1403
+ }
1404
+ }
1405
+ if ((await this.repo.delete(where)).affected === 0) throw new _nestjs_common.NotFoundException(`Entity '${entitySetName}' with key '${keyStr}' not found`);
1406
+ }
1407
+ };
1408
+ TypeOrmAutoHandler = __decorate([
1409
+ (0, _nestjs_common.Injectable)(),
1410
+ __decorateParam(2, (0, _nestjs_common.Inject)(_nestjs_odata_core.ODATA_MODULE_OPTIONS)),
1411
+ __decorateParam(4, (0, _nestjs_common.Optional)()),
1412
+ __decorateParam(4, (0, _nestjs_common.Inject)(_nestjs_odata_core.ETAG_PROVIDER)),
1413
+ __decorateMetadata("design:paramtypes", [
1414
+ typeof (_ref$4 = typeof TypeOrmQueryTranslator !== "undefined" && TypeOrmQueryTranslator) === "function" ? _ref$4 : Object,
1415
+ typeof (_ref2$2 = typeof _nestjs_odata_core.EdmRegistry !== "undefined" && _nestjs_odata_core.EdmRegistry) === "function" ? _ref2$2 : Object,
1416
+ Object,
1417
+ Object,
1418
+ Object,
1419
+ Object
1420
+ ])
1421
+ ], TypeOrmAutoHandler);
1422
+ //#endregion
1423
+ //#region src/etag/typeorm-etag.provider.ts
1424
+ var _ref$3;
1425
+ let TypeOrmETagProvider = class TypeOrmETagProvider {
1426
+ cache = /* @__PURE__ */ new Map();
1427
+ constructor(dataSource, edmRegistry) {
1428
+ this.dataSource = dataSource;
1429
+ this.edmRegistry = edmRegistry;
1430
+ }
1431
+ /**
1432
+ * Get the ETag column name for an entity set.
1433
+ * Returns undefined if neither @ODataETag nor @UpdateDateColumn is found.
1434
+ * Results are cached to avoid repeated metadata reflection.
1435
+ */
1436
+ getETagColumn(entitySetName) {
1437
+ if (this.cache.has(entitySetName)) return this.cache.get(entitySetName) ?? void 0;
1438
+ const column = this.resolveETagColumn(entitySetName);
1439
+ this.cache.set(entitySetName, column ?? null);
1440
+ return column;
1441
+ }
1442
+ /**
1443
+ * Compute a weak ETag string from an entity's ETag column value.
1444
+ * Format: W/"<base64-encoded string value>"
1445
+ */
1446
+ computeETag(entity, etagColumn) {
1447
+ const value = entity[etagColumn];
1448
+ let stringValue;
1449
+ if (value instanceof Date) stringValue = value.toISOString();
1450
+ else stringValue = String(value);
1451
+ return `W/"${Buffer.from(stringValue).toString("base64")}"`;
1452
+ }
1453
+ /**
1454
+ * Validate an If-Match header value against the current entity.
1455
+ * Returns true if the ETag matches (safe to proceed with mutation).
1456
+ */
1457
+ validateIfMatch(ifMatchValue, entity, etagColumn) {
1458
+ const current = this.computeETag(entity, etagColumn);
1459
+ const normalize = (s) => s.trim().replace(/^W\//, "").replace(/^"(.*)"$/, "$1");
1460
+ return normalize(current) === normalize(ifMatchValue);
1461
+ }
1462
+ resolveETagColumn(entitySetName) {
1463
+ const entitySet = this.edmRegistry.getEntitySet(entitySetName);
1464
+ if (!entitySet) return void 0;
1465
+ const entityTypeName = entitySet.entityTypeName;
1466
+ const meta = this.dataSource.entityMetadatas.find((m) => m.name === entityTypeName);
1467
+ if (!meta) return void 0;
1468
+ const entityClass = meta.target;
1469
+ if (typeof entityClass !== "function") return void 0;
1470
+ const explicitProperty = (0, _nestjs_odata_core.getETagProperty)(entityClass);
1471
+ if (explicitProperty) return explicitProperty;
1472
+ const updateDateCol = meta.columns.find((c) => c.isUpdateDate);
1473
+ if (updateDateCol) return updateDateCol.propertyName;
1474
+ }
1475
+ };
1476
+ TypeOrmETagProvider = __decorate([(0, _nestjs_common.Injectable)(), __decorateMetadata("design:paramtypes", [Object, typeof (_ref$3 = typeof _nestjs_odata_core.EdmRegistry !== "undefined" && _nestjs_odata_core.EdmRegistry) === "function" ? _ref$3 : Object])], TypeOrmETagProvider);
1477
+ //#endregion
1478
+ //#region src/translator/search-provider.ts
1479
+ var _ref$2;
1480
+ let TypeOrmSearchProvider = class TypeOrmSearchProvider {
1481
+ constructor(dataSource, edmRegistry) {
1482
+ this.dataSource = dataSource;
1483
+ this.edmRegistry = edmRegistry;
1484
+ }
1485
+ /**
1486
+ * Build a parameterized WHERE condition from a parsed $search AST node.
1487
+ *
1488
+ * @param searchNode - Parsed $search expression
1489
+ * @param entitySetName - OData entity set name being searched
1490
+ * @param alias - TypeORM QueryBuilder alias for the entity table
1491
+ * @returns Condition string + named params, or null if unable to build
1492
+ */
1493
+ buildSearchCondition(searchNode, entitySetName, alias) {
1494
+ const entityClass = this.resolveEntityClass(entitySetName);
1495
+ const searchableFields = entityClass ? (0, _nestjs_odata_core.getSearchableProperties)(entityClass) : [];
1496
+ if (searchableFields.length === 0) throw new _nestjs_odata_core.ODataValidationError(`No searchable fields configured for entity set '${entitySetName}'. Use @ODataSearchable() decorator on entity properties.`, entitySetName, "$search");
1497
+ const params = {};
1498
+ let paramCounter = 0;
1499
+ const buildCondition = (node) => {
1500
+ if (node.kind === "SearchTerm") return this.buildTermCondition(node, searchableFields, alias, params, paramCounter++);
1501
+ return this.buildBinaryCondition(node, buildCondition);
1502
+ };
1503
+ return {
1504
+ condition: buildCondition(searchNode),
1505
+ params
1506
+ };
1507
+ }
1508
+ /**
1509
+ * Resolve the entity class from the EdmRegistry and DataSource metadata.
1510
+ */
1511
+ resolveEntityClass(entitySetName) {
1512
+ const entitySet = this.edmRegistry.getEntitySet(entitySetName);
1513
+ if (!entitySet) return null;
1514
+ const meta = this.dataSource.entityMetadatas.find((m) => m.name === entitySet.entityTypeName);
1515
+ if (!meta) return null;
1516
+ const entityClass = meta.target;
1517
+ if (typeof entityClass !== "function") return null;
1518
+ return entityClass;
1519
+ }
1520
+ /**
1521
+ * Build a single LIKE condition for a search term across all searchable fields.
1522
+ * Fields are OR'd together: (alias.f1 LIKE :p OR alias.f2 LIKE :p)
1523
+ * Negated terms are wrapped: NOT (...)
1524
+ */
1525
+ buildTermCondition(node, searchableFields, alias, params, index) {
1526
+ const paramName = `search_${index}`;
1527
+ params[paramName] = `%${this.escapeLike(node.value)}%`;
1528
+ const inner = `(${searchableFields.map((field) => `${alias}.${field} LIKE :${paramName}`).join(" OR ")})`;
1529
+ return node.negated ? `NOT ${inner}` : inner;
1530
+ }
1531
+ /**
1532
+ * Build a binary AND/OR condition combining two recursively-built conditions.
1533
+ */
1534
+ buildBinaryCondition(node, buildCondition) {
1535
+ const leftCond = buildCondition(node.left);
1536
+ const rightCond = buildCondition(node.right);
1537
+ return `(${leftCond} ${node.operator} ${rightCond})`;
1538
+ }
1539
+ /**
1540
+ * Escape LIKE special characters to prevent wildcard injection (T-11-04).
1541
+ * % -> \%, _ -> \_
1542
+ */
1543
+ escapeLike(value) {
1544
+ return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
1545
+ }
1546
+ };
1547
+ TypeOrmSearchProvider = __decorate([(0, _nestjs_common.Injectable)(), __decorateMetadata("design:paramtypes", [Object, typeof (_ref$2 = typeof _nestjs_odata_core.EdmRegistry !== "undefined" && _nestjs_odata_core.EdmRegistry) === "function" ? _ref$2 : Object])], TypeOrmSearchProvider);
1548
+ //#endregion
1549
+ //#region src/odata-typeorm.module.ts
1550
+ var _ref$1, _ref2$1, _ODataTypeOrmModule;
1551
+ /**
1552
+ * DI injection token for the array of TypeORM entity classes
1553
+ * registered via ODataTypeOrmModule.forFeature().
1554
+ */
1555
+ const TYPEORM_ODATA_ENTITIES = Symbol("TYPEORM_ODATA_ENTITIES");
1556
+ let TypeOrmEdmInitializer = class TypeOrmEdmInitializer {
1557
+ constructor(dataSource, edmRegistry, options, entityClasses) {
1558
+ this.dataSource = dataSource;
1559
+ this.edmRegistry = edmRegistry;
1560
+ this.options = options;
1561
+ this.entityClasses = entityClasses;
1562
+ }
1563
+ onModuleInit() {
1564
+ const deriver = new TypeOrmEdmDeriver(this.options.namespace ?? "Default", this.options.unmappedTypeStrategy ?? "skip");
1565
+ const metadatas = this.entityClasses.map((cls) => this.dataSource.getMetadata(cls));
1566
+ const configs = deriver.deriveEntityTypes(this.entityClasses, metadatas);
1567
+ for (const config of configs) {
1568
+ const namespace = this.options.namespace ?? "Default";
1569
+ const entityType = {
1570
+ name: config.entityTypeName,
1571
+ namespace,
1572
+ properties: config.properties,
1573
+ navigationProperties: config.navigationProperties,
1574
+ keyProperties: config.keyProperties,
1575
+ isReadOnly: config.isReadOnly
1576
+ };
1577
+ const entitySet = {
1578
+ name: config.entitySetName,
1579
+ entityTypeName: config.entityTypeName,
1580
+ namespace,
1581
+ isReadOnly: config.isReadOnly
1582
+ };
1583
+ this.edmRegistry.register(entityType, entitySet);
1584
+ }
1585
+ }
1586
+ };
1587
+ TypeOrmEdmInitializer = __decorate([
1588
+ (0, _nestjs_common.Injectable)(),
1589
+ __decorateParam(2, (0, _nestjs_common.Inject)(_nestjs_odata_core.ODATA_MODULE_OPTIONS)),
1590
+ __decorateParam(3, (0, _nestjs_common.Inject)(TYPEORM_ODATA_ENTITIES)),
1591
+ __decorateMetadata("design:paramtypes", [
1592
+ typeof (_ref$1 = typeof typeorm.DataSource !== "undefined" && typeorm.DataSource) === "function" ? _ref$1 : Object,
1593
+ typeof (_ref2$1 = typeof _nestjs_odata_core.EdmRegistry !== "undefined" && _nestjs_odata_core.EdmRegistry) === "function" ? _ref2$1 : Object,
1594
+ Object,
1595
+ Array
1596
+ ])
1597
+ ], TypeOrmEdmInitializer);
1598
+ let ODataTypeOrmModule = _ODataTypeOrmModule = class ODataTypeOrmModule {
1599
+ /**
1600
+ * Register TypeORM entity classes for OData auto-derivation.
1601
+ *
1602
+ * @param entities - Array of TypeORM entity classes (decorated with @Entity())
1603
+ * @returns DynamicModule that imports TypeOrmModule.forFeature, provides TYPEORM_ODATA_ENTITIES,
1604
+ * and wires TypeOrmEdmInitializer to populate the EdmRegistry at onModuleInit
1605
+ */
1606
+ static forFeature(entities, options) {
1607
+ const serviceRoot = options?.serviceRoot ?? _nestjs_odata_core.ODataModule.registeredServiceRoot ?? "odata";
1608
+ const root = serviceRoot.startsWith("/") ? serviceRoot.slice(1) : serviceRoot;
1609
+ Reflect.defineMetadata(_nestjs_common_constants_js.PATH_METADATA, root, BatchController);
1610
+ return {
1611
+ module: _ODataTypeOrmModule,
1612
+ imports: [_nestjs_typeorm.TypeOrmModule.forFeature(entities)],
1613
+ controllers: [BatchController],
1614
+ providers: [
1615
+ {
1616
+ provide: TYPEORM_ODATA_ENTITIES,
1617
+ useValue: entities
1618
+ },
1619
+ TypeOrmEdmInitializer,
1620
+ {
1621
+ provide: TypeOrmSearchProvider,
1622
+ useFactory: (dataSource, edmRegistry) => {
1623
+ return new TypeOrmSearchProvider(dataSource, edmRegistry);
1624
+ },
1625
+ inject: [typeorm.DataSource, _nestjs_odata_core.EdmRegistry]
1626
+ },
1627
+ {
1628
+ provide: _nestjs_odata_core.SEARCH_PROVIDER,
1629
+ useExisting: TypeOrmSearchProvider
1630
+ },
1631
+ {
1632
+ provide: TypeOrmQueryTranslator,
1633
+ useFactory: (dataSource, edmRegistry, options, searchProvider) => {
1634
+ const firstEntity = entities[0];
1635
+ if (!firstEntity) throw new Error("ODataTypeOrmModule.forFeature() requires at least one entity class");
1636
+ return new TypeOrmQueryTranslator(dataSource.getRepository(firstEntity), edmRegistry, options, searchProvider);
1637
+ },
1638
+ inject: [
1639
+ typeorm.DataSource,
1640
+ _nestjs_odata_core.EdmRegistry,
1641
+ _nestjs_odata_core.ODATA_MODULE_OPTIONS,
1642
+ {
1643
+ token: _nestjs_odata_core.SEARCH_PROVIDER,
1644
+ optional: true
1645
+ }
1646
+ ]
1647
+ },
1648
+ {
1649
+ provide: TypeOrmETagProvider,
1650
+ useFactory: (dataSource, edmRegistry) => {
1651
+ return new TypeOrmETagProvider(dataSource, edmRegistry);
1652
+ },
1653
+ inject: [typeorm.DataSource, _nestjs_odata_core.EdmRegistry]
1654
+ },
1655
+ {
1656
+ provide: _nestjs_odata_core.ETAG_PROVIDER,
1657
+ useExisting: TypeOrmETagProvider
1658
+ },
1659
+ {
1660
+ provide: TypeOrmAutoHandler,
1661
+ useFactory: (translator, edmRegistry, options, dataSource, etagProvider) => {
1662
+ const firstEntity = entities[0];
1663
+ if (!firstEntity) throw new Error("ODataTypeOrmModule.forFeature() requires at least one entity class");
1664
+ return new TypeOrmAutoHandler(translator, edmRegistry, options, dataSource.getRepository(firstEntity), etagProvider, dataSource);
1665
+ },
1666
+ inject: [
1667
+ TypeOrmQueryTranslator,
1668
+ _nestjs_odata_core.EdmRegistry,
1669
+ _nestjs_odata_core.ODATA_MODULE_OPTIONS,
1670
+ typeorm.DataSource,
1671
+ TypeOrmETagProvider
1672
+ ]
1673
+ }
1674
+ ],
1675
+ exports: [
1676
+ TYPEORM_ODATA_ENTITIES,
1677
+ TypeOrmQueryTranslator,
1678
+ TypeOrmAutoHandler,
1679
+ TypeOrmETagProvider,
1680
+ _nestjs_odata_core.ETAG_PROVIDER,
1681
+ TypeOrmSearchProvider,
1682
+ _nestjs_odata_core.SEARCH_PROVIDER
1683
+ ]
1684
+ };
1685
+ }
1686
+ };
1687
+ ODataTypeOrmModule = _ODataTypeOrmModule = __decorate([(0, _nestjs_common.Module)({})], ODataTypeOrmModule);
1688
+ //#endregion
1689
+ //#region src/batch/batch-controller.ts
1690
+ /**
1691
+ * OData v4 $batch endpoint controller.
1692
+ *
1693
+ * Accepts POST /$batch with a multipart/mixed body containing multiple
1694
+ * OData operations. Dispatches each sub-request to the appropriate CRUD
1695
+ * handler. Wraps changesets in TypeORM QueryRunner transactions for
1696
+ * atomicity (D-02, BATCH-02).
1697
+ *
1698
+ * Security:
1699
+ * T-05-01: Boundary validation delegated to parseBatchBody (throws 400).
1700
+ * T-05-02: MAX_BATCH_OPERATIONS limit enforced by parseBatchBody.
1701
+ * T-05-03: Sub-request URLs parsed via regex — never passed to shell/eval.
1702
+ * T-05-05: Error responses use OData error format, no stack traces.
1703
+ */
1704
+ var _ref, _ref2;
1705
+ /**
1706
+ * Internal error used to signal a failed changeset operation so the
1707
+ * QueryRunner transaction can be rolled back.
1708
+ * Carries the status code and serialized body from the failed operation.
1709
+ */
1710
+ var ChangesetOperationError = class extends Error {
1711
+ constructor(statusCode, body) {
1712
+ super(`Changeset operation failed with status ${statusCode}`);
1713
+ this.statusCode = statusCode;
1714
+ this.body = body;
1715
+ this.name = "ChangesetOperationError";
1716
+ Object.setPrototypeOf(this, new.target.prototype);
1717
+ }
1718
+ };
1719
+ /**
1720
+ * Build an OData error response body (T-05-05).
1721
+ * Never exposes stack traces or internal server details.
1722
+ */
1723
+ function buildODataError(code, message) {
1724
+ return JSON.stringify({ error: {
1725
+ code,
1726
+ message,
1727
+ details: []
1728
+ } });
1729
+ }
1730
+ /**
1731
+ * Map HTTP status to OData error code string.
1732
+ */
1733
+ function statusToCode(status) {
1734
+ return {
1735
+ 400: "BadRequest",
1736
+ 401: "Unauthorized",
1737
+ 403: "Forbidden",
1738
+ 404: "NotFound",
1739
+ 409: "Conflict",
1740
+ 500: "InternalServerError"
1741
+ }[status] ?? "Error";
1742
+ }
1743
+ /**
1744
+ * Parse entity set name and optional key from a sub-request URL.
1745
+ *
1746
+ * Strategy:
1747
+ * 1. Strip query string first.
1748
+ * 2. Try to match last segment with key: /EntitySet(key) at path end.
1749
+ * 3. Fall back to matching last path segment (no key).
1750
+ *
1751
+ * Per T-05-03: URL parsing is regex-based, never used for shell/eval.
1752
+ *
1753
+ * Returns null if the URL cannot be parsed.
1754
+ */
1755
+ function parseEntityUrl(url) {
1756
+ const pathOnly = url.split("?")[0];
1757
+ const withKeyMatch = /\/([A-Za-z][A-Za-z0-9_]*)\(([^)]*)\)$/.exec(pathOnly);
1758
+ if (withKeyMatch) return {
1759
+ entitySetName: withKeyMatch[1] ?? "",
1760
+ key: withKeyMatch[2]
1761
+ };
1762
+ const withoutKeyMatch = /\/([A-Za-z][A-Za-z0-9_]*)$/.exec(pathOnly);
1763
+ if (withoutKeyMatch) return {
1764
+ entitySetName: withoutKeyMatch[1] ?? "",
1765
+ key: void 0
1766
+ };
1767
+ return null;
1768
+ }
1769
+ let BatchController = class BatchController {
1770
+ constructor(dataSource, edmRegistry, options, entityClasses) {
1771
+ this.dataSource = dataSource;
1772
+ this.edmRegistry = edmRegistry;
1773
+ this.options = options;
1774
+ this.entityClasses = entityClasses;
1775
+ }
1776
+ /**
1777
+ * POST {serviceRoot}/$batch
1778
+ *
1779
+ * Reads raw body (must be pre-configured with body-parser for text/plain or rawBody),
1780
+ * parses multipart/mixed, dispatches sub-requests, builds multipart response.
1781
+ */
1782
+ async handleBatch(req, res) {
1783
+ let rawBody;
1784
+ try {
1785
+ rawBody = await this.extractRawBody(req);
1786
+ } catch (err) {
1787
+ res.status(_nestjs_common.HttpStatus.BAD_REQUEST).json({ error: {
1788
+ code: "BadRequest",
1789
+ message: String(err),
1790
+ details: []
1791
+ } });
1792
+ return;
1793
+ }
1794
+ const contentTypeHeader = req.headers["content-type"];
1795
+ const contentType = (Array.isArray(contentTypeHeader) ? contentTypeHeader[0] : contentTypeHeader) ?? "";
1796
+ let boundary;
1797
+ try {
1798
+ boundary = (0, _nestjs_odata_core.extractBoundary)(contentType);
1799
+ } catch {
1800
+ res.status(_nestjs_common.HttpStatus.BAD_REQUEST).json({ error: {
1801
+ code: "BadRequest",
1802
+ message: "Missing or invalid boundary in Content-Type for $batch request",
1803
+ details: []
1804
+ } });
1805
+ return;
1806
+ }
1807
+ let parsed;
1808
+ try {
1809
+ parsed = (0, _nestjs_odata_core.parseBatchBody)(rawBody, boundary);
1810
+ } catch (err) {
1811
+ const msg = err instanceof Error ? err.message : "Malformed batch body";
1812
+ res.status(_nestjs_common.HttpStatus.BAD_REQUEST).json({ error: {
1813
+ code: "BadRequest",
1814
+ message: msg,
1815
+ details: []
1816
+ } });
1817
+ return;
1818
+ }
1819
+ const responseParts = [];
1820
+ for (const part of parsed.parts) if (part.kind === "request") {
1821
+ const responsePart = await this.dispatchIndividualRequest(part);
1822
+ responseParts.push(responsePart);
1823
+ } else if (part.kind === "changeset") {
1824
+ const changesetParts = await this.executeChangeset(part.parts);
1825
+ responseParts.push(...changesetParts);
1826
+ }
1827
+ const { contentType: responseContentType, body } = buildBatchResponse(responseParts);
1828
+ res.status(_nestjs_common.HttpStatus.OK).set("Content-Type", responseContentType).send(body);
1829
+ }
1830
+ /**
1831
+ * Extract the raw body string from the request.
1832
+ *
1833
+ * Tries multiple strategies in order:
1834
+ * 1. req.body as string (if a text/plain body parser ran first)
1835
+ * 2. req.rawBody Buffer (NestJS rawBody: true option)
1836
+ * 3. Read directly from the Node.js request stream (multipart/mixed fallback)
1837
+ * This works because $batch sends multipart/mixed which no standard body
1838
+ * parser processes, leaving the stream unconsumed.
1839
+ */
1840
+ extractRawBody(req) {
1841
+ if (typeof req.body === "string" && req.body.length > 0) return req.body;
1842
+ const rawBodyProp = req.rawBody;
1843
+ if (rawBodyProp !== void 0) return typeof rawBodyProp === "string" ? rawBodyProp : rawBodyProp.toString("utf-8");
1844
+ return new Promise((resolve, reject) => {
1845
+ const chunks = [];
1846
+ req.on("data", (chunk) => chunks.push(chunk));
1847
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
1848
+ req.on("error", reject);
1849
+ });
1850
+ }
1851
+ /**
1852
+ * Dispatch a single individual request (outside a changeset).
1853
+ * Failures are caught and returned as per-operation error responses (BATCH-03).
1854
+ */
1855
+ async dispatchIndividualRequest(part) {
1856
+ try {
1857
+ return await this.dispatchWithManager(part, this.dataSource.manager);
1858
+ } catch (err) {
1859
+ return this.buildErrorResponse(err, part.contentId);
1860
+ }
1861
+ }
1862
+ /**
1863
+ * Execute all operations in a changeset within a single QueryRunner transaction.
1864
+ * If any operation fails, all are rolled back (BATCH-02).
1865
+ *
1866
+ * Per D-02: full changeset rollback on any failure.
1867
+ * Per WRITE-03 / T-10-08: contentIdMap is local to each changeset call — never leaks across changesets.
1868
+ */
1869
+ async executeChangeset(parts) {
1870
+ const queryRunner = this.dataSource.createQueryRunner();
1871
+ await queryRunner.connect();
1872
+ await queryRunner.startTransaction();
1873
+ const contentIdMap = /* @__PURE__ */ new Map();
1874
+ try {
1875
+ const results = [];
1876
+ for (const part of parts) {
1877
+ const resolvedPart = this.resolveContentIdReferences(part, contentIdMap);
1878
+ const result = await this.dispatchWithManager(resolvedPart, queryRunner.manager);
1879
+ results.push(result);
1880
+ if (result.statusCode >= 400) throw new ChangesetOperationError(result.statusCode, result.body ?? "Operation failed");
1881
+ const location = result.headers["location"];
1882
+ if (result.statusCode === 201 && location && part.contentId !== void 0) contentIdMap.set(part.contentId, location);
1883
+ }
1884
+ await queryRunner.commitTransaction();
1885
+ return results;
1886
+ } catch (err) {
1887
+ await queryRunner.rollbackTransaction();
1888
+ const errorResponse = err instanceof ChangesetOperationError ? {
1889
+ statusCode: err.statusCode,
1890
+ headers: {},
1891
+ body: err.body
1892
+ } : this.buildErrorResponse(err);
1893
+ return parts.map((p) => ({
1894
+ ...errorResponse,
1895
+ contentId: p.contentId
1896
+ }));
1897
+ } finally {
1898
+ await queryRunner.release();
1899
+ }
1900
+ }
1901
+ /**
1902
+ * Resolve Content-ID references ($N) in a BatchRequestPart's URL and body.
1903
+ *
1904
+ * Per WRITE-03: $N in a URL or body is substituted with the location URL recorded
1905
+ * for content ID N from a prior 201 response in the same changeset.
1906
+ *
1907
+ * Fast path: returns the original part unchanged when contentIdMap is empty or
1908
+ * no $N patterns match (avoids unnecessary object allocation).
1909
+ *
1910
+ * Per immutability rules: never mutates the readonly BatchRequestPart — returns a new object.
1911
+ * Per T-10-07: substitution uses only URLs already in contentIdMap (populated by our own
1912
+ * 201 responses) — no code evaluation, no cross-changeset leak.
1913
+ */
1914
+ resolveContentIdReferences(part, contentIdMap) {
1915
+ if (contentIdMap.size === 0) return part;
1916
+ let url = part.url;
1917
+ let body = part.body;
1918
+ for (const [id, resolvedUrl] of contentIdMap) {
1919
+ const urlPattern = new RegExp(`\\$${id}(?=[/?#]|$)`, "g");
1920
+ url = url.replace(urlPattern, resolvedUrl);
1921
+ if (body) {
1922
+ const bodyPattern = new RegExp(`\\$${id}(?=\\D|$)`, "g");
1923
+ body = body.replace(bodyPattern, resolvedUrl);
1924
+ }
1925
+ }
1926
+ if (url === part.url && body === part.body) return part;
1927
+ return {
1928
+ ...part,
1929
+ url,
1930
+ ...body !== part.body ? { body } : {}
1931
+ };
1932
+ }
1933
+ /**
1934
+ * Dispatch a parsed sub-request to the appropriate handler using the given EntityManager.
1935
+ * Uses the EntityManager so changesets can use a transaction-scoped manager.
1936
+ *
1937
+ * Per T-05-03: URL parsing is regex-based, entity operations go through TypeORM parameterized queries.
1938
+ */
1939
+ async dispatchWithManager(part, manager) {
1940
+ const parsed = parseEntityUrl(part.url);
1941
+ if (!parsed) return {
1942
+ contentId: part.contentId,
1943
+ statusCode: 400,
1944
+ headers: {},
1945
+ body: buildODataError("BadRequest", `Cannot parse entity URL: ${part.url}`)
1946
+ };
1947
+ const { entitySetName, key } = parsed;
1948
+ const entitySet = this.edmRegistry.getEntitySet(entitySetName);
1949
+ if (!entitySet) return {
1950
+ contentId: part.contentId,
1951
+ statusCode: 404,
1952
+ headers: {},
1953
+ body: buildODataError("NotFound", `Entity set '${entitySetName}' not found`)
1954
+ };
1955
+ const entityType = this.edmRegistry.getEntityType(entitySet.entityTypeName);
1956
+ if (!entityType) return {
1957
+ contentId: part.contentId,
1958
+ statusCode: 404,
1959
+ headers: {},
1960
+ body: buildODataError("NotFound", `Entity type '${entitySet.entityTypeName}' not found`)
1961
+ };
1962
+ const entityClass = this.resolveEntityClass(entityType.name);
1963
+ if (!entityClass) return {
1964
+ contentId: part.contentId,
1965
+ statusCode: 500,
1966
+ headers: {},
1967
+ body: buildODataError("InternalServerError", `Entity class not found for '${entityType.name}'`)
1968
+ };
1969
+ const method = part.method.toUpperCase();
1970
+ try {
1971
+ if (method === "GET") {
1972
+ if (key !== void 0) return await this.dispatchGetByKey(key, entitySetName, entityType.keyProperties, entityClass, manager, part.contentId);
1973
+ return await this.dispatchGetCollection(entityClass, manager, part.contentId);
1974
+ } else if (method === "POST") return await this.dispatchCreate(part, entitySetName, entityType.keyProperties, entityClass, manager);
1975
+ else if (method === "PATCH") {
1976
+ if (key === void 0) return {
1977
+ contentId: part.contentId,
1978
+ statusCode: 400,
1979
+ headers: {},
1980
+ body: buildODataError("BadRequest", `PATCH requires a key: ${part.url}`)
1981
+ };
1982
+ return await this.dispatchUpdate(key, part, entitySetName, entityType.keyProperties, entityClass, manager);
1983
+ } else if (method === "PUT") {
1984
+ if (key === void 0) return {
1985
+ contentId: part.contentId,
1986
+ statusCode: 400,
1987
+ headers: {},
1988
+ body: buildODataError("BadRequest", `PUT requires a key: ${part.url}`)
1989
+ };
1990
+ return await this.dispatchReplace(key, part, entitySetName, entityType, entityClass, manager);
1991
+ } else if (method === "DELETE") {
1992
+ if (key === void 0) return {
1993
+ contentId: part.contentId,
1994
+ statusCode: 400,
1995
+ headers: {},
1996
+ body: buildODataError("BadRequest", `DELETE requires a key: ${part.url}`)
1997
+ };
1998
+ return await this.dispatchDelete(key, entitySetName, entityType.keyProperties, entityClass, manager, part.contentId);
1999
+ }
2000
+ return {
2001
+ contentId: part.contentId,
2002
+ statusCode: 405,
2003
+ headers: {},
2004
+ body: buildODataError("MethodNotAllowed", `Method '${method}' not supported in $batch`)
2005
+ };
2006
+ } catch (err) {
2007
+ return this.buildErrorResponse(err, part.contentId);
2008
+ }
2009
+ }
2010
+ /** GET /EntitySet(key) */
2011
+ async dispatchGetByKey(keyStr, entitySetName, keyProperties, entityClass, manager, contentId) {
2012
+ const where = (0, _nestjs_odata_core.parseODataKey)(keyStr, keyProperties);
2013
+ const entity = await manager.findOne(entityClass, { where });
2014
+ if (!entity) return {
2015
+ contentId,
2016
+ statusCode: 404,
2017
+ headers: {},
2018
+ body: buildODataError("NotFound", `Entity '${entitySetName}' with key '${keyStr}' not found`)
2019
+ };
2020
+ return {
2021
+ contentId,
2022
+ statusCode: 200,
2023
+ headers: {},
2024
+ body: JSON.stringify(entity)
2025
+ };
2026
+ }
2027
+ /** GET /EntitySet (collection — basic support) */
2028
+ async dispatchGetCollection(entityClass, manager, contentId) {
2029
+ const items = await manager.find(entityClass);
2030
+ return {
2031
+ contentId,
2032
+ statusCode: 200,
2033
+ headers: {},
2034
+ body: JSON.stringify({ value: items })
2035
+ };
2036
+ }
2037
+ /** POST /EntitySet */
2038
+ async dispatchCreate(part, entitySetName, keyProperties, entityClass, manager) {
2039
+ const body = part.body ? JSON.parse(part.body) : {};
2040
+ const repo = manager.getRepository(entityClass);
2041
+ const created = repo.create(body);
2042
+ const saved = await repo.save(created);
2043
+ const keyStr = keyProperties.map((kp) => {
2044
+ const val = saved[kp];
2045
+ return keyProperties.length === 1 ? String(val) : `${kp}=${String(val)}`;
2046
+ }).join(",");
2047
+ const locationUrl = `${this.options.serviceRoot}/${entitySetName}(${keyStr})`;
2048
+ return {
2049
+ contentId: part.contentId,
2050
+ statusCode: 201,
2051
+ headers: { location: locationUrl },
2052
+ body: JSON.stringify(saved)
2053
+ };
2054
+ }
2055
+ /** PATCH /EntitySet(key) */
2056
+ async dispatchUpdate(keyStr, part, entitySetName, keyProperties, entityClass, manager) {
2057
+ const body = part.body ? JSON.parse(part.body) : {};
2058
+ const where = (0, _nestjs_odata_core.parseODataKey)(keyStr, keyProperties);
2059
+ const EntityCls = entityClass;
2060
+ const existing = await manager.findOne(EntityCls, { where });
2061
+ if (!existing) return {
2062
+ contentId: part.contentId,
2063
+ statusCode: 404,
2064
+ headers: {},
2065
+ body: buildODataError("NotFound", `Entity '${entitySetName}' with key '${keyStr}' not found`)
2066
+ };
2067
+ const merged = manager.merge(EntityCls, existing, body);
2068
+ const saved = await manager.save(EntityCls, merged);
2069
+ return {
2070
+ contentId: part.contentId,
2071
+ statusCode: 200,
2072
+ headers: {},
2073
+ body: JSON.stringify(saved)
2074
+ };
2075
+ }
2076
+ /**
2077
+ * PUT /EntitySet(key) — full entity replacement.
2078
+ *
2079
+ * Per OData v4 spec (D-01): all entity properties replaced.
2080
+ * Unspecified fields reset to column defaults (null for nullable, default value otherwise).
2081
+ * Distinct from PATCH merge semantics used by dispatchUpdate().
2082
+ *
2083
+ * Uses repo.metadata.columns for column introspection — same pattern as handleReplace().
2084
+ */
2085
+ async dispatchReplace(keyStr, part, entitySetName, entityType, entityClass, manager) {
2086
+ const body = part.body ? JSON.parse(part.body) : {};
2087
+ const where = (0, _nestjs_odata_core.parseODataKey)(keyStr, entityType.keyProperties);
2088
+ const EntityCls = entityClass;
2089
+ for (const kp of entityType.keyProperties) {
2090
+ const bodyKeyValue = body[kp];
2091
+ const urlKeyValue = where[kp];
2092
+ if (bodyKeyValue !== void 0 && bodyKeyValue !== urlKeyValue) return {
2093
+ contentId: part.contentId,
2094
+ statusCode: 400,
2095
+ headers: {},
2096
+ body: buildODataError("BadRequest", "Key in body does not match URL key")
2097
+ };
2098
+ }
2099
+ if (!await manager.findOne(EntityCls, { where })) return {
2100
+ contentId: part.contentId,
2101
+ statusCode: 404,
2102
+ headers: {},
2103
+ body: buildODataError("NotFound", `Entity '${entitySetName}' with key '${keyStr}' not found`)
2104
+ };
2105
+ const meta = this.dataSource.getMetadata(entityClass);
2106
+ const replacement = {};
2107
+ for (const kp of entityType.keyProperties) replacement[kp] = where[kp];
2108
+ for (const col of meta.columns) {
2109
+ if (col.isPrimary) continue;
2110
+ if (col.isCreateDate || col.isUpdateDate || col.isVersion) continue;
2111
+ const propName = col.propertyName;
2112
+ if (Object.prototype.hasOwnProperty.call(body, propName)) replacement[propName] = body[propName];
2113
+ else if (col.default !== void 0) replacement[propName] = col.default;
2114
+ else if (col.isNullable) replacement[propName] = null;
2115
+ }
2116
+ const entity = manager.getRepository(EntityCls).create(replacement);
2117
+ const saved = await manager.save(EntityCls, entity);
2118
+ return {
2119
+ contentId: part.contentId,
2120
+ statusCode: 200,
2121
+ headers: {},
2122
+ body: JSON.stringify(saved)
2123
+ };
2124
+ }
2125
+ /** DELETE /EntitySet(key) */
2126
+ async dispatchDelete(keyStr, entitySetName, keyProperties, entityClass, manager, contentId) {
2127
+ const where = (0, _nestjs_odata_core.parseODataKey)(keyStr, keyProperties);
2128
+ if ((await manager.delete(entityClass, where)).affected === 0) return {
2129
+ contentId,
2130
+ statusCode: 404,
2131
+ headers: {},
2132
+ body: buildODataError("NotFound", `Entity '${entitySetName}' with key '${keyStr}' not found`)
2133
+ };
2134
+ return {
2135
+ contentId,
2136
+ statusCode: 204,
2137
+ headers: {},
2138
+ body: void 0
2139
+ };
2140
+ }
2141
+ /**
2142
+ * Build an OData error response part from an exception.
2143
+ * Never exposes stack traces (T-05-05).
2144
+ */
2145
+ buildErrorResponse(err, contentId) {
2146
+ if (err instanceof Error) {
2147
+ if (err.constructor.name === "NotFoundException" || err.status === 404) return {
2148
+ contentId,
2149
+ statusCode: 404,
2150
+ headers: {},
2151
+ body: buildODataError("NotFound", err.message)
2152
+ };
2153
+ if (err.constructor.name === "ODataValidationError" || err.constructor.name === "ODataParseError" || err.status === 400) return {
2154
+ contentId,
2155
+ statusCode: 400,
2156
+ headers: {},
2157
+ body: buildODataError("BadRequest", err.message)
2158
+ };
2159
+ }
2160
+ const status = err?.status;
2161
+ if (typeof status === "number" && status >= 400 && status < 500) return {
2162
+ contentId,
2163
+ statusCode: status,
2164
+ headers: {},
2165
+ body: buildODataError(statusToCode(status), err.message ?? "Request error")
2166
+ };
2167
+ return {
2168
+ contentId,
2169
+ statusCode: 500,
2170
+ headers: {},
2171
+ body: buildODataError("InternalServerError", "An unexpected error occurred.")
2172
+ };
2173
+ }
2174
+ /**
2175
+ * Resolve a TypeORM entity class from an EDM entity type name.
2176
+ * Matches by checking DataSource metadata for each registered entity class.
2177
+ */
2178
+ resolveEntityClass(entityTypeName) {
2179
+ for (const cls of this.entityClasses) try {
2180
+ if (this.dataSource.getMetadata(cls).name === entityTypeName) return cls;
2181
+ } catch {}
2182
+ }
2183
+ };
2184
+ __decorate([
2185
+ (0, _nestjs_common.Post)("$batch"),
2186
+ __decorateParam(0, (0, _nestjs_common.Req)()),
2187
+ __decorateParam(1, (0, _nestjs_common.Res)()),
2188
+ __decorateMetadata("design:type", Function),
2189
+ __decorateMetadata("design:paramtypes", [Object, Object]),
2190
+ __decorateMetadata("design:returntype", Promise)
2191
+ ], BatchController.prototype, "handleBatch", null);
2192
+ BatchController = __decorate([
2193
+ (0, _nestjs_common.Controller)(),
2194
+ __decorateParam(2, (0, _nestjs_common.Inject)(_nestjs_odata_core.ODATA_MODULE_OPTIONS)),
2195
+ __decorateParam(3, (0, _nestjs_common.Inject)(TYPEORM_ODATA_ENTITIES)),
2196
+ __decorateMetadata("design:paramtypes", [
2197
+ typeof (_ref = typeof typeorm.DataSource !== "undefined" && typeorm.DataSource) === "function" ? _ref : Object,
2198
+ typeof (_ref2 = typeof _nestjs_odata_core.EdmRegistry !== "undefined" && _nestjs_odata_core.EdmRegistry) === "function" ? _ref2 : Object,
2199
+ Object,
2200
+ Array
2201
+ ])
2202
+ ], BatchController);
2203
+ //#endregion
2204
+ //#region src/index.ts
2205
+ const VERSION = "0.0.1";
2206
+ //#endregion
2207
+ Object.defineProperty(exports, "BatchController", {
2208
+ enumerable: true,
2209
+ get: function() {
2210
+ return BatchController;
2211
+ }
2212
+ });
2213
+ Object.defineProperty(exports, "ODataTypeOrmModule", {
2214
+ enumerable: true,
2215
+ get: function() {
2216
+ return ODataTypeOrmModule;
2217
+ }
2218
+ });
2219
+ exports.TYPEORM_ODATA_ENTITIES = TYPEORM_ODATA_ENTITIES;
2220
+ exports.TypeOrmApplyVisitor = TypeOrmApplyVisitor;
2221
+ Object.defineProperty(exports, "TypeOrmAutoHandler", {
2222
+ enumerable: true,
2223
+ get: function() {
2224
+ return TypeOrmAutoHandler;
2225
+ }
2226
+ });
2227
+ Object.defineProperty(exports, "TypeOrmETagProvider", {
2228
+ enumerable: true,
2229
+ get: function() {
2230
+ return TypeOrmETagProvider;
2231
+ }
2232
+ });
2233
+ exports.TypeOrmEdmDeriver = TypeOrmEdmDeriver;
2234
+ Object.defineProperty(exports, "TypeOrmEdmInitializer", {
2235
+ enumerable: true,
2236
+ get: function() {
2237
+ return TypeOrmEdmInitializer;
2238
+ }
2239
+ });
2240
+ exports.TypeOrmFilterVisitor = TypeOrmFilterVisitor;
2241
+ exports.TypeOrmOrderByVisitor = TypeOrmOrderByVisitor;
2242
+ exports.TypeOrmPaginationVisitor = TypeOrmPaginationVisitor;
2243
+ Object.defineProperty(exports, "TypeOrmQueryTranslator", {
2244
+ enumerable: true,
2245
+ get: function() {
2246
+ return TypeOrmQueryTranslator;
2247
+ }
2248
+ });
2249
+ Object.defineProperty(exports, "TypeOrmSearchProvider", {
2250
+ enumerable: true,
2251
+ get: function() {
2252
+ return TypeOrmSearchProvider;
2253
+ }
2254
+ });
2255
+ exports.TypeOrmSelectVisitor = TypeOrmSelectVisitor;
2256
+ exports.VERSION = VERSION;
2257
+ exports.applyExpandPagination = applyExpandPagination;
2258
+ exports.buildBatchResponse = buildBatchResponse;