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