@pristine-ts/data-mapping 2.0.10 → 2.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.md CHANGED
@@ -1 +1,345 @@
1
- # Data Mapping module.
1
+ # @pristine-ts/data-mapping
2
+
3
+ Type-aware object mapping for [Pristine](https://github.com/magieno/pristine-ts). Convert
4
+ plain payloads (HTTP request bodies, database rows, queue messages) into typed class
5
+ instances — with renaming, coercion, validation, and nested-object support.
6
+
7
+ This package is the DI-wired wrapper. The framework-agnostic core lives in
8
+ [`@pristine-ts/data-mapping-common`](../data-mapping-common) and can be used standalone
9
+ in frontend bundles without pulling the DI container in.
10
+
11
+ ## When to use
12
+
13
+ - HTTP request body → DTO class (used internally by `BodyMappingRequestInterceptor`)
14
+ - Database rows → entity classes (used by `@pristine-ts/mysql` and `@pristine-ts/aws`)
15
+ - Queue messages, CLI args, file contents → typed models
16
+ - Anywhere you have a plain object and want a typed one, with field renames and per-field coercion
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ npm install @pristine-ts/data-mapping
22
+ ```
23
+
24
+ Import the module in your root `AppModule`:
25
+
26
+ ```ts
27
+ import {DataMappingModule} from "@pristine-ts/data-mapping";
28
+
29
+ export const AppModule: AppModuleInterface = {
30
+ keyname: "app",
31
+ importModules: [DataMappingModule, /* ... */],
32
+ };
33
+ ```
34
+
35
+ Then inject `DataMapper` anywhere:
36
+
37
+ ```ts
38
+ import {injectable} from "tsyringe";
39
+ import {DataMapper} from "@pristine-ts/data-mapping";
40
+
41
+ @injectable()
42
+ export class UserService {
43
+ constructor(private readonly dataMapper: DataMapper) {}
44
+ }
45
+ ```
46
+
47
+ ## Three ways to define a mapping
48
+
49
+ ### 1. `autoMap` — by reflection on the destination class (the common case)
50
+
51
+ If your source object's field names already line up with your destination class, just hand
52
+ both to `autoMap`. The schema is inferred from `@property` / `@array` decorators on the
53
+ destination, cached per-class, and reused on subsequent calls.
54
+
55
+ ```ts
56
+ import {classMetadata, property} from "@pristine-ts/metadata";
57
+ import {array} from "@pristine-ts/data-mapping";
58
+
59
+ @classMetadata()
60
+ class User {
61
+ @property() id: string;
62
+ @property() email: string;
63
+ @property() age: number;
64
+ @property() createdAt: Date;
65
+ @array(String) tags: string[] = [];
66
+ }
67
+
68
+ const user = await this.dataMapper.autoMap(
69
+ {id: "u1", email: "x@example.com", age: "37", createdAt: "2026-01-01", tags: ["a", "b"]},
70
+ User,
71
+ );
72
+
73
+ user instanceof User; // true
74
+ typeof user.age; // "number" — coerced from "37"
75
+ user.createdAt instanceof Date; // true — coerced from string
76
+ ```
77
+
78
+ `autoMap` also accepts an array as input and returns an array. Pass a `PrimitiveType` value
79
+ (`PrimitiveType.String | Number | Boolean | Date`) instead of a class to coerce a single
80
+ value:
81
+
82
+ ```ts
83
+ import {PrimitiveType} from "@pristine-ts/data-mapping";
84
+
85
+ await dataMapper.autoMap("2026-01-01", PrimitiveType.Date); // Date instance
86
+ await dataMapper.autoMap("42", PrimitiveType.Number); // 42
87
+ ```
88
+
89
+ ### 2. The fluent builder — when you need rename or per-field config
90
+
91
+ When source field names don't match destination, or you need per-field normalizers, build an
92
+ explicit schema with `DataMappingBuilder`:
93
+
94
+ ```ts
95
+ import {DataMappingBuilder, DataMapper, LowercaseNormalizerUniqueKey} from "@pristine-ts/data-mapping";
96
+
97
+ const builder = new DataMappingBuilder()
98
+ .field("title", "name", {normalizers: [LowercaseNormalizerUniqueKey]})
99
+ .field("status") // single arg = no rename
100
+ .nested("address", "address", a => { // single nested object
101
+ a.field("street_name", "street")
102
+ .field("zip", "postalCode");
103
+ })
104
+ .arrayOfObjects("items", "products", item => { // array of objects
105
+ item.field("sku", "sku")
106
+ .field("qty", "quantity");
107
+ })
108
+ .arrayOfScalars("tag_list", "tags"); // array of primitives
109
+
110
+ const result = await dataMapper.map(builder, sourceObject, DestinationClass);
111
+ ```
112
+
113
+ The fluent API is sugar over a lower-level chain (`.add().setSourceProperty().setDestinationProperty().end()`)
114
+ which is still available — see the source for details.
115
+
116
+ ### 3. `@bodyMapping` decorator (in HTTP controllers)
117
+
118
+ For HTTP controllers, `@pristine-ts/networking` ships a `@bodyMapping` decorator that runs
119
+ `DataMapper` automatically on the request body before your handler is called:
120
+
121
+ ```ts
122
+ import {bodyMapping, controller, route, body} from "@pristine-ts/networking";
123
+
124
+ @controller("/users")
125
+ export class UserController {
126
+ @route(HttpMethod.Post, "")
127
+ @bodyMapping(User)
128
+ async create(@body() user: User) {
129
+ user instanceof User; // true
130
+ }
131
+ }
132
+ ```
133
+
134
+ `@bodyMapping` accepts a class (uses `autoMap`), a `DataMappingBuilder`, or a function for
135
+ custom logic.
136
+
137
+ ## Built-in normalizers
138
+
139
+ The module registers five built-in normalizers, keyed under `"DataNormalizerInterface"` and
140
+ available via `resolveAll`:
141
+
142
+ | Key | Class | Coerces to |
143
+ |---|---|---|
144
+ | `PRISTINE_STRING_NORMALIZER` | `StringNormalizer` | `string` (handles numbers, booleans, dates, objects) |
145
+ | `PRISTINE_NUMBER_NORMALIZER` | `NumberNormalizer` | `number` (parses numeric strings) |
146
+ | `PRISTINE_BOOLEAN_NORMALIZER` | `BooleanNormalizer` | `boolean` (`"true"`/`"1"`/`1` → true) |
147
+ | `PRISTINE_DATE_NORMALIZER` | `DateNormalizer` | `Date` (parses ISO strings, ms/sec timestamps, `{year, month, day}` objects) |
148
+ | `PRISTINE_LOWERCASE_NORMALIZER` | `LowercaseNormalizer` | lowercased `string` |
149
+
150
+ `autoMap` wires the first four automatically based on `@property` types. `LowercaseNormalizer`
151
+ is opt-in per field.
152
+
153
+ ### Writing your own
154
+
155
+ Implement `DataNormalizerInterface`, then register it under the `"DataNormalizerInterface"`
156
+ tag — Pristine's `@tag` decorator handles registration:
157
+
158
+ ```ts
159
+ import {injectable} from "tsyringe";
160
+ import {tag} from "@pristine-ts/common";
161
+ import {DataNormalizerInterface, DataNormalizerUniqueKey} from "@pristine-ts/data-mapping";
162
+
163
+ export const TrimNormalizerUniqueKey = "MY_APP_TRIM_NORMALIZER";
164
+
165
+ @tag("DataNormalizerInterface")
166
+ @injectable()
167
+ export class TrimNormalizer implements DataNormalizerInterface<string, undefined> {
168
+ getUniqueKey(): DataNormalizerUniqueKey { return TrimNormalizerUniqueKey; }
169
+ normalize(source: any): string {
170
+ return typeof source === "string" ? source.trim() : source;
171
+ }
172
+ }
173
+ ```
174
+
175
+ Then use the key in a builder:
176
+
177
+ ```ts
178
+ builder.field("name", "name", {normalizers: [TrimNormalizerUniqueKey]});
179
+ ```
180
+
181
+ ## Interceptors
182
+
183
+ Run logic before / after a whole mapping (not per-field). Useful for combining fields,
184
+ adding computed values, or post-processing the result.
185
+
186
+ ```ts
187
+ import {tag} from "@pristine-ts/common";
188
+ import {injectable} from "tsyringe";
189
+ import {DataMappingInterceptorInterface, DataMappingInterceptorUniqueKeyType} from "@pristine-ts/data-mapping";
190
+
191
+ @tag("DataMappingInterceptorInterface")
192
+ @injectable()
193
+ export class CombineNamesInterceptor implements DataMappingInterceptorInterface {
194
+ getUniqueKey(): DataMappingInterceptorUniqueKeyType { return "combine_names"; }
195
+
196
+ async beforeMapping(row: any): Promise<any> {
197
+ return {...row, fullName: `${row.firstName} ${row.lastName}`};
198
+ }
199
+
200
+ async afterMapping(row: any): Promise<any> {
201
+ return row;
202
+ }
203
+ }
204
+ ```
205
+
206
+ Wire it into a builder by key:
207
+
208
+ ```ts
209
+ new DataMappingBuilder()
210
+ .addBeforeMappingInterceptor("combine_names")
211
+ .field("fullName", "name");
212
+ ```
213
+
214
+ The framework applies before-interceptors in registration order, then maps fields, then
215
+ runs after-interceptors. Each receives the optional `options` payload you passed to
216
+ `addBeforeMappingInterceptor` / `addAfterMappingInterceptor`.
217
+
218
+ ## Schema cache
219
+
220
+ `AutoDataMappingBuilder` caches the schema it infers for each destination class in a
221
+ `WeakMap`. Repeated `autoMap` calls against the same class reuse the cached schema instead
222
+ of rewalking metadata every time.
223
+
224
+ **Measured impact** (benchmark in `auto-data-mapping.builder.cache-benchmark.spec.ts`):
225
+
226
+ | Workload | Speed-up | Notes |
227
+ |---|---|---|
228
+ | Schema-build step alone | ~80× | Upper bound — the only thing the cache saves |
229
+ | Full `autoMap` on a typical REST body (~10 fields, 1 nested object) | ~1.6× | The hot path for `BodyMappingRequestInterceptor` |
230
+ | Full `autoMap` on a deep+wide schema (6 levels, arrays at every level) | ~1.03× | Map cost dominates; cache barely matters here |
231
+ | Memory cost | ~12 KB per cached class | `WeakMap` — dropped classes are reclaimed |
232
+
233
+ Bypass the cache when source-shape inference must vary per call (e.g. an untyped scalar
234
+ array where the element type is inferred from `source[0]`):
235
+
236
+ ```ts
237
+ import {AutoDataMappingBuilderOptions} from "@pristine-ts/data-mapping";
238
+
239
+ await dataMapper.autoMap(source, DestClass, new AutoDataMappingBuilderOptions({disableCache: true}));
240
+ ```
241
+
242
+ ## Error reporting
243
+
244
+ `autoMap` catches errors during inference / mapping and (by default) returns the source
245
+ unchanged. Behavior is controlled by two options:
246
+
247
+ ```ts
248
+ await dataMapper.autoMap(source, DestClass, new AutoDataMappingBuilderOptions({
249
+ throwOnErrors: true, // re-throw instead of swallowing; default false
250
+ logErrors: true, // emit a report; default false
251
+ }));
252
+ ```
253
+
254
+ When `logErrors: true` is set, reports go to the error reporter wired into the `DataMapper`:
255
+
256
+ - Inside the framework (this module), reports route to `LogHandlerInterface.error(...)`,
257
+ flowing through `LogStore`, Sentry, etc.
258
+ - Outside the framework (frontend usage, see below), the default reporter writes to
259
+ `console.error` with a `[DataMapper]` prefix.
260
+
261
+ To use a custom sink, construct `DataMapper` manually with your own
262
+ `DataMapperErrorReporter`:
263
+
264
+ ```ts
265
+ import {DataMapper, ConsoleErrorReporter} from "@pristine-ts/data-mapping";
266
+
267
+ const dataMapper = new DataMapper(
268
+ autoBuilder, normalizers, interceptors,
269
+ (error, context) => {
270
+ sendToSentry(error);
271
+ ConsoleErrorReporter.report(error, context); // compose with the built-in
272
+ },
273
+ );
274
+ ```
275
+
276
+ Pass `() => {}` to silence reports entirely even when `logErrors: true`.
277
+
278
+ ## Frontend usage (`@pristine-ts/data-mapping-common`)
279
+
280
+ The `DataMapper` class lives in `@pristine-ts/data-mapping-common`, which has no DI or
281
+ logging dependency. Use it directly in Angular / browser bundles:
282
+
283
+ ```sh
284
+ npm install @pristine-ts/data-mapping-common class-transformer
285
+ ```
286
+
287
+ ```ts
288
+ import {
289
+ DataMapper, AutoDataMappingBuilder,
290
+ StringNormalizer, NumberNormalizer, BooleanNormalizer, DateNormalizer, LowercaseNormalizer,
291
+ } from "@pristine-ts/data-mapping-common";
292
+
293
+ const dataMapper = new DataMapper(
294
+ new AutoDataMappingBuilder(),
295
+ [new StringNormalizer(), new NumberNormalizer(), new BooleanNormalizer(), new DateNormalizer(), new LowercaseNormalizer()],
296
+ [],
297
+ );
298
+
299
+ const user = await dataMapper.autoMap(payload, User);
300
+ ```
301
+
302
+ The frontend build gets the same API surface, just without auto-wiring. Errors go to
303
+ `console.error` by default — pass a custom reporter to the constructor to redirect.
304
+
305
+ ## Mapping behavior reference
306
+
307
+ A few non-obvious rules worth knowing:
308
+
309
+ - **Renames drop the source key.** When a node has both `sourceProperty: "title"` and
310
+ `destinationProperty: "name"`, only `name` appears on the result. The old key is not
311
+ carried through.
312
+ - **Extraneous values.** When `excludeExtraneousValues` is `false` (default), source
313
+ properties not covered by the schema are copied through. When `true`, only explicitly-
314
+ mapped destination properties end up on the result. This applies recursively to nested
315
+ objects.
316
+ - **Missing optional fields** are skipped silently. Missing required fields throw
317
+ `DataMappingSourcePropertyNotFoundError`. Mark fields optional via `setIsOptional(true)`
318
+ or `field(src, dst, {isOptional: true})`.
319
+ - **Unknown normalizer keys throw.** Adding a `normalizerUniqueKey` to a leaf that isn't
320
+ registered with the `DataMapper` raises `DataNormalizerNotFoundError` at map time.
321
+ - **`export()` / `import()` of a builder schema.** `DataMappingBuilder.export()` returns a
322
+ serializable plain object and does NOT mutate the live tree (the builder remains usable
323
+ after exporting). On `import()`, the `destinationType` field is intentionally not
324
+ rehydrated — it's a class constructor that can't be transferred. Decorate the destination
325
+ class with `class-transformer`'s `@Type()` to recover class identity on round-tripped schemas.
326
+
327
+ ## Errors thrown
328
+
329
+ | Error | When |
330
+ |---|---|
331
+ | `DataMappingSourcePropertyNotFoundError` | Required source field missing |
332
+ | `DataNormalizerNotFoundError` | Leaf references a normalizer key that wasn't registered |
333
+ | `DataMappingInterceptorNotFoundError` | Builder references an interceptor key that wasn't registered |
334
+ | `DataNormalizerAlreadyAdded` | Same normalizer key added twice to one leaf (or to leaf + root) |
335
+ | `DataBeforeMappingInterceptorAlreadyAddedError` / `DataAfterMappingInterceptorAlreadyAddedError` | Same interceptor key added twice |
336
+ | `ArrayDataMappingNodeInvalidSourcePropertyTypeError` | ScalarArray / ObjectArray node received a non-array source |
337
+ | `UndefinedSourcePropertyError` / `UndefinedDestinationPropertyError` | `end()` called on a node without setting source / destination property |
338
+ | `AutoMapPrimitiveTypeNormalizerNotFoundError` | `autoMap(value, PrimitiveType.X)` called for a primitive type whose normalizer isn't registered |
339
+ | `NormalizerInvalidSourceTypeError` | Normalizer with `shouldThrowIfTypeIsNotString: true` received a non-string |
340
+
341
+ ## Related packages
342
+
343
+ - [`@pristine-ts/data-mapping-common`](../data-mapping-common) — framework-agnostic core (use directly in frontend)
344
+ - [`@pristine-ts/metadata`](../metadata) — `@property` / `@classMetadata` decorators the auto-builder reads
345
+ - [`@pristine-ts/networking`](../networking) — `@bodyMapping` and `BodyMappingRequestInterceptor`