diff --git a/packages/benchmark-core/.gitignore b/packages/benchmark-core/.gitignore new file mode 100644 index 000000000..0427870a0 --- /dev/null +++ b/packages/benchmark-core/.gitignore @@ -0,0 +1,2 @@ +profiles/ +*.cpuprofile diff --git a/packages/benchmark-core/package.json b/packages/benchmark-core/package.json index eba2de86a..cd1035dad 100644 --- a/packages/benchmark-core/package.json +++ b/packages/benchmark-core/package.json @@ -5,7 +5,10 @@ "type": "module", "description": "Benchmark harness for @automapper/core hot path (map / mapArray)", "scripts": { - "bench": "tsx src/bench.ts" + "bench": "node --expose-gc --import tsx src/bench.ts", + "bench:startup": "node --expose-gc --import tsx src/startup-bench.ts", + "bench:profile": "node --cpu-prof --cpu-prof-dir ./profiles --import tsx src/profile-formember.ts", + "bench:profile:analyze": "tsx src/analyze-cpuprofile.ts" }, "dependencies": { "@automapper/core": "workspace:*", diff --git a/packages/benchmark-core/src/analyze-cpuprofile.ts b/packages/benchmark-core/src/analyze-cpuprofile.ts new file mode 100644 index 000000000..554362482 --- /dev/null +++ b/packages/benchmark-core/src/analyze-cpuprofile.ts @@ -0,0 +1,86 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +// Aggregate self-time from the newest V8 .cpuprofile in ./profiles by function +// (functionName @ url:line), so the dominant cost of the forMember/mapFrom path +// is attributable instead of guessed. Usage: pnpm tsx src/analyze-cpuprofile.ts +// +// Reads from the fixed ./profiles dir (where `bench:profile` writes via +// --cpu-prof-dir). The directory is a constant — no CLI/external input feeds the +// path, so there is no path-injection surface. +const dir = './profiles'; +const files = readdirSync(dir) + .filter((f) => f.endsWith('.cpuprofile')) + .map((f) => ({ f, t: statSync(join(dir, f)).mtimeMs })) + .sort((a, b) => b.t - a.t); + +if (!files.length) { + console.error(`No .cpuprofile found in ${dir}`); + process.exit(1); +} + +const path = join(dir, files[0].f); +const prof = JSON.parse(readFileSync(path, 'utf8')) as { + nodes: Array<{ + id: number; + hitCount?: number; + callFrame: { functionName: string; url: string; lineNumber: number }; + }>; + samples: number[]; + timeDeltas: number[]; +}; + +const byId = new Map(); +for (const n of prof.nodes) byId.set(n.id, n); + +// accurate self time: sum the inter-sample delta charged to each sampled node +const selfUs = new Map(); +for (let i = 0; i < prof.samples.length; i++) { + const id = prof.samples[i]; + const dt = prof.timeDeltas[i] ?? 0; + selfUs.set(id, (selfUs.get(id) ?? 0) + dt); +} + +const isAutomapper = (url: string) => + url.includes('/packages/core/') || + url.includes('/packages/classes/') || + url.includes('@automapper'); + +const agg = new Map(); +let total = 0; +for (const [id, us] of selfUs) { + const node = byId.get(id); + if (!node) continue; + const cf = node.callFrame; + const name = cf.functionName || '(anonymous)'; + const file = cf.url + ? cf.url.replace(/^.*\/packages\//, 'packages/').replace(/^file:\/\//, '') + : '(native)'; + const key = `${name} ${file}:${cf.lineNumber + 1}`; + const cur = agg.get(key) ?? { us: 0, automapper: isAutomapper(cf.url) }; + cur.us += us; + agg.set(key, cur); + total += us; +} + +const rows = [...agg.entries()].sort((a, b) => b[1].us - a[1].us).slice(0, 30); +console.log(`\nCPU profile: ${path}`); +console.log(`Total sampled self-time: ${(total / 1000).toFixed(1)} ms\n`); +console.log(' self% self(ms) fn @ file:line'); +console.log(' ------ -------- --------------'); +let amTotal = 0; +for (const [key, { us, automapper }] of rows) { + const pct = (100 * us) / total; + if (automapper) amTotal += us; + console.log( + ` ${pct.toFixed(1).padStart(5)}% ${(us / 1000) + .toFixed(1) + .padStart(7)} ${automapper ? '*' : ' '} ${key}` + ); +} +console.log( + `\n (* = @automapper frame). @automapper self-time in top 30 ≈ ${( + (100 * amTotal) / + total + ).toFixed(1)}% of total.\n` +); diff --git a/packages/benchmark-core/src/bench.ts b/packages/benchmark-core/src/bench.ts index 8e6c6b13e..ade46e8df 100644 --- a/packages/benchmark-core/src/bench.ts +++ b/packages/benchmark-core/src/bench.ts @@ -1,22 +1,24 @@ import 'reflect-metadata'; -import { createMap, createMapper, forMember, mapFrom } from '@automapper/core'; +import { createMap, createMapper } from '@automapper/core'; import { AutoMap, classes } from '@automapper/classes'; import { pojos, PojosMetadataMap } from '@automapper/pojos'; import { bench, group, run } from 'mitata'; +import { + CamelUserDto, + makeSnakeUser, + makeUser, + registerUserMaps, + SnakeUser, + User, + UserDto, + UserView, +} from './fixtures'; // =========================================================================== -// Shared fixtures (plain source objects — map() reads by property name) +// Fixtures (plain source objects — map() reads by property name). The User / +// Snake model + mappings are shared via ./fixtures; Address/Profile below are +// bench-only (nested-mapping scenarios). // =========================================================================== -const makeUser = (i: number) => ({ - firstName: `First${i}`, - lastName: `Last${i}`, - email: `user${i}@example.com`, - age: 20 + (i % 50), - active: i % 2 === 0, - role: i % 3 === 0 ? 'admin' : 'user', - score: i * 1.5, - createdAt: 'Fri Jun 19 2026', -}); const makeProfile = (i: number) => ({ id: `id-${i}`, username: `user${i}`, @@ -25,10 +27,20 @@ const makeProfile = (i: number) => ({ }); const user = makeUser(1); -const users100 = Array.from({ length: 100 }, (_, i) => makeUser(i)); const users1000 = Array.from({ length: 1000 }, (_, i) => makeUser(i)); const profile = makeProfile(1); const profiles1000 = Array.from({ length: 1000 }, (_, i) => makeProfile(i)); +const snakeUsers1000 = Array.from({ length: 1000 }, (_, i) => makeSnakeUser(i)); + +// Rotating source pools (>=64 distinct objects). mitata reuses the same fixture +// each iteration, which lets V8 constant-fold a resolver over one fixed object +// and overstate the win; rotating through a pool keeps the measured cost +// representative of production polymorphism. +const POOL = 64; +const userPool = Array.from({ length: POOL }, (_, i) => makeUser(i)); +const snakePool = Array.from({ length: POOL }, (_, i) => makeSnakeUser(i)); +let userIdx = 0; +let snakeIdx = 0; // =========================================================================== // pojos strategy @@ -83,7 +95,8 @@ createMap(pojosMapper, 'Profile', 'ProfileDto'); // =========================================================================== // classes strategy (explicit @AutoMap types — esbuild/tsx has no -// emitDecoratorMetadata, so design:type is provided explicitly) +// emitDecoratorMetadata, so design:type is provided explicitly). Address / +// Profile are bench-only nested fixtures; the User* set comes from ./fixtures. // =========================================================================== class Address { @AutoMap(() => String) street!: string; @@ -95,26 +108,6 @@ class AddressDto { @AutoMap(() => String) city!: string; @AutoMap(() => String) zip!: string; } -class User { - @AutoMap(() => String) firstName!: string; - @AutoMap(() => String) lastName!: string; - @AutoMap(() => String) email!: string; - @AutoMap(() => Number) age!: number; - @AutoMap(() => Boolean) active!: boolean; - @AutoMap(() => String) role!: string; - @AutoMap(() => Number) score!: number; - @AutoMap(() => String) createdAt!: string; -} -class UserDto { - @AutoMap(() => String) firstName!: string; - @AutoMap(() => String) lastName!: string; - @AutoMap(() => String) email!: string; - @AutoMap(() => Number) age!: number; - @AutoMap(() => Boolean) active!: boolean; - @AutoMap(() => String) role!: string; - @AutoMap(() => Number) score!: number; - @AutoMap(() => String) createdAt!: string; -} class Profile { @AutoMap(() => String) id!: string; @AutoMap(() => String) username!: string; @@ -130,12 +123,19 @@ class ProfileDto { const classesMapper = createMapper({ strategyInitializer: classes() }); createMap(classesMapper, Address, AddressDto); -createMap(classesMapper, User, UserDto); createMap(classesMapper, Profile, ProfileDto); +// User -> UserDto, User -> UserView (forMember+mapFrom), SnakeUser -> CamelUserDto +registerUserMaps(classesMapper); // =========================================================================== -// Benchmarks (mitata uses the returned value as a sink to defeat DCE) +// Benchmarks (mitata uses the returned value as a sink to defeat DCE). +// Run under `--expose-gc` (see package.json `bench` script) so mitata reports +// the heap/gc columns; allocation is the dominant variable cost and the metric +// most likely to regress. `.gc('inner')` is set on the resolver/naming groups +// to surface a clean per-iteration heap delta. // =========================================================================== + +// --- Identity auto-map baseline (regression guard; latency-focused) --- group('pojos / flat (8 primitive members)', () => { bench('map x1', () => pojosMapper.map(user, 'User', 'UserDto')); bench('mapArray x1000', () => @@ -161,4 +161,28 @@ group('classes / nested (object member + array)', () => { ); }); +// --- forMember + mapFrom (2 auto + 6 mapFrom) --- +group('classes / forMember+mapFrom (2 auto + 6 mapFrom)', () => { + bench('map x1 (rotating pool)', () => + classesMapper.map(userPool[userIdx++ & (POOL - 1)], User, UserView) + ).gc('inner'); + bench('mapArray x1000', () => + classesMapper.mapArray(users1000, User, UserView) + ).gc('inner'); +}); + +// --- snake_case -> camelCase naming convention --- +group('classes / naming snake->camel (6 members)', () => { + bench('map x1 (rotating pool)', () => + classesMapper.map( + snakePool[snakeIdx++ & (POOL - 1)], + SnakeUser, + CamelUserDto + ) + ).gc('inner'); + bench('mapArray x1000', () => + classesMapper.mapArray(snakeUsers1000, SnakeUser, CamelUserDto) + ).gc('inner'); +}); + await run(); diff --git a/packages/benchmark-core/src/fixtures.ts b/packages/benchmark-core/src/fixtures.ts new file mode 100644 index 000000000..0def6bd5a --- /dev/null +++ b/packages/benchmark-core/src/fixtures.ts @@ -0,0 +1,134 @@ +import 'reflect-metadata'; +import { + CamelCaseNamingConvention, + createMap, + forMember, + mapFrom, + type Mapper, + namingConventions, + SnakeCaseNamingConvention, +} from '@automapper/core'; +import { AutoMap } from '@automapper/classes'; + +// Shared classes-strategy fixtures used by bench.ts, verify.ts, and +// profile-formember.ts so the model/factory/mapping setup lives in one place. + +export const makeUser = (i: number) => ({ + firstName: `First${i}`, + lastName: `Last${i}`, + email: `user${i}@example.com`, + age: 20 + (i % 50), + active: i % 2 === 0, + role: i % 3 === 0 ? 'admin' : 'user', + score: i * 1.5, + createdAt: 'Fri Jun 19 2026', +}); + +// snake_case source — exercises the source->dest naming-convention rename path +export const makeSnakeUser = (i: number) => ({ + first_name: `First${i}`, + last_name: `Last${i}`, + email_address: `user${i}@example.com`, + user_age: 20 + (i % 50), + is_active: i % 2 === 0, + user_role: i % 3 === 0 ? 'admin' : 'user', +}); + +// Explicit @AutoMap types — esbuild/tsx has no emitDecoratorMetadata, so the +// design:type is provided explicitly. +export class User { + @AutoMap(() => String) firstName!: string; + @AutoMap(() => String) lastName!: string; + @AutoMap(() => String) email!: string; + @AutoMap(() => Number) age!: number; + @AutoMap(() => Boolean) active!: boolean; + @AutoMap(() => String) role!: string; + @AutoMap(() => Number) score!: number; + @AutoMap(() => String) createdAt!: string; +} +export class UserDto { + @AutoMap(() => String) firstName!: string; + @AutoMap(() => String) lastName!: string; + @AutoMap(() => String) email!: string; + @AutoMap(() => Number) age!: number; + @AutoMap(() => Boolean) active!: boolean; + @AutoMap(() => String) role!: string; + @AutoMap(() => Number) score!: number; + @AutoMap(() => String) createdAt!: string; +} +// 2 auto-mapped (same-name) + 6 mapFrom-derived members — a common real-world +// configuration the identity groups don't exercise. +export class UserView { + @AutoMap(() => String) firstName!: string; // auto (same name) + @AutoMap(() => String) lastName!: string; // auto (same name) + @AutoMap(() => String) fullName!: string; // mapFrom + @AutoMap(() => String) emailLower!: string; // mapFrom + @AutoMap(() => String) ageGroup!: string; // mapFrom + @AutoMap(() => Boolean) isActive!: boolean; // mapFrom + @AutoMap(() => String) roleLabel!: string; // mapFrom + @AutoMap(() => Number) scoreRounded!: number; // mapFrom +} +export class SnakeUser { + @AutoMap(() => String) first_name!: string; + @AutoMap(() => String) last_name!: string; + @AutoMap(() => String) email_address!: string; + @AutoMap(() => Number) user_age!: number; + @AutoMap(() => Boolean) is_active!: boolean; + @AutoMap(() => String) user_role!: string; +} +export class CamelUserDto { + @AutoMap(() => String) firstName!: string; + @AutoMap(() => String) lastName!: string; + @AutoMap(() => String) emailAddress!: string; + @AutoMap(() => Number) userAge!: number; + @AutoMap(() => Boolean) isActive!: boolean; + @AutoMap(() => String) userRole!: string; +} + +/** + * Register the three classes-strategy mappings on a classes() mapper: + * - User -> UserDto (identity / auto-map) + * - User -> UserView (2 auto + 6 forMember(mapFrom)) + * - SnakeUser -> CamelUserDto (snake_case -> camelCase naming convention) + */ +export function registerUserMaps(mapper: Mapper): void { + createMap(mapper, User, UserDto); + createMap( + mapper, + User, + UserView, + forMember( + (d) => d.fullName, + mapFrom((s) => `${s.firstName} ${s.lastName}`) + ), + forMember( + (d) => d.emailLower, + mapFrom((s) => s.email.toLowerCase()) + ), + forMember( + (d) => d.ageGroup, + mapFrom((s) => (s.age < 30 ? 'young' : 'adult')) + ), + forMember( + (d) => d.isActive, + mapFrom((s) => s.active) + ), + forMember( + (d) => d.roleLabel, + mapFrom((s) => s.role.toUpperCase()) + ), + forMember( + (d) => d.scoreRounded, + mapFrom((s) => Math.round(s.score)) + ) + ); + createMap( + mapper, + SnakeUser, + CamelUserDto, + namingConventions({ + source: new SnakeCaseNamingConvention(), + destination: new CamelCaseNamingConvention(), + }) + ); +} diff --git a/packages/benchmark-core/src/profile-formember.ts b/packages/benchmark-core/src/profile-formember.ts new file mode 100644 index 000000000..340fa99e2 --- /dev/null +++ b/packages/benchmark-core/src/profile-formember.ts @@ -0,0 +1,47 @@ +import 'reflect-metadata'; +import { createMapper } from '@automapper/core'; +import { classes } from '@automapper/classes'; +import { makeUser, registerUserMaps, User, UserView } from './fixtures'; + +// =========================================================================== +// Focused CPU-profile driver for the common path: classes strategy, +// synchronous map(), forMember(mapFrom). Run under: +// node --cpu-prof --cpu-prof-dir ./profiles --import tsx src/profile-formember.ts +// then analyze the emitted .cpuprofile with `pnpm tsx src/analyze-cpuprofile.ts`. +// Unlike mitata, this runs a single straight hot loop so self-time attributes +// cleanly to map()/set()/mapMember()/step-closures rather than the harness. +// =========================================================================== + +const mapper = createMapper({ strategyInitializer: classes() }); +registerUserMaps(mapper); // registers User -> UserView (among others) + +const POOL = 64; +const pool = Array.from({ length: POOL }, (_, i) => makeUser(i)); +const arr = Array.from({ length: 1000 }, (_, i) => makeUser(i)); + +// Warmup so TurboFan tiers up before the profiled steady state. +for (let i = 0; i < 200_000; i++) mapper.map(pool[i & (POOL - 1)], User, UserView); + +const ITERS = 4_000_000; +let sink = 0; +const t0 = process.hrtime.bigint(); +for (let i = 0; i < ITERS; i++) { + const v = mapper.map(pool[i & (POOL - 1)], User, UserView) as { + fullName: string; + }; + sink += v.fullName.length; +} +// a few large mapArray passes too (per-element compounding) +for (let i = 0; i < 2000; i++) { + const out = mapper.mapArray(arr, User, UserView); + sink += out.length; +} +const t1 = process.hrtime.bigint(); + +console.log( + `profiled ${ITERS.toLocaleString()} single maps + 2000 mapArray(1000) in ${( + Number(t1 - t0) / 1e6 + ).toFixed(0)} ms (sink=${sink}); ~${(Number(t1 - t0) / ITERS).toFixed( + 1 + )} ns/map. .cpuprofile written to ./profiles on exit.` +); diff --git a/packages/benchmark-core/src/startup-bench.ts b/packages/benchmark-core/src/startup-bench.ts new file mode 100644 index 000000000..0ac97f5a9 --- /dev/null +++ b/packages/benchmark-core/src/startup-bench.ts @@ -0,0 +1,149 @@ +import 'reflect-metadata'; +import { createMap, createMapper, forMember, mapFrom } from '@automapper/core'; +import { AutoMap, classes } from '@automapper/classes'; + +// =========================================================================== +// 408-createMap startup + retained-memory bench. +// +// A large app may call createMap hundreds of times at boot (classes strategy, many @AutoMap +// decorators), and every compiled plan is retained for the process lifetime of +// a long-running NestJS service. The steady-state `bench.ts` cannot see this: +// it measures map() throughput post-tiering, not the one-shot compile cost or +// the resident-memory footprint of the retained plans. +// +// This harness builds a tail-skewed class-size distribution representative of a +// large app (most classes small, a few very wide), runs all createMaps once +// (the realistic boot path — no warmup/tiering), and reports: +// - wall time of createMap + buildMapPlan via process.hrtime.bigint() +// - retained heapUsed after global.gc() x3 (requires --expose-gc) +// It is the instrument for the startup/memory optimizations. +// Run: `node --expose-gc --import tsx src/startup-bench.ts`. +// =========================================================================== + +// --- tail-skewed @AutoMap class-size distribution, total ~408 plans ---------- +function buildSizeDistribution(): number[] { + const sizes: number[] = []; + sizes.push(197, 188); // the long tail (a couple of god-DTOs) + for (let i = 0; i < 4; i++) sizes.push(100); + for (let i = 0; i < 10; i++) sizes.push(60); + for (let i = 0; i < 44; i++) sizes.push(21); // mid-size tier + // remainder are small classes (5..10 props): deterministic spread + while (sizes.length < 408) sizes.push(5 + (sizes.length % 6)); + return sizes; +} + +// Build a class with `numProps` @AutoMap string properties, applied imperatively +// (identical to the `@AutoMap(() => String)` field decorator). The decorator's +// O(P^2) metadata spread (automap.ts) is intentionally exercised. +function makeMetadataClass(numProps: number): new () => Record { + const C = class {} as new () => Record; + for (let i = 0; i < numProps; i++) { + AutoMap(() => String)(C.prototype as object, 'p' + i); + } + return C; +} + +interface TrialResult { + plans: number; + totalProps: number; + wallMs: number; + retainedMB: number; +} + +const retained: unknown[] = []; // hold references so plans aren't collected pre-measure + +function gcTimes(n: number): void { + const gc = (globalThis as { gc?: () => void }).gc; + if (!gc) return; + for (let i = 0; i < n; i++) gc(); +} + +function runTrial(sizes: number[]): TrialResult { + const mapper = createMapper({ strategyInitializer: classes() }); + // Pre-create the class pairs (decorator cost) BEFORE the timed region so the + // measurement isolates createMap + buildMapPlan, not decoration. + const pairs = sizes.map((n) => ({ + Source: makeMetadataClass(n), + Dest: makeMetadataClass(n), + n, + })); + const totalProps = sizes.reduce((a, b) => a + b, 0); + + gcTimes(3); + const heapBefore = process.memoryUsage().heapUsed; + + const t0 = process.hrtime.bigint(); + for (const { Source, Dest } of pairs) { + // 2 forMember(mapFrom) per plan exercises the config path (getMetadataAtMember, + // getNestedMappingPair, processSourcePath) exercised by forMember/mapFrom. + createMap( + mapper, + Source, + Dest, + forMember( + (d: Record) => d['p0'], + mapFrom((s: Record) => s['p0']) + ), + forMember( + (d: Record) => d['p1'], + mapFrom((s: Record) => s['p1']) + ) + ); + } + const t1 = process.hrtime.bigint(); + + gcTimes(3); + const heapAfter = process.memoryUsage().heapUsed; + + retained.push(mapper, pairs); + + return { + plans: sizes.length, + totalProps, + wallMs: Number(t1 - t0) / 1e6, + retainedMB: (heapAfter - heapBefore) / (1024 * 1024), + }; +} + +const sizes = buildSizeDistribution(); +const TRIALS = 3; + +if (!(globalThis as { gc?: () => void }).gc) { + console.warn( + '[startup-bench] global.gc is unavailable — run with `node --expose-gc --import tsx src/startup-bench.ts` for accurate retained-memory numbers.' + ); +} + +console.log( + `\n408-createMap startup bench — ${sizes.length} plans, ${sizes.reduce( + (a, b) => a + b, + 0 + )} total props (max ${Math.max(...sizes)}), classes strategy, 2 forMember(mapFrom) each\n` +); +console.log('trial | plans | totalProps | wall (ms) | retained (MB)'); +console.log('------+---------+------------+-------------+--------------'); +const results: TrialResult[] = []; +for (let t = 0; t < TRIALS; t++) { + const r = runTrial(sizes); + results.push(r); + console.log( + `${String(t + 1).padStart(5)} | ${String(r.plans).padStart( + 7 + )} | ${String(r.totalProps).padStart(10)} | ${r.wallMs + .toFixed(2) + .padStart(11)} | ${r.retainedMB.toFixed(2).padStart(12)}` + ); +} + +const avg = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length; +console.log('------+---------+------------+-------------+--------------'); +console.log( + ` avg | | | ${avg(results.map((r) => r.wallMs)) + .toFixed(2) + .padStart(11)} | ${avg(results.map((r) => r.retainedMB)) + .toFixed(2) + .padStart(12)}` +); +console.log( + '\nNote: retained MB is heapUsed delta across the createMap loop after gc x3 — the resident cost of the compiled plans + stored metadata. It is the before/after instrument for the dropped plan array and O(P^2) startup fixes.\n' +); diff --git a/packages/benchmark-core/src/verify.ts b/packages/benchmark-core/src/verify.ts new file mode 100644 index 000000000..40e782f18 --- /dev/null +++ b/packages/benchmark-core/src/verify.ts @@ -0,0 +1,124 @@ +import 'reflect-metadata'; +import { createMap, createMapper } from '@automapper/core'; +import { classes } from '@automapper/classes'; +import { pojos, PojosMetadataMap } from '@automapper/pojos'; +import { + CamelUserDto, + makeSnakeUser, + makeUser, + registerUserMaps, + SnakeUser, + User, + UserDto, + UserView, +} from './fixtures'; + +// Independent validation of the bench: (1) assert every scenario maps CORRECTLY +// (so mitata isn't timing a silently no-op'd / wrong map), (2) re-time with a +// hand-rolled hrtime loop (no mitata) to corroborate the magnitude. Runs against +// whatever @automapper/* source is currently in the tree (this branch, or main +// when its src is overlaid), so the SAME assertions gate both sides. + +let failures = 0; +const eq = (a: unknown, b: unknown, msg: string) => { + if (JSON.stringify(a) !== JSON.stringify(b)) { + failures++; + console.error( + ` FAIL: ${msg} — got ${JSON.stringify(a)}, want ${JSON.stringify(b)}` + ); + } +}; + +// pojos identity mapper (verify the pojos path too) +PojosMetadataMap.create('User', { + firstName: String, + lastName: String, + email: String, + age: Number, + active: Boolean, + role: String, + score: Number, + createdAt: String, +}); +PojosMetadataMap.create('UserDto', { + firstName: String, + lastName: String, + email: String, + age: Number, + active: Boolean, + role: String, + score: Number, + createdAt: String, +}); +const pojosMapper = createMapper({ strategyInitializer: pojos() }); +createMap(pojosMapper, 'User', 'UserDto'); + +const m = createMapper({ strategyInitializer: classes() }); +registerUserMaps(m); // User->UserDto, User->UserView, SnakeUser->CamelUserDto + +// ---- (1) correctness ---- +console.log('correctness:'); +const u = makeUser(7); +const KEYS = [ + 'firstName', + 'lastName', + 'email', + 'age', + 'active', + 'role', + 'score', + 'createdAt', +] as const; + +const pd = pojosMapper.map(u, 'User', 'UserDto') as Record; +for (const k of KEYS) eq(pd[k], (u as never)[k], `pojos UserDto.${k}`); + +const cd = m.map(u, User, UserDto) as unknown as Record; +for (const k of KEYS) eq(cd[k], (u as never)[k], `classes UserDto.${k}`); + +const v = m.map(u, User, UserView) as unknown as Record; +eq(v.firstName, u.firstName, 'UserView.firstName (auto)'); +eq(v.lastName, u.lastName, 'UserView.lastName (auto)'); +eq(v.fullName, `${u.firstName} ${u.lastName}`, 'UserView.fullName (mapFrom)'); +eq(v.emailLower, u.email.toLowerCase(), 'UserView.emailLower (mapFrom)'); +eq(v.ageGroup, u.age < 30 ? 'young' : 'adult', 'UserView.ageGroup (mapFrom)'); +eq(v.isActive, u.active, 'UserView.isActive (mapFrom)'); +eq(v.roleLabel, u.role.toUpperCase(), 'UserView.roleLabel (mapFrom)'); +eq(v.scoreRounded, Math.round(u.score), 'UserView.scoreRounded (mapFrom)'); + +const s = makeSnakeUser(7); +const nd = m.map(s, SnakeUser, CamelUserDto) as unknown as Record< + string, + unknown +>; +eq(nd.firstName, s.first_name, 'CamelUserDto.firstName (naming)'); +eq(nd.lastName, s.last_name, 'CamelUserDto.lastName (naming)'); +eq(nd.emailAddress, s.email_address, 'CamelUserDto.emailAddress (naming)'); +eq(nd.userAge, s.user_age, 'CamelUserDto.userAge (naming)'); +eq(nd.isActive, s.is_active, 'CamelUserDto.isActive (naming)'); +eq(nd.userRole, s.user_role, 'CamelUserDto.userRole (naming)'); + +console.log(failures ? ` ${failures} FAILURE(S)` : ' all correctness checks passed'); + +// ---- (2) independent hrtime timing (no mitata) ---- +const POOL = 64; +const userPool = Array.from({ length: POOL }, (_, i) => makeUser(i)); +const snakePool = Array.from({ length: POOL }, (_, i) => makeSnakeUser(i)); +function time(label: string, fn: (i: number) => number, iters = 2_000_000) { + for (let i = 0; i < 200_000; i++) fn(i); // warmup -> TurboFan + let sink = 0; + const t0 = process.hrtime.bigint(); + for (let i = 0; i < iters; i++) sink += fn(i); + const t1 = process.hrtime.bigint(); + console.log( + ` ${label.padEnd(22)} ${(Number(t1 - t0) / iters) + .toFixed(1) + .padStart(8)} ns/op (sink ${sink & 255})` + ); +} +console.log('hrtime ns/op (independent of mitata):'); +time('classes flat', (i) => (m.map(userPool[i & (POOL - 1)], User, UserDto) as { age: number }).age); +time('forMember+mapFrom', (i) => (m.map(userPool[i & (POOL - 1)], User, UserView) as { fullName: string }).fullName.length); +time('naming snake->camel', (i) => (m.map(snakePool[i & (POOL - 1)], SnakeUser, CamelUserDto) as { userAge: number }).userAge); + +process.exit(failures ? 1 : 0); diff --git a/packages/classes/src/lib/automap.ts b/packages/classes/src/lib/automap.ts index b33015423..3ce9b33f0 100644 --- a/packages/classes/src/lib/automap.ts +++ b/packages/classes/src/lib/automap.ts @@ -30,12 +30,6 @@ export function AutoMap( ): PropertyDecorator { const options = getAutoMapOptions(typeFnOrOptions); return (target, propertyKey) => { - const existingMetadataList = - Reflect.getMetadata( - AUTOMAP_PROPERTIES_METADATA_KEY, - target.constructor - ) || []; - if (!options.type) { const designTypeMeta = Reflect.getMetadata( 'design:type', @@ -79,11 +73,26 @@ Manually provide the "type" metadata to prevent unexpected behavior. designParamsType && !(designParamsType as []).length; } - Reflect.defineMetadata( + // Push onto the class's OWN metadata list — O(1) per decorator instead of + // spreading the whole accumulated array each time (was O(P^2) per class). + // The own list is seeded once from inherited metadata via slice(), so a + // subclass keeps its parent's entries WITHOUT mutating the parent's array. + const ctor = target.constructor; + let metadataList = Reflect.getOwnMetadata( AUTOMAP_PROPERTIES_METADATA_KEY, - [...existingMetadataList, [propertyKey, options]], - target.constructor + ctor ); + if (!metadataList) { + metadataList = ( + Reflect.getMetadata(AUTOMAP_PROPERTIES_METADATA_KEY, ctor) || [] + ).slice(); + Reflect.defineMetadata( + AUTOMAP_PROPERTIES_METADATA_KEY, + metadataList, + ctor + ); + } + metadataList.push([propertyKey, options]); }; } diff --git a/packages/classes/src/lib/get-metadata-list.ts b/packages/classes/src/lib/get-metadata-list.ts index 97f3c09eb..cf8c4cb94 100644 --- a/packages/classes/src/lib/get-metadata-list.ts +++ b/packages/classes/src/lib/get-metadata-list.ts @@ -33,14 +33,15 @@ export function getMetadataList(model: MetadataIdentifier): [ return [[], []]; } + // `model` is a class (function); @AutoMap stores metadata on the class via + // `target.constructor`, and Reflect.getMetadata walks the class's prototype + // chain (so subclass inheritance is already covered). The old + // `model.constructor.prototype` read was `Function.prototype` — never a + // metadata target — so it always contributed []. `.concat()` keeps the + // defensive copy (never hand out the stored array). let metadataList: MetadataList = ( - model.constructor?.prototype - ? Reflect.getMetadata( - AUTOMAP_PROPERTIES_METADATA_KEY, - model.constructor.prototype - ) || [] - : [] - ).concat(Reflect.getMetadata(AUTOMAP_PROPERTIES_METADATA_KEY, model) || []); + Reflect.getMetadata(AUTOMAP_PROPERTIES_METADATA_KEY, model) || [] + ).concat(); const metadataFactoryFn = model[AUTOMAPPER_METADATA_FACTORY_KEY]; if (metadataFactoryFn) { diff --git a/packages/core/src/lib/core.ts b/packages/core/src/lib/core.ts index 0f0e60bf4..ba079e4c9 100644 --- a/packages/core/src/lib/core.ts +++ b/packages/core/src/lib/core.ts @@ -266,6 +266,14 @@ export function createMapper({ mappingCallbacks?.[MappingCallbacksClassId.afterMapArray]; const runArrayMapping = () => { + // options is read-only in map()/mapReturn — share one object for + // every element instead of allocating `{ extraArgs }` per element. + const elementOptions = { + extraArgs: extraArgs as MapOptions< + TSource, + TDestination + >['extraArgs'], + }; for (let i = 0, length = sourceArray.length; i < length; i++) { let sourceObject = sourceArray[i]; sourceObject = strategy.preMap(sourceObject, mapping); @@ -273,12 +281,7 @@ export function createMapper({ const destination = mapReturn( mapping, sourceObject, - { - extraArgs: extraArgs as MapOptions< - TSource, - TDestination - >['extraArgs'], - }, + elementOptions, true ); @@ -470,6 +473,14 @@ export function createMapper({ mappingCallbacks?.[MappingCallbacksClassId.afterMapArray]; const runArrayMapping = () => { + // options is read-only in map()/mapMutate — share one object for + // every element instead of allocating `{ extraArgs }` per element. + const elementOptions = { + extraArgs: extraArgs as MapOptions< + TSource, + TDestination + >['extraArgs'], + }; for (let i = 0, length = sourceArray.length; i < length; i++) { let sourceObject = sourceArray[i]; @@ -483,12 +494,7 @@ export function createMapper({ mapping, sourceObject, destination, - { - extraArgs: extraArgs as MapOptions< - TSource, - TDestination - >['extraArgs'], - }, + elementOptions, true ); diff --git a/packages/core/src/lib/mapping-configurations/extend.ts b/packages/core/src/lib/mapping-configurations/extend.ts index 5a9d44f24..5610c8af2 100644 --- a/packages/core/src/lib/mapping-configurations/extend.ts +++ b/packages/core/src/lib/mapping-configurations/extend.ts @@ -7,7 +7,7 @@ import type { } from '../types'; import { MappingClassId } from '../types'; import { getMapping } from '../utils/get-mapping'; -import { isSamePath } from '../utils/is-same-path'; +import { pathKey } from '../utils/path-key'; export function extend< TSource extends Dictionary, @@ -50,18 +50,25 @@ export function extend< } const propsToExtend = mappingToExtend[MappingClassId.properties]; + const customProperties = mapping[MappingClassId.customProperties]; + // Don't overwrite a destination already configured by a forMember. + // Index present keys in a Set (O(1) lookups instead of an O(custom) + // `.find` per parent prop) and add on push so dedup within this batch is + // preserved. Compile-time only, so the Set is cheap at any size. + const present = new Set( + customProperties.map(([pKey]) => pathKey(pKey)) + ); for (let i = 0, length = propsToExtend.length; i < length; i++) { const [ propToExtendKey, propToExtendMappingProp, propToExtendNestedMapping, ] = propsToExtend[i]; - const existProp = mapping[MappingClassId.customProperties].find( - ([pKey]) => isSamePath(pKey, propToExtendKey) - ); - if (existProp) continue; - mapping[MappingClassId.customProperties].push([ + const key = pathKey(propToExtendKey); + if (present.has(key)) continue; + present.add(key); + customProperties.push([ propToExtendKey, propToExtendMappingProp as MappingProperty< TSource, diff --git a/packages/core/src/lib/mappings/apply-metadata.ts b/packages/core/src/lib/mappings/apply-metadata.ts index ce6fd89a7..833c8391d 100644 --- a/packages/core/src/lib/mappings/apply-metadata.ts +++ b/packages/core/src/lib/mappings/apply-metadata.ts @@ -14,7 +14,6 @@ import { isDateConstructor } from '../utils/is-date-constructor'; import { isEmpty } from '../utils/is-empty'; import { isPrimitiveConstructor } from '../utils/is-primitive-constructor'; import { getRecursiveValue, setRecursiveValue } from '../utils/recursion'; -import { setMutate } from '../utils/set'; export function defaultApplyMetadata( strategy: MappingStrategy @@ -34,8 +33,10 @@ export function defaultApplyMetadata( // get the metadata of the model const metadata = metadataMap.get(model); - // instantiate a model - const instance = {}; + // instantiate a model. Metadata property keys are always a single + // segment here (storeMetadata stores `[propertyKey]`), so members are + // assigned directly — no setMutate path-walk or throwaway temp object. + const instance: Record = {}; // if metadata is empty, return the instance early if (isEmpty(metadata) || !metadata) { @@ -65,20 +66,20 @@ export function defaultApplyMetadata( // if the metadata is an Array, then assign an empty array if (isArray) { - setMutate(instance as Record, key, []); + instance[key[0]] = []; continue; } // if is String, Number, Boolean // null meta means this has any type or an arbitrary object, treat as primitives if (isPrimitiveConstructor(metaResult) || metaResult === null) { - setMutate(instance as Record, key, undefined); + instance[key[0]] = undefined; continue; } // if is Date, assign a new Date value if valueAtKey is defined, otherwise, undefined if (isDateConstructor(metaResult)) { - setMutate(instance as Record, key, new Date()); + instance[key[0]] = new Date(); continue; } @@ -89,7 +90,7 @@ export function defaultApplyMetadata( // if no depth, just instantiate with new keyword without recursive if (depth === 0) { - setMutate(instance as Record, key, {}); + instance[key[0]] = {}; continue; } @@ -99,7 +100,7 @@ export function defaultApplyMetadata( if (root || !selfReference) { setRecursiveValue(recursiveCountMap, model, key, 0); } - setMutate(instance as Record, key, {}); + instance[key[0]] = {}; continue; } @@ -116,7 +117,7 @@ export function defaultApplyMetadata( false, metaResult === model ); - setMutate(instance as Record, key, childMetadata); + instance[key[0]] = childMetadata; } // after all, resetAllCount on the current model diff --git a/packages/core/src/lib/mappings/compile-mapping.ts b/packages/core/src/lib/mappings/compile-mapping.ts index 85da2ef64..8beeaf181 100644 --- a/packages/core/src/lib/mappings/compile-mapping.ts +++ b/packages/core/src/lib/mappings/compile-mapping.ts @@ -1,5 +1,5 @@ import type { - CompiledMapping, + CompiledMappingDescriptors, CompiledMappingProperty, Dictionary, Mapping, @@ -16,7 +16,7 @@ export function compileMapping< TDestination extends Dictionary >( propsToMap: Mapping[MappingClassId.properties] -): Omit, 'steps'> { +): CompiledMappingDescriptors { const props: CompiledMappingProperty[] = []; const configuredKeys: string[] = []; diff --git a/packages/core/src/lib/mappings/create-initial-mapping.ts b/packages/core/src/lib/mappings/create-initial-mapping.ts index 8e1bdee3f..daa55c46c 100644 --- a/packages/core/src/lib/mappings/create-initial-mapping.ts +++ b/packages/core/src/lib/mappings/create-initial-mapping.ts @@ -28,6 +28,7 @@ import { import { getFlatteningPaths, getPath } from '../utils/get-path'; import { getPathRecursive } from '../utils/get-path-recursive'; import { isPrimitiveArrayEqual } from '../utils/is-primitive-array-equal'; +import { pathKey } from '../utils/path-key'; export function createInitialMapping< TSource extends Dictionary, @@ -93,6 +94,15 @@ export function createInitialMapping< const mappingProperties = mapping[MappingClassId.properties]; const customMappingProperties = mapping[MappingClassId.customProperties]; const hasCustomMappingProperties = customMappingProperties.length > 0; + // Configured destination paths as a Set, built once instead of an O(custom) + // .some scan per destination path. + const customPropertyKeys = hasCustomMappingProperties + ? new Set( + customMappingProperties.map((property) => + pathKey(property[MappingPropertiesClassId.path]) + ) + ) + : null; const namingConventions = mapping[MappingClassId.namingConventions]; const { processSourcePath, getMetadataAtMember, getNestedMappingPair } = @@ -101,17 +111,9 @@ export function createInitialMapping< for (let i = 0, length = destinationPaths.length; i < length; i++) { const destinationPath = destinationPaths[i]; - // is a forMember (custom mapping configuration) already exists - // for this destination path, skip it - if ( - hasCustomMappingProperties && - customMappingProperties.some((property) => - isPrimitiveArrayEqual( - property[MappingPropertiesClassId.path], - destinationPath - ) - ) - ) { + // a forMember (custom mapping configuration) already exists for this + // destination path — skip it + if (customPropertyKeys && customPropertyKeys.has(pathKey(destinationPath))) { continue; } @@ -226,18 +228,41 @@ export function createMappingUtil< const destinationMetadata = metadataMap.get(destinationIdentifier) || []; const sourceMetadata = metadataMap.get(sourceIdentifier) || []; + // For wide classes, index metadata by null-byte-joined property path so + // getMetadataAtMember is O(1) rather than an O(P) .find — it runs twice per + // destination path, so the scan is O(P^2) over the class. Below the gate the + // .find is cheaper than building the Map; first-match-wins is preserved. + const METADATA_INDEX_GATE = 30; + const buildIndex = (meta: Metadata[]) => { + if (meta.length <= METADATA_INDEX_GATE) return null; + const index = new Map(); + for (let i = 0, len = meta.length; i < len; i++) { + const key = pathKey(meta[i][MetadataClassId.propertyKeys]); + if (!index.has(key)) index.set(key, meta[i]); + } + return index; + }; + const sourceIndex = buildIndex(sourceMetadata); + const destinationIndex = buildIndex(destinationMetadata); + return { getMetadataAtMember: ( memberPath: string[], type: 'source' | 'destination' - ) => - (type === 'source' ? sourceMetadata : destinationMetadata).find( - (m) => - isPrimitiveArrayEqual( - m[MetadataClassId.propertyKeys], - memberPath - ) - ), + ) => { + const index = type === 'source' ? sourceIndex : destinationIndex; + if (index) { + return index.get(pathKey(memberPath)); + } + return ( + type === 'source' ? sourceMetadata : destinationMetadata + ).find((m) => + isPrimitiveArrayEqual( + m[MetadataClassId.propertyKeys], + memberPath + ) + ); + }, processSourcePath: ( sourceObject: TSource, namingConventions: Mapping[MappingClassId.namingConventions], diff --git a/packages/core/src/lib/mappings/create-map.ts b/packages/core/src/lib/mappings/create-map.ts index 8b6edc4c7..138518f28 100644 --- a/packages/core/src/lib/mappings/create-map.ts +++ b/packages/core/src/lib/mappings/create-map.ts @@ -111,7 +111,8 @@ export function createMap< // properties are finalized — compile the per-property step plan once, now, // so map() never has to re-inspect the positional tuples at runtime. mapping[MappingClassId.compiledPlan] = buildMapPlan( - mapping[MappingClassId.properties] + mapping[MappingClassId.properties], + mapping[MappingClassId.identifierMetadata][1] ); // store the mapping diff --git a/packages/core/src/lib/mappings/map.ts b/packages/core/src/lib/mappings/map.ts index 03ad4f2c3..9bbe9891e 100644 --- a/packages/core/src/lib/mappings/map.ts +++ b/packages/core/src/lib/mappings/map.ts @@ -13,7 +13,10 @@ import type { MetadataIdentifier, } from '../types'; import { MapFnClassId, MappingClassId, TransformationType } from '../types'; -import { assertUnmappedProperties } from '../utils/assert-unmapped-properties'; +import { + assertUnmappedProperties, + computeUnmappedCandidateKeys, +} from '../utils/assert-unmapped-properties'; import { get } from '../utils/get'; import { getMapping } from '../utils/get-mapping'; import { isMappableIdentifier } from '../utils/is-mappable-identifier'; @@ -23,15 +26,30 @@ import { set, setMutate } from '../utils/set'; import { compileMapping } from './compile-mapping'; import { mapMember } from './map-member'; +// A genuine File (and any File-like value) reports `[object File]`, i.e. its +// [Symbol.toStringTag] is 'File'. Reading the symbol directly avoids the +// per-call `Object.prototype.toString.call(x).slice(8, -1)` string allocation on +// the hot MapInitialize path, and is null-safe for the array-element check. NOT +// constructor.name, which false-positives on user classes named "File". +const FILE_TAG = Symbol.toStringTag; +function isFileTagged(value: unknown): boolean { + return ( + value != null && + (value as Record)[FILE_TAG] === 'File' + ); +} + +// Direct per-member writer (no per-member closure). set() returns the same +// object reference for a non-empty path, so the old `destination = set(...)` +// reassignment was a no-op and is dropped. function setMemberReturnFn = any>( destinationMemberPath: string[], - destination: TDestination | undefined + destination: TDestination | undefined, + value: unknown ) { - return (value: unknown) => { - if (destination) { - destination = set(destination, destinationMemberPath, value); - } - }; + if (destination) { + set(destination, destinationMemberPath, value); + } } export function mapReturn< @@ -53,7 +71,11 @@ export function mapReturn< } function setMemberMutateFn(destinationObj: Record) { - return (destinationMember: string[]) => (value: unknown) => { + return ( + destinationMember: string[], + _destination: unknown, + value: unknown + ) => { if (value !== undefined) { setMutate(destinationObj, destinationMember, value); } @@ -94,8 +116,9 @@ interface MapParameter< options: MapOptions; setMemberFn: ( destinationMemberPath: string[], - destination?: TDestination - ) => (value: unknown) => void; + destination: TDestination | undefined, + value: unknown + ) => void; getMemberFn?: ( destinationMemberPath: string[] | undefined ) => Record; @@ -285,6 +308,8 @@ interface MapStepContext< destination: TDestination; extraArguments: Record | undefined; extraArgs: MapOptions['extraArgs']; + // reusable options object for nested map() recursion (shared, read-only) + nestedOptions: MapOptions; mapper: Mapper; errorHandler: ErrorHandler; destinationIdentifier: MetadataIdentifier; @@ -309,7 +334,6 @@ function assignResolved< destinationMemberPath: string[], ctx: MapStepContext ): void { - const setValue = ctx.setMemberFn(destinationMemberPath, ctx.destination); if (isThenable(value)) { if (asyncMapContext === null) { throw new Error( @@ -319,7 +343,13 @@ function assignResolved< asyncMapContext.pending.push( Promise.resolve(value) - .then(setValue) + .then((resolved) => + ctx.setMemberFn( + destinationMemberPath, + ctx.destination, + resolved + ) + ) .catch((originalError) => { throw makeMemberError( destinationMemberPath, @@ -331,7 +361,7 @@ function assignResolved< ); return; } - setValue(value); + ctx.setMemberFn(destinationMemberPath, ctx.destination, value); } // Assign a precomputed auto-map value (its production already happened). This is @@ -350,14 +380,16 @@ function assignMember< ctx: MapStepContext ): void { try { - const setValue = ctx.setMemberFn( - destinationMemberPath, - ctx.destination - ); if (asyncMapContext !== null && isThenable(value)) { asyncMapContext.pending.push( Promise.resolve(value) - .then(setValue) + .then((resolved) => + ctx.setMemberFn( + destinationMemberPath, + ctx.destination, + resolved + ) + ) .catch((originalError) => { throw makeMemberError( destinationMemberPath, @@ -369,7 +401,7 @@ function assignMember< ); return; } - setValue(value); + ctx.setMemberFn(destinationMemberPath, ctx.destination, value); } catch (originalError) { throw makeMemberError( destinationMemberPath, @@ -422,8 +454,40 @@ function compileStep< sourceMemberIdentifier, } = prop; - // Everything that isn't MapInitialize dispatches through mapMember(); no - // identifier/value-shape inspection is needed here. + // MapFrom is the most common member transform. Call the selector directly + // and assign, skipping both the per-member `() => mapMember(...)` thunk and + // the mapMember() type switch — mapMember's MapFrom arm is exactly + // `value = mapFn(sourceObject)` (no implicit member mapping), so this is + // equivalent. One try/catch wraps production + assignment in a single + // MapMemberError, matching setMemberValue. + if (transformationType === TransformationType.MapFrom) { + const mapFromFn = transformationMapFn[MapFnClassId.fn] as ( + source: TSource + ) => unknown; + return (ctx) => { + if (preCond && !preCond(ctx.sourceObject)) { + assignMember(preCondDefault, destinationMemberPath, ctx); + return; + } + try { + assignResolved( + mapFromFn(ctx.sourceObject), + destinationMemberPath, + ctx + ); + } catch (originalError) { + throw makeMemberError( + destinationMemberPath, + ctx.destinationIdentifier, + ctx.errorHandler, + originalError + ); + } + }; + } + + // Everything else that isn't MapInitialize dispatches through mapMember(); + // no identifier/value-shape inspection is needed here. if (transformationType !== TransformationType.MapInitialize) { return (ctx) => { if (preCond && !preCond(ctx.sourceObject)) { @@ -472,9 +536,21 @@ function compileStep< const mapInitializedValue = mapInitializeFn(ctx.sourceObject); - // a same identifier that isn't a primitive/Date AND has no mapping of - // its own — assigned as-is. getMapping is only consulted when the - // (invariant) candidate held, so primitives never hit the registry. + // Scalar fast-path: null/undefined and primitives are assigned as-is and + // can never be a Date/File/Array or carry a nested mapping of their own, + // so they skip the identifier registry probe and the Date/File tag checks + // entirely. This is the bulk of @AutoMap members (same-name primitives). + if ( + mapInitializedValue == null || + typeof mapInitializedValue !== 'object' + ) { + assignMember(mapInitializedValue, destinationMemberPath, ctx); + return; + } + + // From here mapInitializedValue is a non-null object. A same identifier + // that isn't a Date/File AND has no mapping of its own is assigned as-is; + // getMapping is only consulted when the (invariant) candidate held. const hasSameIdentifier = sameIdentifierCandidate && !getMapping( @@ -485,10 +561,8 @@ function compileStep< ); if ( - mapInitializedValue == null || mapInitializedValue instanceof Date || - Object.prototype.toString.call(mapInitializedValue).slice(8, -1) === - 'File' || + isFileTagged(mapInitializedValue) || hasSameIdentifier || isTypedConverted ) { @@ -498,11 +572,12 @@ function compileStep< if (Array.isArray(mapInitializedValue)) { const [first] = mapInitializedValue; - // primitive/Date/File element array — shallow copy + // primitive/Date/File element array — shallow copy. isFileTagged is + // null-safe, so a null first element falls through to isEmpty below. if ( typeof first !== 'object' || first instanceof Date || - Object.prototype.toString.call(first).slice(8, -1) === 'File' + isFileTagged(first) ) { assignMember( mapInitializedValue.slice(), @@ -532,7 +607,7 @@ function compileStep< destinationMemberIdentifier as MetadataIdentifier ), each, - { extraArgs: ctx.extraArgs } + ctx.nestedOptions ) ), destinationMemberPath, @@ -541,44 +616,41 @@ function compileStep< return; } - if (typeof mapInitializedValue === 'object') { - const nestedMapping = getMapping( - ctx.mapper, - sourceMemberIdentifier as MetadataIdentifier, - destinationMemberIdentifier as MetadataIdentifier - ); + // non-null, non-array object: map it through its nested mapping. (The + // scalar fast-path above already handled primitives, so this is the only + // remaining case — no `typeof === 'object'` guard needed.) + const nestedMapping = getMapping( + ctx.mapper, + sourceMemberIdentifier as MetadataIdentifier, + destinationMemberIdentifier as MetadataIdentifier + ); - // nested mutate — recurse directly (unwrapped, as before) - if (ctx.getMemberFn) { - const memberValue = ctx.getMemberFn(destinationMemberPath); - if (memberValue !== undefined) { - map({ - sourceObject: mapInitializedValue as TSource, - mapping: nestedMapping, - options: { extraArgs: ctx.extraArgs }, - setMemberFn: setMemberMutateFn(memberValue), - getMemberFn: getMemberMutateFn(memberValue), - }); - } - return; + // nested mutate — recurse directly (unwrapped, as before) + if (ctx.getMemberFn) { + const memberValue = ctx.getMemberFn(destinationMemberPath); + if (memberValue !== undefined) { + map({ + sourceObject: mapInitializedValue as TSource, + mapping: nestedMapping, + options: ctx.nestedOptions, + setMemberFn: setMemberMutateFn(memberValue), + getMemberFn: getMemberMutateFn(memberValue), + }); } - - setMemberValue( - () => - map({ - mapping: nestedMapping, - sourceObject: mapInitializedValue as TSource, - options: { extraArgs: ctx.extraArgs }, - setMemberFn: setMemberReturnFn, - }), - destinationMemberPath, - ctx - ); return; } - // primitive - assignMember(mapInitializedValue, destinationMemberPath, ctx); + setMemberValue( + () => + map({ + mapping: nestedMapping, + sourceObject: mapInitializedValue as TSource, + options: ctx.nestedOptions, + setMemberFn: setMemberReturnFn, + }), + destinationMemberPath, + ctx + ); }; } @@ -588,7 +660,8 @@ export function buildMapPlan< TSource extends Dictionary, TDestination extends Dictionary >( - propsToMap: Mapping[MappingClassId.properties] + propsToMap: Mapping[MappingClassId.properties], + destinationMetadata: TDestination ): CompiledMapping { const { props, configuredKeys } = compileMapping( propsToMap @@ -597,7 +670,16 @@ export function buildMapPlan< for (let i = 0, length = props.length; i < length; i++) { steps[i] = compileStep(props[i]); } - return { props, configuredKeys, steps }; + // Precompute the unmapped-candidate residual (writable destination keys this + // mapping doesn't configure) once, here, so assertUnmappedProperties never + // rebuilds a per-call Set nor rescans the full writable-key list on the hot + // path (it runs on every map() / every mapArray element). + const unmappedCandidateKeys = computeUnmappedCandidateKeys( + destinationMetadata as object, + configuredKeys + ); + // props + configuredKeys are build-time only — not retained on the plan. + return { steps, unmappedCandidateKeys }; } export function map< @@ -642,15 +724,21 @@ export function map< // get extraArguments const extraArguments = extraArgs?.(mapping, destination); + // One options object reused by every nested map() (object members, + // object-array elements, nested mutate) instead of allocating + // `{ extraArgs }` per nested call. options is read-only in map(), so a + // shared instance is safe. + const nestedOptions: MapOptions = { extraArgs }; + // Compiled plan (specialized step closures + configured keys). Built eagerly // at createMap and hung on the mapping; the lazy fallback only fires for a // mapping constructed outside the normal createMap path. let compiledPlan = mapping[MappingClassId.compiledPlan]; if (compiledPlan === undefined) { - compiledPlan = buildMapPlan(propsToMap); + compiledPlan = buildMapPlan(propsToMap, destinationWithMetadata); mapping[MappingClassId.compiledPlan] = compiledPlan; } - const { steps, configuredKeys } = compiledPlan; + const { steps, unmappedCandidateKeys } = compiledPlan; // Build the per-call context once, then run each compiled step. All the // per-member branching/destructuring was resolved at compile time. @@ -659,6 +747,7 @@ export function map< destination, extraArguments, extraArgs, + nestedOptions, mapper, errorHandler, destinationIdentifier, @@ -686,8 +775,7 @@ export function map< assertUnmappedProperties( destination, - destinationWithMetadata, - configuredKeys, + unmappedCandidateKeys, sourceIdentifier, destinationIdentifier, errorHandler @@ -719,8 +807,7 @@ export function map< // this assertion runs inside the gated async work as well. assertUnmappedProperties( destination, - destinationWithMetadata, - configuredKeys, + unmappedCandidateKeys, sourceIdentifier, destinationIdentifier, errorHandler diff --git a/packages/core/src/lib/naming-conventions/snake-case-naming-convention.ts b/packages/core/src/lib/naming-conventions/snake-case-naming-convention.ts index 637a0f4f9..d704b2ed6 100644 --- a/packages/core/src/lib/naming-conventions/snake-case-naming-convention.ts +++ b/packages/core/src/lib/naming-conventions/snake-case-naming-convention.ts @@ -16,8 +16,12 @@ export class SnakeCaseNamingConvention implements NamingConvention { return sourcePropNameParts[0].toLowerCase() || ''; } - return sourcePropNameParts - .map((p) => p.toLowerCase()) - .join(this.separatorCharacter); + // indexed build instead of map().join() — one pass, no intermediate + // array (compile-time; byte-identical output). + let result = sourcePropNameParts[0].toLowerCase(); + for (let i = 1; i < len; i++) { + result += this.separatorCharacter + sourcePropNameParts[i].toLowerCase(); + } + return result; } } diff --git a/packages/core/src/lib/types.ts b/packages/core/src/lib/types.ts index 67576d3a2..2e497b1ef 100644 --- a/packages/core/src/lib/types.ts +++ b/packages/core/src/lib/types.ts @@ -564,10 +564,22 @@ export type CompiledMapStep = (context: any) => void; export interface CompiledMapping< TSource extends Dictionary = any, TDestination extends Dictionary = any +> { + steps: CompiledMapStep[]; + // writable destination keys this mapping does not configure, precomputed at + // createMap so assertUnmappedProperties does no per-call Set/scan work. + unmappedCandidateKeys: string[]; +} + +// Build-time intermediate produced by compileMapping(): `props` + `configuredKeys` +// are consumed by buildMapPlan() to make `steps` + `unmappedCandidateKeys` and are +// NOT retained on the compiled plan (nothing reads them at runtime). +export interface CompiledMappingDescriptors< + TSource extends Dictionary = any, + TDestination extends Dictionary = any > { props: CompiledMappingProperty[]; configuredKeys: string[]; - steps: CompiledMapStep[]; } export const enum MappingClassId { diff --git a/packages/core/src/lib/utils/assert-unmapped-properties.ts b/packages/core/src/lib/utils/assert-unmapped-properties.ts index 1224b3c33..017d72613 100644 --- a/packages/core/src/lib/utils/assert-unmapped-properties.ts +++ b/packages/core/src/lib/utils/assert-unmapped-properties.ts @@ -5,9 +5,9 @@ import type { MetadataIdentifier, } from '../types'; -// The writable keys of a destinationMetadata are fixed per mapping, but -// assertUnmappedProperties runs on every map() (per element in mapArray). -// Cache the Object.keys + getOwnPropertyDescriptor work per metadata object. +// The writable keys of a destinationMetadata are fixed per mapping. Cache the +// Object.keys + getOwnPropertyDescriptor work per metadata object (shared across +// mappings that target the same destination). const writableKeysCache = new WeakMap(); function getWritableKeys(destinationMetadata: object): string[] { @@ -23,27 +23,54 @@ function getWritableKeys(destinationMetadata: object): string[] { return keys; } +// Compile-time half: the writable destination keys this mapping does NOT +// configure. Computed once per mapping at createMap (buildMapPlan) and hung on +// the compiled plan, so map() never rebuilds a `new Set(configuredKeys)` nor +// re-scans the full writable-key list on every call / every mapArray element. +// Per-mapping, NOT cached on the metadata alone: two mappings can share a +// destination-metadata object yet configure different keys. +export function computeUnmappedCandidateKeys( + destinationMetadata: object, + configuredKeys: string[] +): string[] { + const writableKeys = getWritableKeys(destinationMetadata); + const configured = new Set(configuredKeys); + + const candidates: string[] = []; + for (let i = 0, len = writableKeys.length; i < len; i++) { + const key = writableKeys[i]; + if (!configured.has(key)) { + candidates.push(key); + } + } + return candidates; +} + /** + * Runtime half: of the precomputed unmapped-candidate keys, report those the + * mapping left unpopulated (absent from the destination AND undefined). Runs on + * every map() (per element in mapArray); the candidate list is precomputed at + * createMap so this is only a per-key presence check. + * * Depends on implementation of strategy.createMapping */ export function assertUnmappedProperties< TDestination extends Dictionary >( destinationObject: TDestination, - destinationMetadata: TDestination, - configuredKeys: string[], + unmappedCandidateKeys: string[], sourceIdentifier: MetadataIdentifier, destinationIdentifier: MetadataIdentifier, errorHandler: ErrorHandler ) { - const writableKeys = getWritableKeys(destinationMetadata as object); - const configured = new Set(configuredKeys); + if (unmappedCandidateKeys.length === 0) { + return; + } const unmappedKeys: string[] = []; - for (let i = 0, len = writableKeys.length; i < len; i++) { - const key = writableKeys[i]; + for (let i = 0, len = unmappedCandidateKeys.length; i < len; i++) { + const key = unmappedCandidateKeys[i]; if ( - !configured.has(key) && !(key in destinationObject) && destinationObject[key as keyof typeof destinationObject] === undefined diff --git a/packages/core/src/lib/utils/get-path-recursive.ts b/packages/core/src/lib/utils/get-path-recursive.ts index a7fa9ac29..ed840cfc6 100644 --- a/packages/core/src/lib/utils/get-path-recursive.ts +++ b/packages/core/src/lib/utils/get-path-recursive.ts @@ -1,6 +1,6 @@ import { uniquePaths } from './unique-path'; -const EXCLUDE_KEYS = [ +const EXCLUDE_KEYS = new Set([ 'constructor', '__defineGetter__', '__defineSetter__', @@ -13,7 +13,7 @@ const EXCLUDE_KEYS = [ 'valueOf', '__proto__', 'toLocaleString', -]; +]); export function getPathRecursive( node: Record, @@ -24,12 +24,11 @@ export function getPathRecursive( let hasChildPaths = false; - const keys = Array.from( - new Set( - [...Object.getOwnPropertyNames(node)].filter( - (key) => !EXCLUDE_KEYS.includes(key) - ) - ) + // Object.getOwnPropertyNames already returns unique keys, so the previous + // Array.from(new Set(...)) dedup removed nothing. Cross-node dedup (the only + // place a path can repeat) is still done by uniquePaths(result) below. + const keys = Object.getOwnPropertyNames(node).filter( + (key) => !EXCLUDE_KEYS.has(key) ); for (let i = 0, len = keys.length; i < len; i++) { diff --git a/packages/core/src/lib/utils/get-path.ts b/packages/core/src/lib/utils/get-path.ts index 3a3842eeb..7cf3124cb 100644 --- a/packages/core/src/lib/utils/get-path.ts +++ b/packages/core/src/lib/utils/get-path.ts @@ -24,16 +24,19 @@ export function getFlatteningPaths( namingConventions: [NamingConvention, NamingConvention] ): string[] { const [sourceNamingConvention] = namingConventions; + + // single-segment path that already exists on source: no flattening needed — + // return before doing the regex split work. + if (srcPath.length === 1 && hasProperty(src, srcPath[0])) { + return srcPath; + } + const splitSourcePaths: string[] = ([] as string[]).concat( ...srcPath.map((s) => s.split(sourceNamingConvention.splittingExpression).filter(Boolean) ) ); - if (srcPath.length === 1 && hasProperty(src, srcPath[0])) { - return srcPath; - } - const [first, ...paths] = splitSourcePaths.slice( 0, splitSourcePaths.length - 1 diff --git a/packages/core/src/lib/utils/get.ts b/packages/core/src/lib/utils/get.ts index ac30b38b2..8081381e6 100644 --- a/packages/core/src/lib/utils/get.ts +++ b/packages/core/src/lib/utils/get.ts @@ -3,6 +3,15 @@ export function get(object: T, path: (string | symbol)[] = []): unknown { return; } + // flat-member fast path: the dominant case is a single-segment path (a + // top-level source/destination member). Skip the loop + the trailing + // index===length bookkeeping entirely. + if (path.length === 1) { + return object == null + ? undefined + : (object as Record)[path[0] as string]; + } + let index: number; const length = path.length; diff --git a/packages/core/src/lib/utils/path-key.ts b/packages/core/src/lib/utils/path-key.ts new file mode 100644 index 000000000..d20f39532 --- /dev/null +++ b/packages/core/src/lib/utils/path-key.ts @@ -0,0 +1,12 @@ +/** + * Serialize a member path (e.g. `['address', 'street']`) into a single string + * usable as a `Set`/`Map` key. The `\0` separator is collision-proof for string + * property segments, so `pathKey(a) === pathKey(b)` is equivalent to comparing + * the arrays element-by-element (see `isSamePath` / `isPrimitiveArrayEqual`). + * + * Used by the compile-time path (createMap) to index/dedupe paths in O(1) + * instead of repeated linear array-equality scans. + */ +export function pathKey(path: readonly string[]): string { + return path.join('\0'); +} diff --git a/packages/core/src/lib/utils/specs/assert-unmapped-properties.spec.ts b/packages/core/src/lib/utils/specs/assert-unmapped-properties.spec.ts new file mode 100644 index 000000000..c5738292e --- /dev/null +++ b/packages/core/src/lib/utils/specs/assert-unmapped-properties.spec.ts @@ -0,0 +1,136 @@ +import { + assertUnmappedProperties, + computeUnmappedCandidateKeys, +} from '../assert-unmapped-properties'; +import type { ErrorHandler } from '../../types'; + +// A destination-metadata object as the strategies build it: enumerable data +// properties are writable members; a non-writable value and an accessor must be +// ignored (they are not assignable destination members). +function makeMetadata(): Record { + const meta: Record = {}; + Object.defineProperties(meta, { + firstName: { value: undefined, writable: true, enumerable: true }, + lastName: { value: undefined, writable: true, enumerable: true }, + fullName: { value: undefined, writable: true, enumerable: true }, + age: { value: undefined, writable: true, enumerable: true }, + id: { value: 1, writable: false, enumerable: true }, // non-writable + computed: { get: () => 1, enumerable: true }, // accessor (getter-only) + }); + return meta; +} + +const srcId = Symbol.for('UnmappedSrc'); +const destId = Symbol.for('UnmappedDest'); + +function recordingHandler(): ErrorHandler & { messages: unknown[] } { + const messages: unknown[] = []; + return { messages, handle: (e: unknown) => messages.push(e) }; +} + +describe('computeUnmappedCandidateKeys', () => { + it('returns writable metadata keys that are not configured, in metadata order', () => { + const meta = makeMetadata(); + expect( + computeUnmappedCandidateKeys(meta, ['firstName', 'fullName']) + ).toEqual(['lastName', 'age']); + }); + + it('excludes non-writable and accessor (getter-only) metadata keys', () => { + const meta = makeMetadata(); + const result = computeUnmappedCandidateKeys(meta, []); + expect(result).toEqual(['firstName', 'lastName', 'fullName', 'age']); + expect(result).not.toContain('id'); + expect(result).not.toContain('computed'); + }); + + it('ignores configured keys that are not present in the metadata', () => { + const meta = makeMetadata(); + expect( + computeUnmappedCandidateKeys(meta, [ + 'notThere', + 'firstName', + 'lastName', + 'fullName', + 'age', + ]) + ).toEqual([]); + }); + + it('recomputes per call: same metadata object, different configured sets => different residuals (not cached per metadata)', () => { + const meta = makeMetadata(); + expect(computeUnmappedCandidateKeys(meta, ['firstName'])).toEqual([ + 'lastName', + 'fullName', + 'age', + ]); + // SAME metadata object — a residual cached on the metadata alone would be + // unsound, since another mapping to the same destination has its own + // configured keys. + expect( + computeUnmappedCandidateKeys(meta, [ + 'firstName', + 'lastName', + 'fullName', + 'age', + ]) + ).toEqual([]); + }); +}); + +describe('assertUnmappedProperties (precomputed candidates)', () => { + it('reports candidate keys that are absent and undefined on the destination', () => { + const h = recordingHandler(); + assertUnmappedProperties({}, ['lastName', 'age'], srcId, destId, h); + expect(h.messages).toHaveLength(1); + expect(String(h.messages[0])).toContain('lastName'); + expect(String(h.messages[0])).toContain('age'); + }); + + it('does not report a candidate that was assigned a value', () => { + const h = recordingHandler(); + assertUnmappedProperties( + { lastName: 'x' }, + ['lastName', 'age'], + srcId, + destId, + h + ); + expect(h.messages).toHaveLength(1); + const msg = String(h.messages[0]); + expect(msg).toContain('age'); + expect(msg).not.toContain('lastName'); + }); + + it('does not report a candidate present on the destination even if its value is undefined', () => { + const h = recordingHandler(); + // key present (own) though undefined -> `!(key in dest)` is false -> skip + assertUnmappedProperties({ age: undefined }, ['age'], srcId, destId, h); + expect(h.messages).toHaveLength(0); + }); + + it('does not report a candidate inherited via the prototype chain', () => { + const h = recordingHandler(); + const dest = Object.create({ age: 1 }); // `'age' in dest` === true (inherited) + assertUnmappedProperties(dest, ['age'], srcId, destId, h); + expect(h.messages).toHaveLength(0); + }); + + it('does not invoke the error handler when there are no candidate keys', () => { + const h = recordingHandler(); + assertUnmappedProperties({}, [], srcId, destId, h); + expect(h.messages).toHaveLength(0); + }); + + it('does not invoke the error handler when all candidates are populated', () => { + const h = recordingHandler(); + assertUnmappedProperties( + { lastName: 'x', age: 3 }, + ['lastName', 'age'], + srcId, + destId, + h + ); + expect(h.messages).toHaveLength(0); + }); +}); diff --git a/packages/core/src/lib/utils/specs/get-path-recursive.spec.ts b/packages/core/src/lib/utils/specs/get-path-recursive.spec.ts new file mode 100644 index 000000000..7625056fd --- /dev/null +++ b/packages/core/src/lib/utils/specs/get-path-recursive.spec.ts @@ -0,0 +1,37 @@ +import { getPathRecursive } from '../get-path-recursive'; + +describe('getPathRecursive', () => { + // Diamond shape: a nested object shared under two keys (a, b), an array of + // differing-shape objects, and a scalar. Pins byte-identical, order-identical + // output so this cleanup (drop the redundant own-name Set dedup; module + // Set for EXCLUDE_KEYS) is provably behavior-preserving — cross-node dedup is + // done by uniquePaths(), which this cleanup leaves untouched. + it('produces stable, deduped paths for a diamond shape', () => { + const shared = { id: 1 }; + const node: Record = { + a: shared, + b: shared, + items: [{ name: 'x' }, { value: 2 }], + scalar: 5, + }; + + expect(getPathRecursive(node)).toEqual([ + ['a'], + ['a', 'id'], + ['b'], + ['b', 'id'], + ['items'], + ['items', 'name'], + ['items', 'value'], + ['scalar'], + ]); + }); + + it('skips function members and prototype/built-in keys', () => { + const node: Record = { + keep: 1, + fn: () => 0, + }; + expect(getPathRecursive(node)).toEqual([['keep']]); + }); +}); diff --git a/packages/core/src/lib/utils/specs/get.spec.ts b/packages/core/src/lib/utils/specs/get.spec.ts index f0cdd9212..78a07f8ba 100644 --- a/packages/core/src/lib/utils/specs/get.spec.ts +++ b/packages/core/src/lib/utils/specs/get.spec.ts @@ -35,4 +35,21 @@ describe('get', () => { const result = get(obj); expect(result).toEqual(undefined); }); + + // single-segment path (the flat-member fast path) + it('should return a single-segment leaf value', () => { + expect(get({ foo: 'bar' }, ['foo'])).toEqual('bar'); + }); + + it('should return null for a single-segment null value', () => { + expect(get({ foo: null }, ['foo'])).toEqual(null); + }); + + it('should return undefined for a single-segment unknown key', () => { + expect(get({ foo: 'bar' }, ['baz'])).toEqual(undefined); + }); + + it('should return undefined for a single-segment path on a null object', () => { + expect(get(null, ['foo'])).toEqual(undefined); + }); }); diff --git a/packages/core/src/lib/utils/store-metadata.ts b/packages/core/src/lib/utils/store-metadata.ts index 776c39d56..9968592af 100644 --- a/packages/core/src/lib/utils/store-metadata.ts +++ b/packages/core/src/lib/utils/store-metadata.ts @@ -11,14 +11,16 @@ export function storeMetadata( if (!isDefined(metadataList)) return; const metadataMap = getMetadataMap(mapper); if (metadataMap.has(model)) return; + + // Build the stored list once (push in place) instead of spreading the whole + // accumulated array per property — that was O(P^2) per createMap. The + // `has(model)` guard above guarantees there is no existing entry to seed. + const list: NonNullable> = []; for (const [ propertyKey, { isGetterOnly, type, depth, isArray }, ] of metadataList) { - metadataMap.set(model, [ - ...(metadataMap.get(model) || []), - [[propertyKey], type, isArray, isGetterOnly], - ]); + list.push([[propertyKey], type, isArray, isGetterOnly]); if (depth != null) { setRecursiveValue( @@ -29,4 +31,5 @@ export function storeMetadata( ); } } + metadataMap.set(model, list); } diff --git a/packages/integration-test/src/classes/automap-inheritance.spec.ts b/packages/integration-test/src/classes/automap-inheritance.spec.ts new file mode 100644 index 000000000..e681e0c40 --- /dev/null +++ b/packages/integration-test/src/classes/automap-inheritance.spec.ts @@ -0,0 +1,35 @@ +import { AutoMap, getMetadataList } from '@automapper/classes'; + +// Guards inheritance: the @AutoMap decorator now pushes onto the class's OWN metadata +// list (O(1)) and seeds a subclass from inherited metadata via slice(). This +// must NOT mutate the parent's metadata, and a subclass must keep parent members. +describe('@AutoMap inheritance (own-list push + sliced seed)', () => { + class Parent { + @AutoMap(() => String) a!: string; + @AutoMap(() => String) b!: string; + } + class Child extends Parent { + @AutoMap(() => String) c!: string; + } + class ChildNoDecl extends Parent {} + + const keys = (model: object) => + getMetadataList(model as never)[0].map(([key]) => key); + + it('parent exposes only its own members', () => { + expect(keys(Parent)).toEqual(['a', 'b']); + }); + + it('subclass keeps parent members and appends its own', () => { + expect(keys(Child)).toEqual(['a', 'b', 'c']); + }); + + it('subclass with no own decorators inherits parent members', () => { + expect(keys(ChildNoDecl)).toEqual(['a', 'b']); + }); + + it('decorating the subclass did not mutate the parent', () => { + // re-read parent after Child/ChildNoDecl were defined+decorated + expect(keys(Parent)).toEqual(['a', 'b']); + }); +}); diff --git a/packages/integration-test/src/pojos/map-from-resolver-paths.spec.ts b/packages/integration-test/src/pojos/map-from-resolver-paths.spec.ts new file mode 100644 index 000000000..58708932f --- /dev/null +++ b/packages/integration-test/src/pojos/map-from-resolver-paths.spec.ts @@ -0,0 +1,58 @@ +import { createMap, createMapper, forMember, mapFrom } from '@automapper/core'; +import { pojos, PojosMetadataMap } from '@automapper/pojos'; + +// Characterizes the mapFrom member path across return/mutate/error so the +// the per-member-writer + inlined-mapFrom refactor stays +// behavior-preserving. +describe('mapFrom resolver paths (return / mutate / error)', () => { + afterEach(() => { + PojosMetadataMap.reset(); + }); + + function setup(resolver: (s: { a: string }) => unknown) { + const mapper = createMapper({ strategyInitializer: pojos() }); + PojosMetadataMap.create('Src', { a: String, b: String }); + PojosMetadataMap.create('Dst', { a: String, b: String }); + createMap( + mapper, + 'Src', + 'Dst', + forMember( + (d: { b: unknown }) => d.b, + mapFrom(resolver as never) + ) + ); + return mapper; + } + + it('writes the mapFrom-computed value on the return path', () => { + const mapper = setup((s) => s.a.toUpperCase()); + const dto = mapper.map({ a: 'x' }, 'Src', 'Dst') as { + a: string; + b: string; + }; + expect(dto).toEqual({ a: 'x', b: 'X' }); + }); + + it('wraps a throwing resolver (original error surfaced once)', () => { + const mapper = setup(() => { + throw new Error('boom'); + }); + expect(() => mapper.map({ a: 'x' }, 'Src', 'Dst')).toThrow(/boom/); + }); + + it('applies mapFrom onto an existing object on the mutate path', () => { + const mapper = setup((s) => s.a.toUpperCase()); + const dest = { a: 'old', b: 'old' }; + mapper.mutate({ a: 'x' }, dest, 'Src', 'Dst'); + expect(dest).toEqual({ a: 'x', b: 'X' }); + }); + + it('mutate skips an undefined resolver result (preserves the existing value)', () => { + const mapper = setup(() => undefined); + const dest = { a: 'old', b: 'keepme' }; + mapper.mutate({ a: 'x' }, dest, 'Src', 'Dst'); + expect(dest.b).toBe('keepme'); // undefined is NOT written on mutate + expect(dest.a).toBe('x'); + }); +}); diff --git a/packages/integration-test/src/pojos/map-initialize-value-branch.spec.ts b/packages/integration-test/src/pojos/map-initialize-value-branch.spec.ts new file mode 100644 index 000000000..422bedae5 --- /dev/null +++ b/packages/integration-test/src/pojos/map-initialize-value-branch.spec.ts @@ -0,0 +1,102 @@ +import { createMap, createMapper, type Mapper } from '@automapper/core'; +import { pojos, PojosMetadataMap } from '@automapper/pojos'; + +// Characterization of the MapInitialize value branch in compileStep (map.ts): +// primitives, Date instances, and File values are assigned AS-IS (never deep +// mapped / cloned), and File is detected by its `[object File]` tag. These pin +// the behavior refactored here (scalar fast-path ordering + Symbol.toStringTag +// File detection) so the refactor is provably behavior-preserving. +describe('MapInitialize value branch (Date / File / array)', () => { + let mapper: Mapper; + + beforeEach(() => { + mapper = createMapper({ strategyInitializer: pojos() }); + }); + + afterEach(() => { + mapper.dispose(); + PojosMetadataMap.reset(); + }); + + it('assigns a Date member as-is (same value, still a Date — not cloned/mapped)', () => { + PojosMetadataMap.create('HasDate', { when: Date }); + PojosMetadataMap.create('HasDateDto', { when: Date }); + createMap(mapper, 'HasDate', 'HasDateDto'); + + const when = new Date('2020-01-02T03:04:05.000Z'); + const dto = mapper.map({ when }, 'HasDate', 'HasDateDto') as { + when: Date; + }; + + expect(dto.when).toBeInstanceOf(Date); + expect(dto.when.getTime()).toBe(when.getTime()); + }); + + it('assigns a File value as-is via its tag, even when src/dest member identifiers differ', () => { + // Different nested identifiers => sameIdentifier is false, so without the + // File detection this would fall into the object branch and throw + // (no SrcDoc -> DstDoc mapping registered). + PojosMetadataMap.create('HasDoc', { doc: 'SrcDoc' }); + PojosMetadataMap.create('HasDocDto', { doc: 'DstDoc' }); + createMap(mapper, 'HasDoc', 'HasDocDto'); + + // Object.prototype.toString.call(fileLike) === '[object File]' + const fileLike = { [Symbol.toStringTag]: 'File', name: 'a.txt' }; + const dto = mapper.map({ doc: fileLike }, 'HasDoc', 'HasDocDto') as { + doc: unknown; + }; + + expect(dto.doc).toBe(fileLike); + }); + + it('does NOT treat a plain object whose class is named "File" as a File (tag, not constructor.name)', () => { + // Discriminates Symbol.toStringTag (correct) from constructor.name (wrong): + // this object has constructor.name === 'File' but no '[object File]' tag, + // so it must go through the normal object (nested-map) branch. + class File { + value = 1; + } + PojosMetadataMap.create('HasObj', { obj: 'SrcObj' }); + PojosMetadataMap.create('HasObjDto', { obj: 'DstObj' }); + PojosMetadataMap.create('SrcObj', { value: Number }); + PojosMetadataMap.create('DstObj', { value: Number }); + createMap(mapper, 'SrcObj', 'DstObj'); + createMap(mapper, 'HasObj', 'HasObjDto'); + + const dto = mapper.map({ obj: new File() }, 'HasObj', 'HasObjDto') as { + obj: { value: number }; + }; + + expect(dto.obj).not.toBeInstanceOf(File); + expect(dto.obj.value).toBe(1); + }); + + it('shallow-copies an array of File values (new array, elements by reference)', () => { + PojosMetadataMap.create('HasDocs', { docs: ['SrcDoc'] }); + PojosMetadataMap.create('HasDocsDto', { docs: ['DstDoc'] }); + createMap(mapper, 'HasDocs', 'HasDocsDto'); + + const fileLike = { [Symbol.toStringTag]: 'File', name: 'a.txt' }; + const input = [fileLike]; + const dto = mapper.map({ docs: input }, 'HasDocs', 'HasDocsDto') as { + docs: unknown[]; + }; + + expect(dto.docs).not.toBe(input); + expect(dto.docs[0]).toBe(fileLike); + }); + + it('assigns a primitive scalar member as-is', () => { + PojosMetadataMap.create('HasNum', { n: Number, s: String }); + PojosMetadataMap.create('HasNumDto', { n: Number, s: String }); + createMap(mapper, 'HasNum', 'HasNumDto'); + + const dto = mapper.map({ n: 42, s: 'hi' }, 'HasNum', 'HasNumDto') as { + n: number; + s: string; + }; + + expect(dto.n).toBe(42); + expect(dto.s).toBe('hi'); + }); +}); diff --git a/packages/integration-test/src/pojos/wide-mapping-size-gate.spec.ts b/packages/integration-test/src/pojos/wide-mapping-size-gate.spec.ts new file mode 100644 index 000000000..9b3d55592 --- /dev/null +++ b/packages/integration-test/src/pojos/wide-mapping-size-gate.spec.ts @@ -0,0 +1,73 @@ +import { createMap, createMapper, extend, forMember, mapFrom } from '@automapper/core'; +import { pojos, PojosMetadataMap, type PojosMetadata } from '@automapper/pojos'; + +// Exercises the size-gated cold-path branches (metadata index, extend +// Set) which only trigger above ~30 properties — wider than any class in the rest +// of the suite, so without this they'd be uncovered. Asserts the gated paths +// produce the same results as the small-class .find paths. +const WIDTH = 40; // > the 30 gate + +function wideMeta(): PojosMetadata { + const meta: PojosMetadata = {}; + for (let i = 0; i < WIDTH; i++) meta['p' + i] = String; + return meta; +} + +function wideSource(): Record { + const src: Record = {}; + for (let i = 0; i < WIDTH; i++) src['p' + i] = 'v' + i; + return src; +} + +describe('wide mapping (size-gated cold paths)', () => { + afterEach(() => { + PojosMetadataMap.reset(); + }); + + it('maps every member of a >30-property class (metadata index gate)', () => { + const mapper = createMapper({ strategyInitializer: pojos() }); + PojosMetadataMap.create('WideSrc', wideMeta()); + PojosMetadataMap.create('WideDst', wideMeta()); + createMap(mapper, 'WideSrc', 'WideDst'); + + const dto = mapper.map(wideSource(), 'WideSrc', 'WideDst') as Record< + string, + string + >; + + for (let i = 0; i < WIDTH; i++) { + expect(dto['p' + i]).toBe('v' + i); + } + }); + + it('extend copies a >30-property base, preserving an existing custom member (extend Set gate)', () => { + const mapper = createMapper({ strategyInitializer: pojos() }); + PojosMetadataMap.create('BaseSrc', wideMeta()); + PojosMetadataMap.create('BaseDst', wideMeta()); + PojosMetadataMap.create('ChildSrc', wideMeta()); + PojosMetadataMap.create('ChildDst', wideMeta()); + + createMap(mapper, 'BaseSrc', 'BaseDst'); + createMap( + mapper, + 'ChildSrc', + 'ChildDst', + // a pre-existing custom member must NOT be overwritten by extend + forMember( + (d: Record) => d['p0'], + mapFrom(() => 'CUSTOM') + ), + extend('BaseSrc', 'BaseDst') + ); + + const dto = mapper.map(wideSource(), 'ChildSrc', 'ChildDst') as Record< + string, + string + >; + + expect(dto['p0']).toBe('CUSTOM'); // dedup: existing custom wins + for (let i = 1; i < WIDTH; i++) { + expect(dto['p' + i]).toBe('v' + i); + } + }); +});