diff --git a/.cz-config.js b/.cz-config.js index 4da633892..96f406f0d 100644 --- a/.cz-config.js +++ b/.cz-config.js @@ -20,7 +20,6 @@ module.exports = { description: 'AutoMapper classes strategy', }, { name: 'pojos', description: 'AutoMapper pojos strategy' }, - { name: 'zod', description: 'AutoMapper zod strategy' }, { name: 'nestjs', description: 'AutoMapper nestjs integration))', diff --git a/.eslintrc.json b/.eslintrc.json index 12f3a13b3..35dbfcd80 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,12 +1,12 @@ { "root": true, "ignorePatterns": ["**/*"], - "plugins": ["@nrwl/nx"], + "plugins": ["@nx"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": { - "@nrwl/nx/enforce-module-boundaries": [ + "@nx/enforce-module-boundaries": [ "error", { "enforceBuildableLibDependency": true, @@ -23,13 +23,19 @@ }, { "files": ["*.ts", "*.tsx"], - "extends": ["plugin:@nrwl/nx/typescript"], + "extends": ["plugin:@nx/typescript"], "rules": {} }, { "files": ["*.js", "*.jsx"], - "extends": ["plugin:@nrwl/nx/javascript"], + "extends": ["plugin:@nx/javascript"], "rules": {} + }, + { + "files": ["**/vitest.config.ts", "vitest.shared.ts"], + "rules": { + "@nx/enforce-module-boundaries": "off" + } } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 854f60f24..0936c0428 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,20 +2,81 @@ name: CI on: push: - branches: - - main + branches: [main] pull_request: +env: + NX_VERBOSE_LOGGING: 'true' + NX_CLOUD_CONTINUOUS_ASSIGNMENT: 'true' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - main: - name: Nx Cloud - Main Job - uses: nrwl/ci/.github/workflows/nx-cloud-main.yml@v0.8 - with: - parallel-commands-on-agents: | - pnpm exec nx affected --target=test --parallel=3 --ci --code-coverage - # pnpm exec nx affected --target=package --parallel=2 - agents: - name: Nx Cloud - Agents - uses: nrwl/ci/.github/workflows/nx-cloud-agents.yml@v0.8 - with: - number-of-agents: 3 + verify: + name: sync · lint · typecheck · build · test (node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ['20', '22'] + env: + NX_CI_EXECUTION_ENV: 'node ${{ matrix.node }}' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + filter: tree:0 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - run: corepack enable + - run: pnpm install --frozen-lockfile + - name: Start Nx Agents + run: >- + pnpm nx start-ci-run + --distribute-on="3 linux-medium-node${{ matrix.node }}" + --stop-agents-after=lint,typecheck,package,test + # fail fast if tsconfig project references drift from the graph + - run: pnpm nx record -- nx sync:check + - run: pnpm nx run-many -t lint,typecheck,package,test + + package-contract: + name: publint + attw + runs-on: ubuntu-latest + env: + NX_CI_EXECUTION_ENV: 'publint + attw' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: corepack enable + - run: pnpm install --frozen-lockfile + - run: pnpm run package + - run: pnpm nx record -- pnpm run check:packages + + # The main verify job runs against nestjs 11 (the installed version). This job + # certifies the lower bound of @automapper/nestjs's `^10 || ^11` peer range by + # downgrading the @nestjs runtime to v10 and running the DI integration specs. + nestjs-compat: + name: nestjs 10 compat + runs-on: ubuntu-latest + env: + NX_CI_EXECUTION_ENV: 'nestjs-compat' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: corepack enable + - run: pnpm install --frozen-lockfile + # downgrade only the @nestjs runtime; rxjs/reflect-metadata span both majors + - run: pnpm --config.prefer-workspace-packages=true add -w @nestjs/common@^10 @nestjs/core@^10 @nestjs/testing@^10 @nestjs/platform-express@^10 + - name: Start Nx Agents + run: >- + pnpm nx start-ci-run + --distribute-on="3 linux-medium-node22" + --stop-agents-after=test + - run: pnpm nx run integration-test:test diff --git a/.github/workflows/docusaurus.yml b/.github/workflows/docusaurus.yml index 338885d6b..be8b9c1af 100644 --- a/.github/workflows/docusaurus.yml +++ b/.github/workflows/docusaurus.yml @@ -8,39 +8,45 @@ on: - 'packages/documentations/docusaurus.config.js' - 'packages/documentations/sidebars.js' - 'packages/documentations/*.json' + - 'packages/documentations/pnpm-lock.yaml' - 'packages/documentations/docs/**/*.md' - 'packages/documentations/docs/**/*.mdx' - - 'packages/documentations/static/**/*.*' + - 'packages/documentations/static/**/*' - 'packages/documentations/src/**/*.js' - 'packages/documentations/src/**/*.css' + - 'packages/*/src/**/*.ts' + - 'packages/*/tsconfig*.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'tsconfig.base.json' jobs: build-docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: persist-credentials: false fetch-depth: 0 # otherwise, you will failed to push refs to dest repo - - uses: pnpm/action-setup@v2.1.0 + - uses: actions/setup-node@v4 with: - version: latest - run_install: false + node-version: '20' - - uses: actions/setup-node@v2 - with: - node-version: '16' - cache: 'pnpm' + - run: corepack enable + + # TypeDoc reads the library sources through their workspace links. + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + # docs are not a workspace member (the workspace is scoped to the + # publishable libs), so install them standalone. - name: Install dependencies - run: | - pnpm install - pnpm --filter "./packages/documentations" install + working-directory: packages/documentations + run: pnpm install --ignore-workspace --frozen-lockfile - name: Create local build artifacts - run: | - echo 'Building documentations...' - pnpm exec nx build documentations + working-directory: packages/documentations + run: pnpm build - name: Create local changes and commit run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..8d9c38c7a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,59 @@ +name: Release + +on: + workflow_dispatch: + inputs: + first-release: + description: 'First release using the {projectName}@{version} tag scheme (no prior tags to diff against)' + type: boolean + default: false + dry-run: + description: 'Compute versions/changelogs without writing, tagging, or publishing' + type: boolean + default: false + +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: write # push version-bump commit + tags, create GitHub releases + id-token: write # npm provenance / OIDC trusted publishing + +jobs: + release: + name: nx release (alpha) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history is required for conventional-commit version analysis + - uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + - run: corepack enable + - run: pnpm install --frozen-lockfile + + # 1. bump versions, write changelogs, tag, create GitHub releases — but do not publish yet + - name: Version + changelog + tag + run: >- + pnpm exec nx release + ${{ inputs.first-release && '--first-release' || '' }} + ${{ inputs.dry-run && '--dry-run' || '' }} + --skip-publish --yes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # 2. build the dist artifacts with the freshly-bumped versions + - name: Build packages + if: ${{ !inputs.dry-run }} + run: pnpm run package + + # 3. publish the built dist to npm under the alpha dist-tag with provenance + - name: Publish (alpha, provenance) + if: ${{ !inputs.dry-run }} + run: pnpm exec nx release publish --tag=alpha + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml deleted file mode 100644 index 4c4a5e83d..000000000 --- a/.github/workflows/unit-test.yml +++ /dev/null @@ -1,43 +0,0 @@ -# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions - -name: Unit Test -on: - # Allow triggering this workflow manually via GitHub CLI/web - workflow_dispatch: - - push: - branches: [main] - paths: - - '**.ts' - - '**.js' - - 'package.json' - - 'package-lock.json' - - 'yarn.lock' - - pull_request: - branches: [main] - paths: - - '**.ts' - - '**.js' - - 'package.json' - - 'package-lock.json' - - 'yarn.lock' - -jobs: - jest: - runs-on: ${{matrix.os}} - - strategy: - matrix: - node-version: [14.x] - os: [ubuntu-latest, macos-latest] - - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - run: npm install - - run: npm run test diff --git a/.gitignore b/.gitignore index b889fdf8e..92471ba8f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,9 @@ /out-tsc # dependencies -/node_modules +node_modules +# per-package dist (per-package builds / workspace) +packages/*/dist # IDEs and editors /.idea @@ -40,3 +42,13 @@ Thumbs.db old .env + +.nx/cache +.nx/workspace-data +.claude/worktrees +.nx/polygraph +.nx/self-healing + +# local agent planning and visual brainstorming artifacts +.superpowers/ +docs/superpowers/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..a10195dfc --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +public-hoist-pattern[]=@nestjs/platform-express diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.nx/workflows/agents.yaml b/.nx/workflows/agents.yaml new file mode 100644 index 000000000..d20e4e6f5 --- /dev/null +++ b/.nx/workflows/agents.yaml @@ -0,0 +1,30 @@ +launch-templates: + linux-medium-node20: + resource-class: 'docker_linux_amd64/medium' + image: 'ubuntu22.04-node24.14-v1' + init-steps: + - name: Checkout + uses: 'nrwl/nx-cloud-workflows/v6/workflow-steps/checkout/main.yaml' + + - name: Install Node 20 + uses: 'nrwl/nx-cloud-workflows/v6/workflow-steps/install-node/main.yaml' + inputs: + node_version: '20' + + - name: Install Node Nodules + uses: 'nrwl/nx-cloud-workflows/v4/workflow-steps/install-node-modules/main.yaml' + + linux-medium-node22: + resource-class: 'docker_linux_amd64/medium' + image: 'ubuntu22.04-node24.14-v1' + init-steps: + - name: Checkout + uses: 'nrwl/nx-cloud-workflows/v6/workflow-steps/checkout/main.yaml' + + - name: Install Node 22 + uses: 'nrwl/nx-cloud-workflows/v6/workflow-steps/install-node/main.yaml' + inputs: + node_version: '22' + + - name: Install Node Nodules + uses: 'nrwl/nx-cloud-workflows/v4/workflow-steps/install-node-modules/main.yaml' diff --git a/.prettierignore b/.prettierignore index d0b804da2..fbbd81756 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,7 @@ /dist /coverage + +/.nx/cache +/.nx/workspace-data +.nx/self-healing \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts deleted file mode 100644 index 31b55d37a..000000000 --- a/jest.config.ts +++ /dev/null @@ -1,5 +0,0 @@ -const { getJestProjects } = require('@nrwl/jest'); - -export default { - projects: getJestProjects(), -}; diff --git a/jest.preset.js b/jest.preset.js deleted file mode 100644 index 4ed37a658..000000000 --- a/jest.preset.js +++ /dev/null @@ -1,20 +0,0 @@ -const nxPreset = require('@nrwl/jest/preset').default; - -module.exports = { - ...nxPreset, - extensionsToTreatAsEsm: ['.ts'], - globals: {}, - moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1', - }, - /* TODO: Update to latest Jest snapshotFormat - * By default Nx has kept the older style of Jest Snapshot formats - * to prevent breaking of any existing tests with snapshots. - * It's recommend you update to the latest format. - * You can do this by removing snapshotFormat property - * and running tests with --update-snapshot flag. - * Example: "nx affected --targets=test --update-snapshot" - * More info: https://jestjs.io/docs/upgrading-to-jest29#snapshot-format - */ - snapshotFormat: { escapeString: true, printBasicPrototype: true }, -}; diff --git a/nx.json b/nx.json index 308b5dc7d..7786c7fad 100644 --- a/nx.json +++ b/nx.json @@ -1,44 +1,61 @@ { "extends": "nx/presets/core.json", - "npmScope": "automapper", - "affected": { - "defaultBase": "main" - }, - "cli": { - "defaultCollection": "@nrwl/workspace" - }, - "tasksRunnerOptions": { - "default": { - "runner": "@nrwl/nx-cloud", - "options": { - "cacheableOperations": [ - "build", - "lint", - "test", - "e2e", - "package", - "package-lib" - ], - "accessToken": "MjMzYTA3OWMtMjQ1MS00YzFhLWExYTYtODMzMTM5MmQxZmE1fHJlYWQtd3JpdGU=" - } - } - }, "$schema": "./node_modules/nx/schemas/nx-schema.json", + "plugins": [ + { + "plugin": "@nx/js/typescript" + } + ], "namedInputs": { - "default": ["{projectRoot}/**/*"], - "prod": ["!{projectRoot}/**/*.spec.ts"] + "default": ["{projectRoot}/**/*", "shared"], + "prod": ["!{projectRoot}/**/*.spec.ts"], + "shared": ["{workspaceRoot}/.github/workflows/ci.yml"] }, "targetDefaults": { - "test": { - "inputs": ["default", "^prod"] + "build": { "cache": true }, + "package": { + "cache": true, + "inputs": [ + "default", + "^default", + "{workspaceRoot}/tools/scripts/build-packages.mjs" + ] }, - "lint": { - "inputs": ["default", "{workspaceRoot}/.eslintrc.json"] + "package-lib": { "cache": true }, + "test": { "inputs": ["default", "^prod"], "cache": true }, + "@nx/eslint:lint": { + "inputs": [ + "default", + "^default", + "{workspaceRoot}/.eslintrc.json", + "{workspaceRoot}/tools/eslint-rules/**/*" + ], + "cache": true } }, - "pluginsConfig": { - "@nrwl/js": { - "analyzeSourceFiles": true + "release": { + "projects": [ + "core", + "classes", + "pojos", + "nestjs", + "mikro", + "sequelize" + ], + "projectsRelationship": "independent", + "releaseTagPattern": "{projectName}@{version}", + "version": { + "conventionalCommits": true, + "preserveLocalDependencyProtocols": true + }, + "changelog": { + "automaticFromRef": true, + "projectChangelogs": { "createRelease": "github" } } - } + }, + "pluginsConfig": { "@nx/js": { "analyzeSourceFiles": true } }, + "defaultBase": "main", + "analytics": false, + "nxCloudId": "6a3e997e7427195a38f6bdbc", + "bust": "2" } diff --git a/package.json b/package.json index 7cbc27899..832d05801 100644 --- a/package.json +++ b/package.json @@ -1,73 +1,81 @@ { - "name": "automapper", - "version": "0.0.0", - "license": "MIT", - "scripts": { - "commit": "git-cz", - "contributors:init": "all-contributors init", - "contributors:add": "all-contributors add", - "release": "dotenv release-it --", - "release:beta": "dotenv release-it -- major --preRelease=beta", - "test": "nx run-many --all --target=test --parallel", - "package": "nx package-all core", - "publish": "nx run-many --target=publish --all --parallel" - }, - "private": true, - "dependencies": { - "@swc/helpers": "~0.4.11", - "tslib": "~2.5.0" - }, - "devDependencies": { - "@mikro-orm/core": "5.6.13", - "@nestjs/common": "10.0.3", - "@nestjs/core": "10.0.3", - "@nestjs/platform-express": "10.0.3", - "@nestjs/schematics": "10.0.1", - "@nestjs/testing": "10.0.3", - "@nrwl/devkit": "15.8.5", - "@nrwl/eslint-plugin-nx": "15.8.5", - "@nrwl/jest": "15.8.5", - "@nrwl/js": "15.8.5", - "@nrwl/linter": "15.8.5", - "@nrwl/nest": "15.8.5", - "@nrwl/nx-cloud": "15.2.0", - "@nrwl/rollup": "15.8.5", - "@nrwl/web": "15.8.5", - "@nrwl/workspace": "15.8.5", - "@release-it/bumper": "4.0.2", - "@release-it/conventional-changelog": "5.1.1", - "@swc/cli": "~0.1.55", - "@swc/core": "^1.2.173", - "@swc/jest": "0.2.20", - "@types/jest": "29.4.0", - "@types/node": "18.15.0", - "@types/supertest": "2.0.12", - "@typescript-eslint/eslint-plugin": "5.54.1", - "@typescript-eslint/parser": "5.54.1", - "all-contributors-cli": "6.24.0", - "babel-preset-minify": "0.5.2", - "commitizen": "4.3.0", - "cz-customizable": "7.0.0", - "dotenv-cli": "7.0.0", - "eslint": "8.35.0", - "eslint-config-prettier": "8.7.0", - "fs-extra": "^11.1.0", - "jest": "29.5.0", - "jest-environment-jsdom": "29.5.0", - "nx": "15.8.5", - "prettier": "2.8.4", - "reflect-metadata": "~0.1.13", - "release-it": "15.8.0", - "rxjs": "~7.8.0", - "sequelize": "6.29.2", - "supertest": "6.3.3", - "ts-jest": "29.0.5", - "ts-node": "10.9.1", - "typescript": "4.9.5" - }, - "config": { - "commitizen": { - "path": "./node_modules/cz-customizable" - } + "name": "automapper", + "version": "0.0.0", + "license": "MIT", + "packageManager": "pnpm@8.15.9", + "scripts": { + "commit": "git-cz", + "contributors:init": "all-contributors init", + "contributors:add": "all-contributors add", + "release": "dotenv release-it --", + "release:beta": "dotenv release-it -- major --preRelease=beta", + "test": "nx run-many --all --target=test --parallel", + "lint": "nx run-many --target=lint --parallel", + "typecheck": "nx run-many --target=typecheck --parallel", + "sync:check": "nx sync:check", + "package": "nx run-many --target=package", + "check:packages": "node tools/scripts/check-packages.mjs", + "publish": "nx run-many --target=publish --all --parallel" + }, + "private": true, + "dependencies": { + "@swc/helpers": "0.5.23", + "tslib": "~2.5.0" + }, + "devDependencies": { + "@mikro-orm/core": "^6.0.0", + "@nestjs/common": "11.1.27", + "@nestjs/core": "11.1.27", + "@nestjs/platform-express": "11.1.27", + "@nestjs/schematics": "11.1.0", + "@nestjs/testing": "11.1.27", + "@nx/devkit": "22.7.5", + "@nx/eslint": "22.7.5", + "@nx/eslint-plugin": "22.7.5", + "@nx/js": "22.7.5", + "@nx/nest": "22.7.5", + "@nx/web": "22.7.5", + "@nx/workspace": "22.7.5", + "@release-it/bumper": "4.0.2", + "@release-it/conventional-changelog": "5.1.1", + "@swc/cli": "0.7.10", + "@swc/core": "1.15.41", + "@types/node": "^22.10.0", + "@types/supertest": "2.0.12", + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@vitest/coverage-v8": "4.1.9", + "all-contributors-cli": "6.24.0", + "babel-preset-minify": "0.5.2", + "commitizen": "4.3.0", + "cz-customizable": "7.0.0", + "dotenv-cli": "7.0.0", + "eslint": "8.57.1", + "eslint-config-prettier": "10.1.8", + "fs-extra": "^11.1.0", + "nx": "22.7.5", + "prettier": "2.8.4", + "reflect-metadata": "~0.2.2", + "release-it": "15.8.0", + "rxjs": "~7.8.0", + "sequelize": "6.29.2", + "supertest": "6.3.3", + "ts-node": "10.9.1", + "tsdown": "^0.22.3", + "typescript": "npm:@typescript/typescript6@6.0.2", + "typescript-7": "npm:typescript@7.0.2", + "unplugin-swc": "1.5.9", + "vitest": "4.1.9" + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-customizable" } + }, + "pnpm": { + "overrides": { + "rxjs": "7.8.1", + "reflect-metadata": "0.2.2" + } + } } 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 new file mode 100644 index 000000000..cd1035dad --- /dev/null +++ b/packages/benchmark-core/package.json @@ -0,0 +1,23 @@ +{ + "name": "@automapper/benchmark-core", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Benchmark harness for @automapper/core hot path (map / mapArray)", + "scripts": { + "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:*", + "@automapper/classes": "workspace:*", + "@automapper/pojos": "workspace:*", + "reflect-metadata": "^0.2.0" + }, + "devDependencies": { + "mitata": "^1.0.0", + "tsx": "^4.19.0" + } +} 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 new file mode 100644 index 000000000..ade46e8df --- /dev/null +++ b/packages/benchmark-core/src/bench.ts @@ -0,0 +1,188 @@ +import 'reflect-metadata'; +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'; + +// =========================================================================== +// 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 makeProfile = (i: number) => ({ + id: `id-${i}`, + username: `user${i}`, + address: { street: `${i} Main St`, city: 'Town', zip: `${10000 + i}` }, + tags: ['a', 'b', 'c'], +}); + +const user = makeUser(1); +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 +// =========================================================================== +PojosMetadataMap.create('Address', { + street: String, + city: String, + zip: String, +}); +PojosMetadataMap.create('AddressDto', { + street: String, + city: String, + zip: String, +}); +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, +}); +PojosMetadataMap.create('Profile', { + id: String, + username: String, + address: 'Address', + tags: [String], +}); +PojosMetadataMap.create('ProfileDto', { + id: String, + username: String, + address: 'AddressDto', + tags: [String], +}); + +const pojosMapper = createMapper({ strategyInitializer: pojos() }); +createMap(pojosMapper, 'Address', 'AddressDto'); +createMap(pojosMapper, 'User', 'UserDto'); +createMap(pojosMapper, 'Profile', 'ProfileDto'); + +// =========================================================================== +// classes strategy (explicit @AutoMap types — esbuild/tsx has no +// 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; + @AutoMap(() => String) city!: string; + @AutoMap(() => String) zip!: string; +} +class AddressDto { + @AutoMap(() => String) street!: string; + @AutoMap(() => String) city!: string; + @AutoMap(() => String) zip!: string; +} +class Profile { + @AutoMap(() => String) id!: string; + @AutoMap(() => String) username!: string; + @AutoMap(() => Address) address!: Address; + @AutoMap(() => [String]) tags!: string[]; +} +class ProfileDto { + @AutoMap(() => String) id!: string; + @AutoMap(() => String) username!: string; + @AutoMap(() => AddressDto) address!: AddressDto; + @AutoMap(() => [String]) tags!: string[]; +} + +const classesMapper = createMapper({ strategyInitializer: classes() }); +createMap(classesMapper, Address, AddressDto); +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). +// 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', () => + pojosMapper.mapArray(users1000, 'User', 'UserDto') + ); +}); +group('classes / flat (8 primitive members)', () => { + bench('map x1', () => classesMapper.map(user, User, UserDto)); + bench('mapArray x1000', () => + classesMapper.mapArray(users1000, User, UserDto) + ); +}); +group('pojos / nested (object member + array)', () => { + bench('map x1', () => pojosMapper.map(profile, 'Profile', 'ProfileDto')); + bench('mapArray x1000', () => + pojosMapper.mapArray(profiles1000, 'Profile', 'ProfileDto') + ); +}); +group('classes / nested (object member + array)', () => { + bench('map x1', () => classesMapper.map(profile, Profile, ProfileDto)); + bench('mapArray x1000', () => + classesMapper.mapArray(profiles1000, Profile, ProfileDto) + ); +}); + +// --- 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/benchmark-core/tsconfig.json b/packages/benchmark-core/tsconfig.json new file mode 100644 index 000000000..9bfdcee1d --- /dev/null +++ b/packages/benchmark-core/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "../../dist/out-tsc/benchmark-core", + "composite": true, + "declaration": true, + "target": "es2020", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "useDefineForClassFields": false, + "types": ["node", "reflect-metadata"] + }, + "include": ["src/**/*.ts"], + "references": [] +} diff --git a/packages/classes/jest.config.cjs b/packages/classes/jest.config.cjs deleted file mode 100644 index 21ace1d4c..000000000 --- a/packages/classes/jest.config.cjs +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = { - displayName: 'classes', - preset: '../../jest.preset.js', - globals: {}, - transform: { - '^.+\\.[tj]s$': [ - 'ts-jest', - { - tsconfig: '/tsconfig.spec.json', - }, - ], - }, - moduleFileExtensions: ['ts', 'js', 'html'], - coverageDirectory: '../../coverage/packages/classes', -}; diff --git a/packages/classes/mapped-types/jest.config.cjs b/packages/classes/mapped-types/jest.config.cjs deleted file mode 100644 index b5bbdda0b..000000000 --- a/packages/classes/mapped-types/jest.config.cjs +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = { - displayName: 'classes-mapped-types', - preset: '../../../jest.preset.js', - globals: {}, - transform: { - '^.+\\.[tj]s$': [ - 'ts-jest', - { - tsconfig: '/tsconfig.spec.json', - }, - ], - }, - moduleFileExtensions: ['ts', 'js', 'html'], - coverageDirectory: '../../../coverage/packages/classes/mapped-types', -}; diff --git a/packages/classes/mapped-types/package.json b/packages/classes/mapped-types/package.json index 33031d075..4840c6237 100644 --- a/packages/classes/mapped-types/package.json +++ b/packages/classes/mapped-types/package.json @@ -1,14 +1,14 @@ { "name": "@automapper/classes/mapped-types", - "version": "0.0.0", + "version": "9.0.0-alpha.0", "type": "module", "sideEffects": false, "publishConfig": { "access": "public" }, "peerDependencies": { - "@automapper/core": "latest", - "@automapper/classes": "latest" + "@automapper/core": "^9.0.0-alpha.0", + "@automapper/classes": "^9.0.0-alpha.0" }, "repository": { "type": "git", diff --git a/packages/classes/mapped-types/project.json b/packages/classes/mapped-types/project.json index 055b83210..31a958cac 100644 --- a/packages/classes/mapped-types/project.json +++ b/packages/classes/mapped-types/project.json @@ -2,47 +2,19 @@ "name": "classes-mapped-types", "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "packages/classes/mapped-types/src", + "tags": [ + "classes" + ], "targets": { - "package": { - "command": "NX_CLOUD=true nx package-lib classes-mapped-types" - }, - "package-lib": { - "executor": "@nrwl/rollup:rollup", - "outputs": ["{options.outputPath}"], - "options": { - "project": "packages/classes/mapped-types/package.json", - "outputPath": "dist/packages/classes/mapped-types", - "entryFile": "packages/classes/mapped-types/src/index.ts", - "tsConfig": "packages/classes/mapped-types/tsconfig.lib.json", - "assets": ["packages/classes/mapped-types/src/README.md"], - "compiler": "babel", - "external": [ - "typescript", - "tslib", - "@automapper/core", - "@automapper/classes" - ], - "format": ["cjs", "esm"], - "updateBuildableProjectDepsInPackageJson": false - } - }, "lint": { - "executor": "@nrwl/linter:eslint", - "outputs": ["{options.outputFile}"], - "options": { - "lintFilePatterns": ["packages/classes/mapped-types/**/*.ts"] - } + "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nrwl/jest:jest", - "outputs": [ - "{workspaceRoot}/coverage/packages/classes/mapped-types" - ], + "executor": "nx:run-commands", "options": { - "jestConfig": "packages/classes/mapped-types/jest.config.cjs", - "passWithNoTests": true + "command": "vitest run", + "cwd": "packages/classes/mapped-types" } } - }, - "tags": ["classes"] + } } diff --git a/packages/classes/mapped-types/src/lib/type-helper.ts b/packages/classes/mapped-types/src/lib/type-helper.ts index 8ac722ad8..3800f779e 100644 --- a/packages/classes/mapped-types/src/lib/type-helper.ts +++ b/packages/classes/mapped-types/src/lib/type-helper.ts @@ -7,8 +7,7 @@ import { AutoMapperLogger } from '@automapper/core'; export function inheritAutoMapMetadata( parentClass: Constructor, - // eslint-disable-next-line @typescript-eslint/ban-types - targetClass: Function, + targetClass: Constructor, isPropertyInherited: (key: string) => boolean = () => true ) { try { @@ -17,9 +16,7 @@ export function inheritAutoMapMetadata( return; } - const [existingMetadataList] = getMetadataList( - targetClass as Constructor - ); + const [existingMetadataList] = getMetadataList(targetClass); Reflect.defineMetadata( AUTOMAP_PROPERTIES_METADATA_KEY, [ diff --git a/packages/classes/mapped-types/tsconfig.lib.json b/packages/classes/mapped-types/tsconfig.lib.json index cf95ed0b1..b092e1328 100644 --- a/packages/classes/mapped-types/tsconfig.lib.json +++ b/packages/classes/mapped-types/tsconfig.lib.json @@ -1,10 +1,19 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../../dist/out-tsc", + "outDir": "../../../dist/out-tsc/classes-mapped-types/lib", + "composite": true, "declaration": true, "types": [] }, - "include": ["**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts"] + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts", "**/*.test.ts"], + "references": [ + { + "path": "../tsconfig.lib.json" + }, + { + "path": "../../core/tsconfig.lib.json" + } + ] } diff --git a/packages/classes/mapped-types/tsconfig.spec.json b/packages/classes/mapped-types/tsconfig.spec.json index b7fd60494..a32776794 100644 --- a/packages/classes/mapped-types/tsconfig.spec.json +++ b/packages/classes/mapped-types/tsconfig.spec.json @@ -1,9 +1,29 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../../dist/out-tsc", + "outDir": "../../../dist/out-tsc/classes-mapped-types/spec", "module": "commonjs", - "types": ["jest", "node"] + "composite": true, + "declaration": true, + "types": [ + "vitest/globals", + "node" + ] }, - "include": ["**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] + "include": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/*.d.ts" + ], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "../tsconfig.lib.json" + }, + { + "path": "../../core/tsconfig.lib.json" + } + ] } diff --git a/packages/classes/mapped-types/vitest.config.ts b/packages/classes/mapped-types/vitest.config.ts new file mode 100644 index 000000000..f64bb550d --- /dev/null +++ b/packages/classes/mapped-types/vitest.config.ts @@ -0,0 +1,3 @@ +import { vitestConfig } from '../../../vitest.shared'; + +export default vitestConfig(); diff --git a/packages/classes/package.json b/packages/classes/package.json index 7df472816..73e4b53d3 100644 --- a/packages/classes/package.json +++ b/packages/classes/package.json @@ -1,34 +1,33 @@ { "name": "@automapper/classes", - "version": "0.0.0", + "version": "9.0.0-alpha.0", "type": "module", "peerDependencies": { - "@automapper/core": "latest", - "reflect-metadata": "~0.1.13" + "@automapper/core": "^9.0.0-alpha.0", + "reflect-metadata": "^0.1.14 || ^0.2.0", + "typescript": "^6.0.0" }, - "exports": { - ".": { - "types": "./src/index.d.ts", - "import": "./index.js", - "require": "./index.cjs" - }, - "./transformer-plugin": { - "types": "./transformer-plugin/index.d.ts", - "import": "./transformer-plugin/index.js", - "require": "./transformer-plugin/index.cjs" - }, - "./mapped-types": { - "types": "./mapped-types/index.d.ts", - "import": "./mapped-types/index.js", - "require": "./mapped-types/index.cjs" + "peerDependenciesMeta": { + "typescript": { + "optional": true } }, + "exports": { + ".": "./src/index.ts", + "./mapped-types": "./mapped-types/src/index.ts", + "./transformer-plugin": "./transformer-plugin/src/index.ts" + }, + "devDependencies": { + "@automapper/classes": "workspace:*", + "typescript": "npm:@typescript/typescript6@6.0.2" + }, "sideEffects": false, "engines": { - "node": ">=16.0.0" + "node": ">=20.0.0" }, "publishConfig": { - "access": "public" + "access": "public", + "provenance": true }, "repository": { "type": "git", diff --git a/packages/classes/project.json b/packages/classes/project.json index 66dd4e970..998e7b84b 100644 --- a/packages/classes/project.json +++ b/packages/classes/project.json @@ -3,54 +3,41 @@ "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "packages/classes/src", "projectType": "library", + "tags": [ + "classes" + ], "targets": { "package": { "executor": "nx:run-commands", + "outputs": [ + "{workspaceRoot}/dist/packages/classes" + ], "options": { - "commands": [ - "nx package-lib classes", - "tsc packages/classes/shim/index.ts --outDir dist/packages/classes/shim/" - ], - "parallel": false - } - }, - "package-lib": { - "executor": "@nrwl/rollup:rollup", - "outputs": ["{options.outputPath}"], - "options": { - "project": "packages/classes/package.json", - "outputPath": "dist/packages/classes", - "entryFile": "packages/classes/src/index.ts", - "tsConfig": "packages/classes/tsconfig.lib.json", - "assets": ["packages/classes/src/README.md"], - "compiler": "babel", - "external": ["typescript", "tslib", "@automapper/core"], - "format": ["cjs", "esm"], - "updateBuildableProjectDepsInPackageJson": false + "command": "node tools/scripts/build-packages.mjs classes" } }, "lint": { - "executor": "@nrwl/linter:eslint", - "outputs": ["{options.outputFile}"], - "options": { - "lintFilePatterns": ["packages/classes/**/*.ts"] - } + "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nrwl/jest:jest", - "outputs": ["{workspaceRoot}/coverage/packages/classes"], + "executor": "nx:run-commands", "options": { - "jestConfig": "packages/classes/jest.config.cjs", - "passWithNoTests": true + "command": "vitest run", + "cwd": "packages/classes" } }, "publish": { "executor": "nx:run-commands", "options": { - "command": "npm publish", + "command": "npm publish --tag alpha", "cwd": "dist/packages/classes" } + }, + "nx-release-publish": { + "executor": "@nx/js:release-publish", + "options": { + "packageRoot": "dist/packages/classes" + } } - }, - "tags": ["classes"] + } } diff --git a/packages/classes/src/lib/automap.ts b/packages/classes/src/lib/automap.ts index 8ebfc35af..3ce9b33f0 100644 --- a/packages/classes/src/lib/automap.ts +++ b/packages/classes/src/lib/automap.ts @@ -1,4 +1,4 @@ -import type { Constructor } from '@automapper/core'; +import type { ClassIdentifier } from '@automapper/core'; import { AutoMapperLogger } from '@automapper/core'; import 'reflect-metadata'; import { AUTOMAP_PROPERTIES_METADATA_KEY } from './keys'; @@ -7,7 +7,7 @@ export interface AutoMapOptions { /** * Type Function */ - type?: () => Constructor | [Constructor]; + type?: () => ClassIdentifier | [ClassIdentifier]; /** * Depth for nested models. Default to 1 */ @@ -20,20 +20,16 @@ export interface AutoMapOptions { export function AutoMap(): PropertyDecorator; export function AutoMap( - typeFn: () => Constructor | [Constructor] + typeFn: () => ClassIdentifier | [ClassIdentifier] ): PropertyDecorator; export function AutoMap(options: AutoMapOptions): PropertyDecorator; export function AutoMap( - typeFnOrOptions?: (() => Constructor | [Constructor]) | AutoMapOptions + typeFnOrOptions?: + | (() => ClassIdentifier | [ClassIdentifier]) + | AutoMapOptions ): 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', @@ -77,16 +73,33 @@ 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]); }; } function getAutoMapOptions( - typeFnOrOptions?: (() => Constructor | [Constructor]) | AutoMapOptions + typeFnOrOptions?: + | (() => ClassIdentifier | [ClassIdentifier]) + | AutoMapOptions ): AutoMapOptions { if (typeFnOrOptions === undefined) { return { depth: 1, isGetterOnly: undefined, type: undefined }; diff --git a/packages/classes/src/lib/classes.ts b/packages/classes/src/lib/classes.ts index ccbd3a185..b2c576ca0 100644 --- a/packages/classes/src/lib/classes.ts +++ b/packages/classes/src/lib/classes.ts @@ -13,7 +13,7 @@ import { getMetadataList } from './get-metadata-list'; export function classes( options: MappingStrategyInitializerOptions = {} -): MappingStrategyInitializer { +): MappingStrategyInitializer { const { destinationConstructor = ( _: Dictionary, diff --git a/packages/classes/src/lib/get-metadata-list.ts b/packages/classes/src/lib/get-metadata-list.ts index da59080d7..cf8c4cb94 100644 --- a/packages/classes/src/lib/get-metadata-list.ts +++ b/packages/classes/src/lib/get-metadata-list.ts @@ -1,4 +1,4 @@ -import type { Constructor } from '@automapper/core'; +import type { ClassIdentifier, MetadataIdentifier } from '@automapper/core'; import { isDateConstructor, isPrimitiveConstructor } from '@automapper/core'; import 'reflect-metadata'; import { @@ -10,33 +10,38 @@ type MetadataList = Array< [ string, { - type: () => Constructor | [Constructor]; + type: () => ClassIdentifier | [ClassIdentifier]; depth: number; isGetterOnly?: boolean; } ] >; -export function getMetadataList(model: Constructor): [ +export function getMetadataList(model: MetadataIdentifier): [ metadataList: [ string, { - type: () => Constructor; + type: () => MetadataIdentifier; isArray: boolean; depth: number; isGetterOnly?: boolean; } ][], - nestedConstructor: Constructor[] + nestedConstructor: MetadataIdentifier[] ] { + if (typeof model !== 'function') { + 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) { @@ -70,13 +75,13 @@ export function getMetadataList(model: Constructor): [ metadataList: [ string, { - type: () => Constructor; + type: () => MetadataIdentifier; isArray: boolean; depth: number; isGetterOnly?: boolean; } ][], - nestedConstructor: Constructor[] + nestedConstructor: MetadataIdentifier[] ] ); } diff --git a/packages/classes/src/lib/specs/automap.spec.ts b/packages/classes/src/lib/specs/automap.spec.ts index ad8d68bf6..3277a97f1 100644 --- a/packages/classes/src/lib/specs/automap.spec.ts +++ b/packages/classes/src/lib/specs/automap.spec.ts @@ -3,8 +3,8 @@ import { AUTOMAP_PROPERTIES_METADATA_KEY } from '../keys'; import { AutoMap } from '../automap'; describe(AutoMap.name, () => { - const spiedReflectDefine = jest.spyOn(Reflect, 'defineMetadata'); - const spiedReflectGet = jest.spyOn(Reflect, 'getMetadata'); + const spiedReflectDefine = vi.spyOn(Reflect, 'defineMetadata'); + const spiedReflectGet = vi.spyOn(Reflect, 'getMetadata'); describe('primitives', () => { class Bar { diff --git a/packages/classes/transformer-plugin/jest.config.cjs b/packages/classes/transformer-plugin/jest.config.cjs deleted file mode 100644 index 731c44286..000000000 --- a/packages/classes/transformer-plugin/jest.config.cjs +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = { - displayName: 'classes-transformer-plugin', - preset: '../../../jest.preset.js', - globals: {}, - transform: { - '^.+\\.[tj]s$': [ - 'ts-jest', - { - tsconfig: '/tsconfig.spec.json', - }, - ], - }, - moduleFileExtensions: ['ts', 'js', 'html'], - coverageDirectory: '../../../coverage/packages/classes/transformer-plugin', -}; diff --git a/packages/classes/transformer-plugin/package.json b/packages/classes/transformer-plugin/package.json index 934580192..7e07eb764 100644 --- a/packages/classes/transformer-plugin/package.json +++ b/packages/classes/transformer-plugin/package.json @@ -1,14 +1,14 @@ { "name": "@automapper/classes/transformer-plugin", - "version": "0.0.0", + "version": "9.0.0-alpha.0", "type": "module", "sideEffects": false, "publishConfig": { "access": "public" }, "peerDependencies": { - "@automapper/core": "latest", - "@automapper/classes": "latest" + "@automapper/core": "^9.0.0-alpha.0", + "@automapper/classes": "^9.0.0-alpha.0" }, "repository": { "type": "git", diff --git a/packages/classes/transformer-plugin/project.json b/packages/classes/transformer-plugin/project.json index 9518aca62..f03a63705 100644 --- a/packages/classes/transformer-plugin/project.json +++ b/packages/classes/transformer-plugin/project.json @@ -2,44 +2,19 @@ "name": "classes-transformer-plugin", "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "packages/classes/transformer-plugin/src", + "tags": [ + "classes" + ], "targets": { - "package": { - "command": "NX_CLOUD=true nx package-lib classes-transformer-plugin" - }, - "package-lib": { - "executor": "@nrwl/rollup:rollup", - "outputs": ["{options.outputPath}"], - "options": { - "project": "packages/classes/transformer-plugin/package.json", - "outputPath": "dist/packages/classes/transformer-plugin", - "entryFile": "packages/classes/transformer-plugin/src/index.ts", - "tsConfig": "packages/classes/transformer-plugin/tsconfig.lib.json", - "assets": ["packages/classes/transformer-plugin/src/README.md"], - "compiler": "babel", - "external": ["typescript", "tslib", "@automapper/classes"], - "format": ["cjs", "esm"], - "updateBuildableProjectDepsInPackageJson": false - } - }, "lint": { - "executor": "@nrwl/linter:eslint", - "outputs": ["{options.outputFile}"], - "options": { - "lintFilePatterns": [ - "packages/classes/transformer-plugin/**/*.ts" - ] - } + "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nrwl/jest:jest", - "outputs": [ - "{workspaceRoot}/coverage/packages/classes/transformer-plugin" - ], + "executor": "nx:run-commands", "options": { - "jestConfig": "packages/classes/transformer-plugin/jest.config.cjs", - "passWithNoTests": true + "command": "vitest run", + "cwd": "packages/classes/transformer-plugin" } } - }, - "tags": ["classes"] + } } diff --git a/packages/classes/transformer-plugin/src/README.md b/packages/classes/transformer-plugin/src/README.md index 576b351d4..02e8d72e4 100644 --- a/packages/classes/transformer-plugin/src/README.md +++ b/packages/classes/transformer-plugin/src/README.md @@ -278,7 +278,7 @@ In Nx workspaces, NestJS applications and buildable libraries are handled with ` "lib": { "targets": { "build": { - "executor": "@nrwl/js:tsc", + "executor": "@nx/js:tsc", "options": { "transformers": [ { diff --git a/packages/classes/transformer-plugin/src/index.ts b/packages/classes/transformer-plugin/src/index.ts index bd775c934..a37fa4d02 100644 --- a/packages/classes/transformer-plugin/src/index.ts +++ b/packages/classes/transformer-plugin/src/index.ts @@ -2,7 +2,7 @@ import type { Program, SourceFile, TransformationContext, -} from 'typescript/lib/tsserverlibrary'; +} from 'typescript'; import { ModelVisitor } from './lib/model-visitor'; import type { AutomapperTransformerPluginOptions } from './lib/options'; import { isFilenameMatched } from './lib/utils'; diff --git a/packages/classes/transformer-plugin/src/lib/model-visitor.ts b/packages/classes/transformer-plugin/src/lib/model-visitor.ts index 33f3ea74f..225ac8d3f 100644 --- a/packages/classes/transformer-plugin/src/lib/model-visitor.ts +++ b/packages/classes/transformer-plugin/src/lib/model-visitor.ts @@ -25,11 +25,12 @@ import { isGetAccessorDeclaration, isImportDeclaration, isPropertyDeclaration, + isSourceFile, ModuleKind, SyntaxKind, visitEachChild, visitNode, -} from 'typescript/lib/tsserverlibrary'; +} from 'typescript'; import { AUTOMAP_IGNORE_TAG, AUTOMAPPER_DECORATOR_NAME, @@ -152,10 +153,13 @@ export class ModelVisitor { return nodeVisitor; } + // TS 5.x: visitNode returns `Node | undefined` and needs a type guard + // to narrow back to SourceFile. const visitedSourceFile = visitNode( sourceFile, - nodeVisitorFactory(context, sourceFile) - ); + nodeVisitorFactory(context, sourceFile), + isSourceFile + ) as SourceFile; // if the target is CommonJS, keep as is if (ModelVisitor.isCommonJS) { diff --git a/packages/classes/transformer-plugin/src/lib/utils.spec.ts b/packages/classes/transformer-plugin/src/lib/utils.spec.ts new file mode 100644 index 000000000..31a410fee --- /dev/null +++ b/packages/classes/transformer-plugin/src/lib/utils.spec.ts @@ -0,0 +1,30 @@ +import { replaceImportPath } from './utils'; + +describe(replaceImportPath.name, () => { + it('should rewrite POSIX absolute import references to relative require references', () => { + expect( + replaceImportPath( + 'import("/repo/src/models/user.model").UserModel', + '/repo/src/dtos/user.dto.ts' + ) + ).toEqual('require("../models/user.model").UserModel'); + }); + + it('should rewrite Windows absolute import references to normalized relative require references', () => { + expect( + replaceImportPath( + 'import("C:\\repo\\src\\models\\user.model").UserModel', + 'C:\\repo\\src\\dtos\\user.dto.ts' + ) + ).toEqual('require("../models/user.model").UserModel'); + }); + + it('should keep package import references as package specifiers', () => { + expect( + replaceImportPath( + 'import("@scope/pkg/models").UserModel', + '/repo/src/dtos/user.dto.ts' + ) + ).toEqual('require("@scope/pkg/models").UserModel'); + }); +}); diff --git a/packages/classes/transformer-plugin/src/lib/utils.ts b/packages/classes/transformer-plugin/src/lib/utils.ts index c5d93a590..9e020d862 100644 --- a/packages/classes/transformer-plugin/src/lib/utils.ts +++ b/packages/classes/transformer-plugin/src/lib/utils.ts @@ -1,4 +1,4 @@ -import { dirname, posix } from 'path'; +import { posix } from 'path'; import { ArrayTypeNode, CallExpression, @@ -19,7 +19,7 @@ import { TypeFormatFlags, TypeNode, TypeReference, -} from 'typescript/lib/tsserverlibrary'; +} from 'typescript'; export function isFilenameMatched( patterns: string[], @@ -106,24 +106,38 @@ export function replaceImportPath( typeReference: string, fileName: string ): string | undefined { - let importPath = /\("([^)]).+(")/.exec(typeReference)?.[0]; + const importPath = /import\("([^"]+)"\)/.exec(typeReference)?.[1]; if (!importPath) { return undefined; } - if (process.platform === 'win32') { - return typeReference.replace('import', 'require') + if (!isFilePathImport(importPath)) { + return typeReference.replace('import', 'require'); } - importPath = importPath.slice(2, importPath.length - 1); - - let relativePath = posix.relative(dirname(fileName), importPath); + let relativePath = posix.relative( + posix.dirname(normalizePath(fileName)), + normalizePath(importPath) + ); relativePath = relativePath[0] !== '.' ? './' + relativePath : relativePath; typeReference = typeReference.replace(importPath, relativePath); return typeReference.replace('import', 'require'); } +function normalizePath(path: string): string { + return path.replace(/\\/g, '/'); +} + +function isFilePathImport(path: string): boolean { + return ( + path.startsWith('.') || + path.startsWith('/') || + /^[A-Za-z]:[\\/]/.test(path) || + path.includes('\\') + ); +} + function hasFlag(type: Type, flag: TypeFlags): boolean { return (type.flags & flag) === flag; } diff --git a/packages/classes/transformer-plugin/tsconfig.lib.json b/packages/classes/transformer-plugin/tsconfig.lib.json index c1199aa68..f432ecda7 100644 --- a/packages/classes/transformer-plugin/tsconfig.lib.json +++ b/packages/classes/transformer-plugin/tsconfig.lib.json @@ -1,10 +1,19 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../../dist/out-tsc", + "outDir": "../../../dist/out-tsc/classes-transformer-plugin/lib", + "composite": true, "declaration": true, "types": ["node"] }, - "include": ["**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts"] + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts", "**/*.test.ts"], + "references": [ + { + "path": "../tsconfig.lib.json" + }, + { + "path": "../../core/tsconfig.lib.json" + } + ] } diff --git a/packages/classes/transformer-plugin/tsconfig.spec.json b/packages/classes/transformer-plugin/tsconfig.spec.json index b7fd60494..2cde91d91 100644 --- a/packages/classes/transformer-plugin/tsconfig.spec.json +++ b/packages/classes/transformer-plugin/tsconfig.spec.json @@ -1,9 +1,23 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../../dist/out-tsc", + "outDir": "../../../dist/out-tsc/classes-transformer-plugin/spec", "module": "commonjs", - "types": ["jest", "node"] + "composite": true, + "declaration": true, + "types": [ + "vitest/globals", + "node" + ] }, - "include": ["**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] + "include": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/*.d.ts" + ], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] } diff --git a/packages/classes/transformer-plugin/vitest.config.ts b/packages/classes/transformer-plugin/vitest.config.ts new file mode 100644 index 000000000..f64bb550d --- /dev/null +++ b/packages/classes/transformer-plugin/vitest.config.ts @@ -0,0 +1,3 @@ +import { vitestConfig } from '../../../vitest.shared'; + +export default vitestConfig(); diff --git a/packages/classes/tsconfig.lib.json b/packages/classes/tsconfig.lib.json index 916952904..77aa887cd 100644 --- a/packages/classes/tsconfig.lib.json +++ b/packages/classes/tsconfig.lib.json @@ -1,16 +1,22 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../dist/out-tsc", + "outDir": "../../dist/out-tsc/classes/lib", + "composite": true, "declaration": true, "types": ["node"] }, - "include": ["**/*.ts"], + "include": ["src/**/*.ts"], "exclude": [ "**/*.spec.ts", "**/*.test.ts", "transformer-plugin/**/*", "shim/**/*", "mapped-types/**/*" + ], + "references": [ + { + "path": "../core/tsconfig.lib.json" + } ] } diff --git a/packages/classes/tsconfig.spec.json b/packages/classes/tsconfig.spec.json index eb1304a88..fe0850d31 100644 --- a/packages/classes/tsconfig.spec.json +++ b/packages/classes/tsconfig.spec.json @@ -1,9 +1,28 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../dist/out-tsc", + "outDir": "../../dist/out-tsc/classes/spec", "module": "commonjs", - "types": ["jest", "node"] + "composite": true, + "declaration": true, + "types": [ + "vitest/globals", + "node" + ] }, - "include": ["**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] + "include": [ + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts", + "shim/**/*.ts" + ], + "exclude": [ + "transformer-plugin/**/*", + "mapped-types/**/*" + ], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] } diff --git a/packages/classes/vitest.config.ts b/packages/classes/vitest.config.ts new file mode 100644 index 000000000..87e7ee687 --- /dev/null +++ b/packages/classes/vitest.config.ts @@ -0,0 +1,3 @@ +import { vitestConfig } from '../../vitest.shared'; + +export default vitestConfig(); diff --git a/packages/core/jest.config.cjs b/packages/core/jest.config.cjs deleted file mode 100644 index 15f285a90..000000000 --- a/packages/core/jest.config.cjs +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = { - displayName: 'core', - preset: '../../jest.preset.js', - transform: { - '^.+\\.[tj]s$': 'ts-jest', - }, - moduleFileExtensions: ['ts', 'js', 'html'], - coverageDirectory: '../../coverage/packages/core', -}; diff --git a/packages/core/package.json b/packages/core/package.json index 0c3a3f71c..2e631095d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,13 +1,17 @@ { "name": "@automapper/core", - "version": "0.0.0", + "version": "9.0.0-alpha.0", "type": "module", "sideEffects": false, + "exports": { + ".": "./src/index.ts" + }, "engines": { - "node": ">=16.0.0" + "node": ">=20.0.0" }, "publishConfig": { - "access": "public" + "access": "public", + "provenance": true }, "repository": { "type": "git", diff --git a/packages/core/project.json b/packages/core/project.json index a89fb177f..f4a99ec97 100644 --- a/packages/core/project.json +++ b/packages/core/project.json @@ -3,63 +3,41 @@ "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "packages/core/src", "projectType": "library", + "tags": [ + "core" + ], "targets": { "package": { - "command": "NX_CLOUD=true nx package-lib core" - }, - "package-lib": { - "executor": "@nrwl/rollup:rollup", - "outputs": ["{options.outputPath}"], - "options": { - "project": "packages/core/package.json", - "outputPath": "dist/packages/core", - "entryFile": "packages/core/src/index.ts", - "tsConfig": "packages/core/tsconfig.lib.json", - "assets": ["packages/core/src/README.md"], - "compiler": "babel", - "external": ["typescript", "tslib"], - "format": ["cjs", "esm"], - "updateBuildableProjectDepsInPackageJson": false - } - }, - "package-all": { "executor": "nx:run-commands", + "outputs": [ + "{workspaceRoot}/dist/packages/core" + ], "options": { - "commands": [ - "nx package core", - "nx package classes", - "nx package classes-mapped-types", - "nx package classes-transformer-plugin", - "nx package pojos", - "nx package nestjs", - "nx package mikro", - "nx package sequelize" - ], - "parallel": false + "command": "node tools/scripts/build-packages.mjs core" } }, "lint": { - "executor": "@nrwl/linter:eslint", - "outputs": ["{options.outputFile}"], - "options": { - "lintFilePatterns": ["packages/core/**/*.ts"] - } + "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nrwl/jest:jest", - "outputs": ["{workspaceRoot}/coverage/packages/core"], + "executor": "nx:run-commands", "options": { - "jestConfig": "packages/core/jest.config.cjs", - "passWithNoTests": true + "command": "vitest run", + "cwd": "packages/core" } }, "publish": { "executor": "nx:run-commands", "options": { - "command": "npm publish", + "command": "npm publish --tag alpha", "cwd": "dist/packages/core" } + }, + "nx-release-publish": { + "executor": "@nx/js:release-publish", + "options": { + "packageRoot": "dist/packages/core" + } } - }, - "tags": ["core"] + } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cc3c3486d..27cfc712d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,11 +1,13 @@ export * from './lib/core'; export * from './lib/types'; +export * from './lib/errors'; export * from './lib/default-strategy-initializer-options'; export * from './lib/utils/recursion'; export * from './lib/utils/is-empty'; export * from './lib/utils/is-primitive-constructor'; export * from './lib/utils/is-date-constructor'; +export * from './lib/utils/is-mappable-identifier'; export * from './lib/utils/set'; export * from './lib/utils/logger'; @@ -18,6 +20,8 @@ export * from './lib/mapping-configurations/type-converters'; export * from './lib/mapping-configurations/construct-using'; export * from './lib/mapping-configurations/before-map'; export * from './lib/mapping-configurations/after-map'; +export * from './lib/mapping-configurations/before-map-array'; +export * from './lib/mapping-configurations/after-map-array'; export * from './lib/mapping-configurations/extend'; export * from './lib/mapping-configurations/naming-conventions'; export * from './lib/mapping-configurations/auto-map'; diff --git a/packages/core/src/lib/core.ts b/packages/core/src/lib/core.ts index 041d55f19..ba079e4c9 100644 --- a/packages/core/src/lib/core.ts +++ b/packages/core/src/lib/core.ts @@ -1,4 +1,15 @@ -import { mapMutate, mapReturn } from './mappings/map'; +import { + deferAsyncAfterMapAfterPending, + getAsyncMapContext, + isAsyncMapActive, + isThenable, + mapMutate, + mapReturn, + pushAsyncPending, + runInAsyncMapContext, + runAsyncMap, + settleAsyncMap, +} from './mappings/map'; import { CUSTOM_NODE_INSPECT, ERROR_HANDLER, @@ -26,6 +37,7 @@ import type { ModelIdentifier, NamingConventionInput, } from './types'; +import { MappingCallbacksClassId, MappingClassId } from './types'; import { getMapping } from './utils/get-mapping'; import { AutoMapperLogger } from './utils/logger'; @@ -36,7 +48,7 @@ export interface CreateMapperOptions { } /** - * Creates and returns a Mapper {} as a Proxy. The following methods are available to use with a Mapper: + * Creates and returns a Mapper {}. The following methods are available to use with a Mapper: * ``` * - Mapper#map(Array)(Async), Mapper#mutate(Array)(Async) * - createMap() @@ -53,25 +65,30 @@ export function createMapper({ }: CreateMapperOptions): Mapper { let strategy: MappingStrategy; - // this mapper is responsible for all mappings - let mappings: Map>; - - // this mapper is responsible for all metadata - let metadataMap: Map>; - let metadataObjectMap: Map< + // all mapper state, created eagerly (cheap, always needed) so the mapper can + // be a plain object with direct property access instead of a Proxy get-trap. + const mappings: Map< + MetadataIdentifier, + Map + > = new Map(); + const metadataMap: Map> = new Map(); + const metadataObjectMap: Map< MetadataIdentifier, [ asSource?: Record, asDestination?: Record ] - >; - - // this mapper is responsible for recursive depths and counts - let recursiveDepth: Map; - let recursiveCount: Map; - - // this mapper is tracking some context about the MappingProfile - let profileConfigurationContext: Set; + > = new Map(); + const recursiveDepth: Map = new Map(); + const recursiveCount: Map = new Map(); + const profileConfigurationContext: Set = new Set(); + + const resolvedErrorHandler: ErrorHandler = errorHandler ?? { + handle: AutoMapperLogger.error + ? AutoMapperLogger.error.bind(AutoMapperLogger) + : // eslint-disable-next-line @typescript-eslint/no-empty-function + () => {}, + }; function getOptions< TSource extends Dictionary, @@ -121,453 +138,474 @@ export function createMapper({ return { destinationIdentifier, mapOptions: options }; } - // return the Proxy - return new Proxy( - { - [CUSTOM_NODE_INSPECT]() { - return ` -Mapper {} is an empty Object as a Proxy. The following methods are available to use with a Mapper: -- Mapper#map(Array)(Async), Mapper#mutate(Array)(Async) -- createMap() -- addProfile() -- getMapping() -- getMappings() - `; - }, - } as unknown as Mapper, - { - get(target, p: string | symbol, receiver) { - if (p === STRATEGY) { - if (!strategy) { - strategy = strategyInitializer(receiver); - } - return strategy; - } - - if (p === PROFILE_CONFIGURATION_CONTEXT) { - if (!profileConfigurationContext) { - profileConfigurationContext = new Set(); - } - return profileConfigurationContext; - } - - if (p === MAPPINGS) { - if (!mappings) { - mappings = new Map(); - } - return mappings; - } - - if (p === METADATA_MAP) { - if (!metadataMap) { - metadataMap = new Map(); - } - return metadataMap; - } - - if (p === METADATA_OBJECT_MAP) { - if (!metadataObjectMap) { - metadataObjectMap = new Map(); - } - - return metadataObjectMap; - } - - if (p === ERROR_HANDLER) { - if (!errorHandler) { - errorHandler = { - handle: AutoMapperLogger.error - ? AutoMapperLogger.error.bind(AutoMapperLogger) - : // eslint-disable-next-line @typescript-eslint/no-empty-function - () => {}, - }; - } - return errorHandler; - } - - if (p === NAMING_CONVENTIONS) { - return namingConventions; - } - - if (p === RECURSIVE_DEPTH) { - if (!recursiveDepth) { - recursiveDepth = new Map(); - } - return recursiveDepth; - } - - if (p === RECURSIVE_COUNT) { - if (!recursiveCount) { - recursiveCount = new Map(); - } - return recursiveCount; - } - - if (p === 'dispose') { - return () => { - mappings?.clear(); - // TODO: why can metadata not be clear? - // metadata?.clear(); - metadataObjectMap?.clear(); - recursiveDepth?.clear(); - recursiveCount?.clear(); - profileConfigurationContext?.clear(); - }; - } - - if (p === 'map') { - return < - TSource extends Dictionary, - TDestination extends Dictionary - >( - sourceObject: TSource, - sourceIdentifier: ModelIdentifier, - destinationIdentifierOrOptions?: - | ModelIdentifier - | MapOptions, - options?: MapOptions - ): TDestination => { - if (sourceObject == null) - return sourceObject as TDestination; - - const { destinationIdentifier, mapOptions } = - getOptions( - sourceIdentifier, - destinationIdentifierOrOptions, - options - ); - - const mapping = getMapping( - receiver, - sourceIdentifier, - destinationIdentifier - ); - - sourceObject = strategy.preMap(sourceObject, mapping); - - const destination = mapReturn( - mapping, - sourceObject, - mapOptions || {} - ); + // A plain object (not a Proxy): direct, monomorphic property access at every + // call site, real stack traces, and `mapper.map === mapper.map`. + const mapper = { + map< + TSource extends Dictionary, + TDestination extends Dictionary + >( + sourceObject: TSource, + sourceIdentifier: ModelIdentifier, + destinationIdentifierOrOptions?: + | ModelIdentifier + | MapOptions, + options?: MapOptions + ): TDestination { + if (sourceObject == null) return sourceObject as TDestination; + + const { destinationIdentifier, mapOptions } = getOptions( + sourceIdentifier, + destinationIdentifierOrOptions, + options + ); + + const mapping = getMapping( + mapper, + sourceIdentifier, + destinationIdentifier + ); + + sourceObject = strategy.preMap(sourceObject, mapping); + + const destination = mapReturn( + mapping, + sourceObject, + mapOptions || {} + ); + + return strategy.postMap( + sourceObject, + // returned as-is: intentionally NOT sealed, so consumers can + // add/modify the result after mapping + destination, + mapping + ); + }, + + mapAsync< + TSource extends Dictionary, + TDestination extends Dictionary + >( + sourceObject: TSource, + sourceIdentifier: ModelIdentifier, + destinationIdentifierOrOptions?: + | ModelIdentifier + | MapOptions, + options?: MapOptions + ): Promise { + if (sourceObject == null) { + return Promise.resolve(sourceObject as TDestination); + } - return strategy.postMap( + const { destinationIdentifier, mapOptions } = getOptions( + sourceIdentifier, + destinationIdentifierOrOptions, + options + ); + + const mapping = getMapping( + mapper, + sourceIdentifier, + destinationIdentifier + ); + + sourceObject = strategy.preMap(sourceObject, mapping); + + // Run the synchronous engine inside an async context, then await any + // async member resolvers / before-map, and finally the deferred + // after-maps — so the resolved Promise carries a fully-mapped result. + const [destination, asyncContext] = runAsyncMap(() => + mapReturn(mapping, sourceObject, mapOptions || {}) + ); + return settleAsyncMap(asyncContext).then( + () => + strategy.postMap( + sourceObject, + destination, + mapping + ) as TDestination + ); + }, + + mapArray< + TSource extends Dictionary, + TDestination extends Dictionary + >( + sourceArray: TSource[], + sourceIdentifier: ModelIdentifier, + destinationIdentifierOrOptions?: + | ModelIdentifier + | MapOptions, + options?: MapOptions + ): TDestination[] { + if (!sourceArray.length) return []; + + const { destinationIdentifier, mapOptions } = getOptions( + sourceIdentifier, + destinationIdentifierOrOptions, + options + ); + + const mapping = getMapping( + mapper, + sourceIdentifier, + destinationIdentifier + ); + + const { beforeMap, afterMap, extraArgs } = (mapOptions || + {}) as MapOptions; + + const destinationArray: TDestination[] = []; + const mappingCallbacks = mapping[MappingClassId.callbacks]; + const beforeMapArray = + beforeMap ?? + mappingCallbacks?.[MappingCallbacksClassId.beforeMapArray]; + const afterMapArray = + afterMap ?? + 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); + + const destination = mapReturn( + mapping, + sourceObject, + elementOptions, + true + ); + + destinationArray.push( + strategy.postMap( sourceObject, - // seal destination so that consumers cannot add properties to it - // or change the property descriptors. but they can still modify it - // the ideal behavior is seal but the consumers might need to add/modify the object after map finishes + // returned as-is: intentionally NOT sealed, so + // consumers can add/modify after mapping destination, mapping - ); - }; - } - - if (p === 'mapAsync') { - return < - TSource extends Dictionary, - TDestination extends Dictionary - >( - sourceObject: TSource, - sourceIdentifier: ModelIdentifier, - destinationIdentifierOrOptions?: - | ModelIdentifier - | MapOptions, - options?: MapOptions - ): Promise => { - const result = receiver['map']( - sourceObject, - sourceIdentifier, - destinationIdentifierOrOptions, - options - ); - return new Promise((res) => { - setTimeout(res, 0, result); - }); - }; + ) as TDestination + ); } + }; - if (p === 'mapArray') { - return < - TSource extends Dictionary, - TDestination extends Dictionary - >( - sourceArray: TSource[], - sourceIdentifier: ModelIdentifier, - destinationIdentifierOrOptions?: - | ModelIdentifier - | MapOptions, - options?: MapOptions - ): TDestination[] => { - if (!sourceArray.length) return []; - - const { destinationIdentifier, mapOptions } = - getOptions( - sourceIdentifier, - destinationIdentifierOrOptions, - options - ); - - const mapping = getMapping( - receiver, - sourceIdentifier, - destinationIdentifier - ); - - const { beforeMap, afterMap, extraArgs } = - (mapOptions || {}) as MapOptions< - TSource[], - TDestination[] - >; - - if (beforeMap) { - beforeMap(sourceArray, []); - } - - const destinationArray: TDestination[] = []; - - for ( - let i = 0, length = sourceArray.length; - i < length; - i++ - ) { - let sourceObject = sourceArray[i]; - sourceObject = strategy.preMap( - sourceObject, - mapping - ); - - const destination = mapReturn( - mapping, - sourceObject, - { - extraArgs: extraArgs as MapOptions< - TSource, - TDestination - >['extraArgs'], - }, - true - ); - - destinationArray.push( - strategy.postMap( - sourceObject, - // seal destination so that consumers cannot add properties to it - // or change the property descriptors. but they can still modify it - // the ideal behavior is seal but the consumers might need to add/modify the object after map finishes - destination, - mapping - ) as TDestination - ); - } - - if (afterMap) { - afterMap(sourceArray, destinationArray); - } - - return destinationArray; - }; + let arrayMappingDeferredUntilBeforeMap = false; + const queueAfterMapArray = () => { + if (afterMapArray) { + deferAsyncAfterMapAfterPending(() => + afterMapArray(sourceArray, destinationArray) + ); } + }; - if (p === 'mapArrayAsync') { - return < - TSource extends Dictionary, - TDestination extends Dictionary - >( - sourceArray: TSource[], - sourceIdentifier: ModelIdentifier, - destinationIdentifierOrOptions?: - | ModelIdentifier - | MapOptions, - options?: MapOptions - ) => { - const result = receiver['mapArray']( - sourceArray, - sourceIdentifier, - destinationIdentifierOrOptions, - options - ); - return new Promise((res) => { - setTimeout(res, 0, result); - }); - }; + if (beforeMapArray) { + const beforeMapResult = beforeMapArray( + sourceArray, + destinationArray + ); + if (isAsyncMapActive() && isThenable(beforeMapResult)) { + const asyncMapContext = getAsyncMapContext(); + arrayMappingDeferredUntilBeforeMap = true; + pushAsyncPending( + Promise.resolve(beforeMapResult).then(() => + runInAsyncMapContext(asyncMapContext!, () => { + runArrayMapping(); + queueAfterMapArray(); + }) + ) + ); + } else { + pushAsyncPending(beforeMapResult); } + } - if (p === 'mutate') { - return < - TSource extends Dictionary, - TDestination extends Dictionary - >( - sourceObject: TSource, - destinationObject: TDestination, - sourceIdentifier: ModelIdentifier, - destinationIdentifierOrOptions?: - | ModelIdentifier - | MapOptions, - options?: MapOptions - ) => { - if (sourceObject == null) return; - - const { destinationIdentifier, mapOptions } = - getOptions( - sourceIdentifier, - destinationIdentifierOrOptions, - options - ); - - const mapping = getMapping( - receiver, - sourceIdentifier, - destinationIdentifier - ); - - sourceObject = strategy.preMap(sourceObject, mapping); - - mapMutate( - mapping, - sourceObject, - destinationObject, - mapOptions || {} - ); + if (!arrayMappingDeferredUntilBeforeMap) { + runArrayMapping(); + queueAfterMapArray(); + } - strategy.postMap( - sourceObject, - destinationObject, - mapping - ); - }; - } - if (p === 'mutateAsync') { - return < - TSource extends Dictionary, - TDestination extends Dictionary - >( - sourceObject: TSource, - destinationObject: TDestination, - sourceIdentifier: ModelIdentifier, - destinationIdentifierOrOptions?: - | ModelIdentifier - | MapOptions, - options?: MapOptions - ) => { - return new Promise((res) => { - receiver['mutate']( - sourceObject, - destinationObject, - sourceIdentifier, - destinationIdentifierOrOptions, - options - ); - - setTimeout(res, 0); - }); - }; + return destinationArray; + }, + + mapArrayAsync< + TSource extends Dictionary, + TDestination extends Dictionary + >( + sourceArray: TSource[], + sourceIdentifier: ModelIdentifier, + destinationIdentifierOrOptions?: + | ModelIdentifier + | MapOptions, + options?: MapOptions + ): Promise { + const [result, asyncContext] = runAsyncMap(() => + mapper.mapArray( + sourceArray, + sourceIdentifier, + destinationIdentifierOrOptions as ModelIdentifier, + options + ) + ); + return settleAsyncMap(asyncContext).then(() => result); + }, + + mutate< + TSource extends Dictionary, + TDestination extends Dictionary + >( + sourceObject: TSource, + destinationObject: TDestination, + sourceIdentifier: ModelIdentifier, + destinationIdentifierOrOptions?: + | ModelIdentifier + | MapOptions, + options?: MapOptions + ): void { + if (sourceObject == null) return; + + const { destinationIdentifier, mapOptions } = getOptions( + sourceIdentifier, + destinationIdentifierOrOptions, + options + ); + + const mapping = getMapping( + mapper, + sourceIdentifier, + destinationIdentifier + ); + + sourceObject = strategy.preMap(sourceObject, mapping); + + mapMutate( + mapping, + sourceObject, + destinationObject, + mapOptions || {} + ); + + strategy.postMap(sourceObject, destinationObject, mapping); + }, + + mutateAsync< + TSource extends Dictionary, + TDestination extends Dictionary + >( + sourceObject: TSource, + destinationObject: TDestination, + sourceIdentifier: ModelIdentifier, + destinationIdentifierOrOptions?: + | ModelIdentifier + | MapOptions, + options?: MapOptions + ): Promise { + if (sourceObject == null) return Promise.resolve(); + + const { destinationIdentifier, mapOptions } = getOptions( + sourceIdentifier, + destinationIdentifierOrOptions, + options + ); + + const mapping = getMapping( + mapper, + sourceIdentifier, + destinationIdentifier + ); + + sourceObject = strategy.preMap(sourceObject, mapping); + + const [, asyncContext] = runAsyncMap(() => + mapMutate( + mapping, + sourceObject, + destinationObject, + mapOptions || {} + ) + ); + return settleAsyncMap(asyncContext).then(() => { + strategy.postMap(sourceObject, destinationObject, mapping); + }); + }, + + mutateArray< + TSource extends Dictionary, + TDestination extends Dictionary + >( + sourceArray: TSource[], + destinationArray: TDestination[], + sourceIdentifier: ModelIdentifier, + destinationIdentifierOrOptions?: + | ModelIdentifier + | MapOptions, + options?: MapOptions + ): void { + if (!sourceArray.length) return; + + const { destinationIdentifier, mapOptions } = getOptions( + sourceIdentifier, + destinationIdentifierOrOptions, + options + ); + + const mapping = getMapping( + mapper, + sourceIdentifier, + destinationIdentifier + ); + + const { beforeMap, afterMap, extraArgs } = (mapOptions || + {}) as MapOptions; + const mappingCallbacks = mapping[MappingClassId.callbacks]; + const beforeMapArray = + beforeMap ?? + mappingCallbacks?.[MappingCallbacksClassId.beforeMapArray]; + const afterMapArray = + afterMap ?? + 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]; + + sourceObject = strategy.preMap(sourceObject, mapping); + + const destination = + destinationArray[i] ?? + (destinationArray[i] = {} as TDestination); + + mapMutate( + mapping, + sourceObject, + destination, + elementOptions, + true + ); + + strategy.postMap(sourceObject, destination, mapping); } + }; - if (p === 'mutateArray') { - return < - TSource extends Dictionary, - TDestination extends Dictionary - >( - sourceArray: TSource[], - destinationArray: TDestination[], - sourceIdentifier: ModelIdentifier, - destinationIdentifierOrOptions?: - | ModelIdentifier - | MapOptions, - options?: MapOptions - ) => { - if (!sourceArray.length) return; - - const { destinationIdentifier, mapOptions } = - getOptions( - sourceIdentifier, - destinationIdentifierOrOptions, - options - ); - - const mapping = getMapping( - receiver, - sourceIdentifier, - destinationIdentifier - ); - - const { beforeMap, afterMap, extraArgs } = - (mapOptions || {}) as MapOptions< - TSource[], - TDestination[] - >; - - if (beforeMap) { - beforeMap(sourceArray, destinationArray); - } - - for ( - let i = 0, length = sourceArray.length; - i < length; - i++ - ) { - let sourceObject = sourceArray[i]; - - sourceObject = strategy.preMap( - sourceObject, - mapping - ); - - mapMutate( - mapping, - sourceObject, - destinationArray[i] || {}, - { - extraArgs: extraArgs as MapOptions< - TSource, - TDestination - >['extraArgs'], - }, - true - ); - - strategy.postMap( - sourceObject, - destinationArray[i], - mapping - ); - } - - if (afterMap) { - afterMap(sourceArray, destinationArray); - } - }; + let arrayMappingDeferredUntilBeforeMap = false; + const queueAfterMapArray = () => { + if (afterMapArray) { + deferAsyncAfterMapAfterPending(() => + afterMapArray(sourceArray, destinationArray) + ); } + }; - if (p === 'mutateArrayAsync') { - return < - TSource extends Dictionary, - TDestination extends Dictionary - >( - sourceArray: TSource[], - destinationArray: TDestination[], - sourceIdentifier: ModelIdentifier, - destinationIdentifierOrOptions?: - | ModelIdentifier - | MapOptions, - options?: MapOptions - ) => { - return new Promise((res) => { - receiver['mutateArray']( - sourceArray, - destinationArray, - sourceIdentifier, - destinationIdentifierOrOptions, - options - ); - - setTimeout(res, 0); - }); - }; + if (beforeMapArray) { + const beforeMapResult = beforeMapArray( + sourceArray, + destinationArray + ); + if (isAsyncMapActive() && isThenable(beforeMapResult)) { + const asyncMapContext = getAsyncMapContext(); + arrayMappingDeferredUntilBeforeMap = true; + pushAsyncPending( + Promise.resolve(beforeMapResult).then(() => + runInAsyncMapContext(asyncMapContext!, () => { + runArrayMapping(); + queueAfterMapArray(); + }) + ) + ); + } else { + pushAsyncPending(beforeMapResult); } + } - return Reflect.get(target, p, receiver); + if (!arrayMappingDeferredUntilBeforeMap) { + runArrayMapping(); + queueAfterMapArray(); + } + }, + + mutateArrayAsync< + TSource extends Dictionary, + TDestination extends Dictionary + >( + sourceArray: TSource[], + destinationArray: TDestination[], + sourceIdentifier: ModelIdentifier, + destinationIdentifierOrOptions?: + | ModelIdentifier + | MapOptions, + options?: MapOptions + ): Promise { + const [, asyncContext] = runAsyncMap(() => + mapper.mutateArray( + sourceArray, + destinationArray, + sourceIdentifier, + destinationIdentifierOrOptions as ModelIdentifier, + options + ) + ); + return settleAsyncMap(asyncContext); + }, + + dispose(): void { + mappings.clear(); + // metadataMap is intentionally left intact: strategies cache which + // identifiers they've retrieved metadata for (e.g. classes' + // metadataTracker), so clearing it here without resetting that cache + // would leave re-created mappings with no metadata. + metadataObjectMap.clear(); + recursiveDepth.clear(); + recursiveCount.clear(); + profileConfigurationContext.clear(); + }, + } as unknown as Mapper; + + // internal state, exposed as (non-enumerable) symbol-keyed properties read + // by the get*/symbols helpers. STRATEGY is lazy because the initializer + // needs the fully-constructed mapper. + Object.defineProperties(mapper, { + [STRATEGY]: { + get() { + if (!strategy) { + strategy = strategyInitializer(mapper); + } + return strategy; }, - } - ); + }, + [PROFILE_CONFIGURATION_CONTEXT]: { value: profileConfigurationContext }, + [MAPPINGS]: { value: mappings }, + [METADATA_MAP]: { value: metadataMap }, + [METADATA_OBJECT_MAP]: { value: metadataObjectMap }, + [ERROR_HANDLER]: { value: resolvedErrorHandler }, + [NAMING_CONVENTIONS]: { value: namingConventions }, + [RECURSIVE_DEPTH]: { value: recursiveDepth }, + [RECURSIVE_COUNT]: { value: recursiveCount }, + [CUSTOM_NODE_INSPECT]: { + value: () => ` +Mapper {} The following methods are available to use with a Mapper: +- Mapper#map(Array)(Async), Mapper#mutate(Array)(Async) +- createMap() +- addProfile() +- getMapping() +- getMappings() + `, + }, + }); + + return mapper; } diff --git a/packages/core/src/lib/errors.ts b/packages/core/src/lib/errors.ts new file mode 100644 index 000000000..d4e4d2da7 --- /dev/null +++ b/packages/core/src/lib/errors.ts @@ -0,0 +1,40 @@ +/** + * Base class for all errors thrown by AutoMapper. Lets consumers catch + * mapper errors selectively: `catch (e) { if (e instanceof AutoMapperError) ... }`. + */ +export class AutoMapperError extends Error { + constructor(message: string) { + super(message); + this.name = 'AutoMapperError'; + // restore the prototype chain (transpile targets that down-level class + // extends otherwise break `instanceof`) + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Thrown when `map()` cannot find a mapping for the given identifiers. */ +export class MappingNotFoundError extends AutoMapperError { + constructor( + public readonly sourceName: string, + public readonly destinationName: string + ) { + super(`Mapping is not found for ${sourceName} and ${destinationName}`); + this.name = 'MappingNotFoundError'; + } +} + +/** Thrown when mapping a single member fails; wraps the original error. */ +export class MapMemberError extends AutoMapperError { + constructor( + public readonly memberPath: string, + public readonly destinationName: string, + public readonly originalError: unknown + ) { + super( + `Error at "${memberPath}" on ${destinationName}\n` + + `---------------------------------------------------------------------\n` + + `Original error: ${originalError}` + ); + this.name = 'MapMemberError'; + } +} diff --git a/packages/core/src/lib/mapping-configurations/after-map-array.ts b/packages/core/src/lib/mapping-configurations/after-map-array.ts new file mode 100644 index 000000000..d81ff8b6c --- /dev/null +++ b/packages/core/src/lib/mapping-configurations/after-map-array.ts @@ -0,0 +1,18 @@ +import type { Dictionary, MapCallback, MappingConfiguration } from '../types'; +import { MappingCallbacksClassId, MappingClassId } from '../types'; + +export function afterMapArray< + TSource extends Dictionary, + TDestination extends Dictionary +>( + cb: MapCallback +): MappingConfiguration { + return (mapping) => { + if (mapping[MappingClassId.callbacks] == null) { + mapping[MappingClassId.callbacks] = []; + } + mapping[MappingClassId.callbacks]![ + MappingCallbacksClassId.afterMapArray + ] = cb; + }; +} diff --git a/packages/core/src/lib/mapping-configurations/before-map-array.ts b/packages/core/src/lib/mapping-configurations/before-map-array.ts new file mode 100644 index 000000000..ade100f63 --- /dev/null +++ b/packages/core/src/lib/mapping-configurations/before-map-array.ts @@ -0,0 +1,18 @@ +import type { Dictionary, MapCallback, MappingConfiguration } from '../types'; +import { MappingCallbacksClassId, MappingClassId } from '../types'; + +export function beforeMapArray< + TSource extends Dictionary, + TDestination extends Dictionary +>( + cb: MapCallback +): MappingConfiguration { + return (mapping) => { + if (mapping[MappingClassId.callbacks] == null) { + mapping[MappingClassId.callbacks] = []; + } + mapping[MappingClassId.callbacks]![ + MappingCallbacksClassId.beforeMapArray + ] = cb; + }; +} 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/mapping-configurations/for-member.ts b/packages/core/src/lib/mapping-configurations/for-member.ts index 3d273c804..882e7464c 100644 --- a/packages/core/src/lib/mapping-configurations/for-member.ts +++ b/packages/core/src/lib/mapping-configurations/for-member.ts @@ -1,6 +1,4 @@ import { createMappingUtil } from '../mappings/create-initial-mapping'; -import { createMap } from '../mappings/create-map'; -import { getMetadataMap } from '../symbols'; import type { Dictionary, MappingConfiguration, @@ -10,10 +8,8 @@ import type { Selector, SelectorReturn, } from '../types'; -import { MappingClassId, MetadataClassId, NestedMappingPair } from '../types'; +import { MappingClassId } from '../types'; import { getMemberPath } from '../utils/get-member-path'; -import { getFlatteningPaths, getPath } from '../utils/get-path'; -import { isPrimitiveArrayEqual } from '../utils/is-primitive-array-equal'; export function forMember< TSource extends Dictionary, diff --git a/packages/core/src/lib/mapping-configurations/specs/__snapshots__/extend.spec.ts.snap b/packages/core/src/lib/mapping-configurations/specs/__snapshots__/extend.spec.ts.snap index 6c8ac9eae..c2a1a44d3 100644 --- a/packages/core/src/lib/mapping-configurations/specs/__snapshots__/extend.spec.ts.snap +++ b/packages/core/src/lib/mapping-configurations/specs/__snapshots__/extend.spec.ts.snap @@ -1,23 +1,23 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`extend should extend another mapping 1`] = ` -Array [ - Array [ - Array [ +exports[`extend > should extend another mapping 1`] = ` +[ + [ + [ "foo", ], - Array [ - Array [ + [ + [ "foo", ], - Array [ - Array [ + [ + [ 6, [Function], ], ], ], - Array [ + [ [Function], [Function], ], @@ -25,44 +25,44 @@ Array [ ] `; -exports[`extend should skip existing mapping properties on extended mapping 1`] = ` -Array [ - Array [ - Array [ +exports[`extend > should skip existing mapping properties on extended mapping 1`] = ` +[ + [ + [ "foo", ], - Array [ - Array [ + [ + [ "foo", ], - Array [ - Array [ + [ + [ 6, [Function], ], ], ], - Array [ + [ [Function], [Function], ], ], - Array [ - Array [ + [ + [ "bar", ], - Array [ - Array [ + [ + [ "bar", ], - Array [ - Array [ + [ + [ 6, [Function], ], ], ], - Array [ + [ [Function], [Function], ], diff --git a/packages/core/src/lib/mapping-configurations/specs/__snapshots__/for-self.spec.ts.snap b/packages/core/src/lib/mapping-configurations/specs/__snapshots__/for-self.spec.ts.snap index 5e64f07fe..2c0014a3e 100644 --- a/packages/core/src/lib/mapping-configurations/specs/__snapshots__/for-self.spec.ts.snap +++ b/packages/core/src/lib/mapping-configurations/specs/__snapshots__/for-self.spec.ts.snap @@ -1,43 +1,43 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`forSelf should skip existing mapping properties from another mapping 1`] = ` -Array [ - Array [ - Array [ +exports[`forSelf > should skip existing mapping properties from another mapping 1`] = ` +[ + [ + [ "foo", ], - Array [ - Array [ + [ + [ "foo", ], - Array [ - Array [ + [ + [ 6, [Function], ], ], ], - Array [ + [ [Function], [Function], ], ], - Array [ - Array [ + [ + [ "bar", ], - Array [ - Array [ + [ + [ "bar", ], - Array [ - Array [ + [ + [ 6, [Function], ], ], ], - Array [ + [ [Function], [Function], ], @@ -45,24 +45,24 @@ Array [ ] `; -exports[`forSelf should update mapping properties with another mapping 1`] = ` -Array [ - Array [ - Array [ +exports[`forSelf > should update mapping properties with another mapping 1`] = ` +[ + [ + [ "foo", ], - Array [ - Array [ + [ + [ "foo", ], - Array [ - Array [ + [ + [ 6, [Function], ], ], ], - Array [ + [ [Function], [Function], ], diff --git a/packages/core/src/lib/mapping-configurations/specs/after-map.spec.ts b/packages/core/src/lib/mapping-configurations/specs/after-map.spec.ts index 50448416a..20eb93fa7 100644 --- a/packages/core/src/lib/mapping-configurations/specs/after-map.spec.ts +++ b/packages/core/src/lib/mapping-configurations/specs/after-map.spec.ts @@ -5,7 +5,7 @@ import { afterMap } from '../after-map'; describe(afterMap.name, () => { it('should update mapping configuration with afterMap', () => { const mapping = [] as unknown as Mapping; - const cb = jest.fn(); + const cb = vi.fn(); afterMap(cb)(mapping); expect( mapping[MappingClassId.callbacks]![MappingCallbacksClassId.afterMap] diff --git a/packages/core/src/lib/mapping-configurations/specs/before-map.spec.ts b/packages/core/src/lib/mapping-configurations/specs/before-map.spec.ts index fb4b91dec..3a06632ba 100644 --- a/packages/core/src/lib/mapping-configurations/specs/before-map.spec.ts +++ b/packages/core/src/lib/mapping-configurations/specs/before-map.spec.ts @@ -5,7 +5,7 @@ import { beforeMap } from '../before-map'; describe(beforeMap.name, () => { it('should update mapping configuration with beforeMap', () => { const mapping = [] as unknown as Mapping; - const cb = jest.fn(); + const cb = vi.fn(); beforeMap(cb)(mapping); expect( mapping[MappingClassId.callbacks]![ diff --git a/packages/core/src/lib/mapping-configurations/specs/construct-using.spec.ts b/packages/core/src/lib/mapping-configurations/specs/construct-using.spec.ts index a3adb5941..f3bff5bb6 100644 --- a/packages/core/src/lib/mapping-configurations/specs/construct-using.spec.ts +++ b/packages/core/src/lib/mapping-configurations/specs/construct-using.spec.ts @@ -5,7 +5,7 @@ import { constructUsing } from '../construct-using'; describe(constructUsing.name, () => { it('should update destinationConstructor for the mapping', () => { const mapping = [] as unknown as Mapping; - const constructor = jest.fn(); + const constructor = vi.fn(); constructUsing(constructor)(mapping); expect(mapping[MappingClassId.destinationConstructor]).toBe( constructor diff --git a/packages/core/src/lib/mapping-configurations/specs/type-converters.spec.ts b/packages/core/src/lib/mapping-configurations/specs/type-converters.spec.ts index 30b49993d..ae05fe1ee 100644 --- a/packages/core/src/lib/mapping-configurations/specs/type-converters.spec.ts +++ b/packages/core/src/lib/mapping-configurations/specs/type-converters.spec.ts @@ -5,7 +5,7 @@ import { typeConverter } from '../type-converters'; describe(typeConverter.name, () => { it('should update mapping type converters for Type to Type', () => { const mapping = [] as unknown as Mapping; - const selector = jest.fn(); + const selector = vi.fn(); typeConverter(String, Number, selector)(mapping); /** @@ -27,7 +27,7 @@ describe(typeConverter.name, () => { it('should update mapping type converters for [Type] to [Type]', () => { const mapping = [] as unknown as Mapping; - const selector = jest.fn(); + const selector = vi.fn(); typeConverter([String], [Number], selector)(mapping); /** @@ -49,7 +49,7 @@ describe(typeConverter.name, () => { it('should update mapping type converters for Type to [Type]', () => { const mapping = [] as unknown as Mapping; - const selector = jest.fn(); + const selector = vi.fn(); typeConverter(String, [Number], selector)(mapping); /** @@ -71,7 +71,7 @@ describe(typeConverter.name, () => { it('should update mapping type converters for [Type] to Type', () => { const mapping = [] as unknown as Mapping; - const selector = jest.fn(); + const selector = vi.fn(); typeConverter([String], Number, selector)(mapping); /** 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 new file mode 100644 index 000000000..8beeaf181 --- /dev/null +++ b/packages/core/src/lib/mappings/compile-mapping.ts @@ -0,0 +1,52 @@ +import type { + CompiledMappingDescriptors, + CompiledMappingProperty, + Dictionary, + Mapping, +} from '../types'; +import { MapFnClassId, MappingClassId } from '../types'; + +// Destructure a mapping's positional `properties` tuples once into a flat +// descriptor list (plus the invariant set of configured destination keys). +// Called eagerly at createMap time (via buildMapPlan, which also attaches the +// compiled steps); the result is stored on the mapping and read directly by the +// map() hot loop, so no per-call re-destructuring (and no WeakMap probe) happens. +export function compileMapping< + TSource extends Dictionary, + TDestination extends Dictionary +>( + propsToMap: Mapping[MappingClassId.properties] +): CompiledMappingDescriptors { + const props: CompiledMappingProperty[] = []; + const configuredKeys: string[] = []; + + for (let i = 0, length = propsToMap.length; i < length; i++) { + const [ + destinationMemberPath, + [ + , + [ + transformationMapFn, + [ + transformationPreConditionPredicate, + transformationPreConditionDefaultValue = undefined, + ] = [], + ], + ], + [destinationMemberIdentifier, sourceMemberIdentifier] = [], + ] = propsToMap[i]; + + props.push({ + destinationMemberPath, + transformationMapFn, + transformationType: transformationMapFn[MapFnClassId.type], + transformationPreConditionPredicate, + transformationPreConditionDefaultValue, + destinationMemberIdentifier, + sourceMemberIdentifier, + }); + configuredKeys.push(destinationMemberPath[0]); + } + + return { props, configuredKeys }; +} 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 c2bff73d9..138518f28 100644 --- a/packages/core/src/lib/mappings/create-map.ts +++ b/packages/core/src/lib/mappings/create-map.ts @@ -12,8 +12,10 @@ import type { MetadataIdentifier, ModelIdentifier, } from '../types'; +import { MappingClassId } from '../types'; import { storeMetadata } from '../utils/store-metadata'; import { createInitialMapping } from './create-initial-mapping'; +import { buildMapPlan } from './map'; export function createMap>( mapper: Mapper, @@ -106,6 +108,13 @@ export function createMap< ) as MappingConfiguration[] ); + // 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.identifierMetadata][1] + ); + // store the mapping if (!mappings.has(sourceIdentifier)) { mappings.set( diff --git a/packages/core/src/lib/mappings/map-member.ts b/packages/core/src/lib/mappings/map-member.ts index 63e14abb1..810112d42 100644 --- a/packages/core/src/lib/mappings/map-member.ts +++ b/packages/core/src/lib/mappings/map-member.ts @@ -13,8 +13,7 @@ import type { Primitive, } from '../types'; import { MapFnClassId, TransformationType } from '../types'; -import { isDateConstructor } from '../utils/is-date-constructor'; -import { isPrimitiveConstructor } from '../utils/is-primitive-constructor'; +import { isMappableIdentifier } from '../utils/is-mappable-identifier'; export function mapMember< TSource extends Dictionary, @@ -24,7 +23,7 @@ export function mapMember< sourceObject: TSource, destinationObject: TDestination, destinationMemberPath: string[], - extraArgs: Record | undefined, + extraArgs: Record | undefined, mapper: Mapper, sourceMemberIdentifier?: MetadataIdentifier | Primitive | Date, destinationMemberIdentifier?: MetadataIdentifier | Primitive | Date @@ -33,12 +32,6 @@ export function mapMember< const transformationType: TransformationType = transformationMapFn[MapFnClassId.type]; const mapFn = transformationMapFn[MapFnClassId.fn]; - const shouldRunImplicitMap = !( - isPrimitiveConstructor(sourceMemberIdentifier) || - isPrimitiveConstructor(destinationMemberIdentifier) || - isDateConstructor(sourceMemberIdentifier) || - isDateConstructor(destinationMemberIdentifier) - ); switch (transformationType) { case TransformationType.MapFrom: @@ -75,18 +68,26 @@ export function mapMember< mapFn as ConditionReturn[MapFnClassId.fn] )(sourceObject, destinationMemberPath); - if (shouldRunImplicitMap && value != null) { - value = Array.isArray(value) - ? mapper.mapArray( - value, - sourceMemberIdentifier as MetadataIdentifier, - destinationMemberIdentifier as MetadataIdentifier - ) - : mapper.map( - value, - sourceMemberIdentifier as MetadataIdentifier, - destinationMemberIdentifier as MetadataIdentifier - ); + if (value != null) { + // primitive/date identifiers => no implicit (member) mapping. + // Computed lazily: only Condition/Null/Undefined reach this, so + // the other transformation types skip these predicate calls. + const shouldRunImplicitMap = + isMappableIdentifier(sourceMemberIdentifier) && + isMappableIdentifier(destinationMemberIdentifier); + if (shouldRunImplicitMap) { + value = Array.isArray(value) + ? mapper.mapArray( + value, + sourceMemberIdentifier as MetadataIdentifier, + destinationMemberIdentifier as MetadataIdentifier + ) + : mapper.map( + value, + sourceMemberIdentifier as MetadataIdentifier, + destinationMemberIdentifier as MetadataIdentifier + ); + } } break; diff --git a/packages/core/src/lib/mappings/map.ts b/packages/core/src/lib/mappings/map.ts index be55049cd..9bbe9891e 100644 --- a/packages/core/src/lib/mappings/map.ts +++ b/packages/core/src/lib/mappings/map.ts @@ -1,32 +1,55 @@ -import { getErrorHandler, getMetadataMap } from '../symbols'; +import { MapMemberError } from '../errors'; +import { getErrorHandler } from '../symbols'; import type { + CompiledMapping, + CompiledMappingProperty, Constructor, Dictionary, + ErrorHandler, MapInitializeReturn, + Mapper, MapOptions, Mapping, MetadataIdentifier, } from '../types'; -import { MapFnClassId, MetadataClassId, TransformationType } from '../types'; -import { assertUnmappedProperties } from '../utils/assert-unmapped-properties'; +import { MapFnClassId, MappingClassId, TransformationType } from '../types'; +import { + assertUnmappedProperties, + computeUnmappedCandidateKeys, +} from '../utils/assert-unmapped-properties'; import { get } from '../utils/get'; import { getMapping } from '../utils/get-mapping'; -import { isDateConstructor } from '../utils/is-date-constructor'; +import { isMappableIdentifier } from '../utils/is-mappable-identifier'; import { isEmpty } from '../utils/is-empty'; -import { isPrimitiveArrayEqual } from '../utils/is-primitive-array-equal'; import { isPrimitiveConstructor } from '../utils/is-primitive-constructor'; 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< @@ -48,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); } @@ -89,14 +116,572 @@ interface MapParameter< options: MapOptions; setMemberFn: ( destinationMemberPath: string[], - destination?: TDestination - ) => (value: unknown) => void; + destination: TDestination | undefined, + value: unknown + ) => void; getMemberFn?: ( destinationMemberPath: string[] | undefined ) => Record; isMapArray?: boolean; } +// TRUE async support. The map engine itself is synchronous, but +// mapAsync()/mapArrayAsync()/mutate(Array)Async() run it inside an "async +// context" so they can await work that only resolves later: +// - async member resolvers (mapFrom/convertUsing/mapWith/... returning a +// promise): the resolved value is assigned once it settles, instead of a +// Promise leaking onto the destination; +// - async before-map callbacks: collected and awaited; +// - after-map callbacks: DEFERRED until members have settled, so they observe +// a fully-mapped destination (and are awaited too if async). +// In the synchronous path the context is null and behaviour/throughput are +// unchanged (one null-check per member). +export interface AsyncMapContext { + // member-value + before-map promises, awaited before the deferred after-maps + pending: Promise[]; + // after-map thunks, run in collection order (depth-first) once pending settles + deferredAfterMaps: Array<{ + fn: () => unknown; + depth: number; + order: number; + }>; + nextAfterMapOrder: number; +} + +let asyncMapContext: AsyncMapContext | null = null; +let asyncMapDepth = 0; + +export function isAsyncMapActive(): boolean { + return asyncMapContext !== null; +} + +export function getAsyncMapContext(): AsyncMapContext | null { + return asyncMapContext; +} + +export function runInAsyncMapContext( + context: AsyncMapContext, + fn: () => T +): T { + const previous = asyncMapContext; + asyncMapContext = context; + try { + return fn(); + } finally { + asyncMapContext = previous; + } +} + +export function isThenable(value: unknown): value is Promise { + return ( + value != null && + typeof (value as { then?: unknown }).then === 'function' + ); +} + +// Run a synchronous map() call inside a fresh async context and hand back the +// context. Nested map() calls share whichever context is active, so a nested +// map's async members/after-maps are collected by the outermost mapAsync(). +export function runAsyncMap(fn: () => T): [T, AsyncMapContext] { + const previous = asyncMapContext; + const previousDepth = asyncMapDepth; + const context: AsyncMapContext = { + pending: [], + deferredAfterMaps: [], + nextAfterMapOrder: 0, + }; + asyncMapContext = context; + asyncMapDepth = 0; + try { + return [fn(), context]; + } finally { + asyncMapContext = previous; + asyncMapDepth = previousDepth; + } +} + +// Await everything a context collected: pending member/before-map promises +// first, then the deferred after-maps (sequentially; each may be async). +export async function settleAsyncMap(context: AsyncMapContext): Promise { + while (context.pending.length) { + await Promise.all(context.pending.splice(0)); + } + while (context.deferredAfterMaps.length) { + context.deferredAfterMaps.sort( + (a, b) => b.depth - a.depth || a.order - b.order + ); + const { fn } = context.deferredAfterMaps.shift()!; + const result = runInAsyncMapContext(context, fn); + if (isThenable(result)) await result; + while (context.pending.length) { + await Promise.all(context.pending.splice(0)); + } + } +} + +// Collect a promise (e.g. an async before-map) to await before after-maps run. +export function pushAsyncPending(value: unknown): void { + if (asyncMapContext !== null && isThenable(value)) { + asyncMapContext.pending.push(value as Promise); + } +} + +// Defer an after-map (array-level or mapping-level) past member resolution when +// running async; runs it immediately when synchronous. +function runAtAsyncMapDepth(depth: number, fn: () => T): T { + const previousDepth = asyncMapDepth; + asyncMapDepth = depth; + try { + return fn(); + } finally { + asyncMapDepth = previousDepth; + } +} + +export function deferAsyncAfterMap( + fn: () => unknown, + depth = asyncMapDepth +): void { + if (asyncMapContext !== null) { + asyncMapContext.deferredAfterMaps.push({ + fn, + depth, + order: asyncMapContext.nextAfterMapOrder++, + }); + } else { + fn(); + } +} + +export function deferAsyncAfterMapAfterPending( + fn: () => unknown, + depth = asyncMapDepth +): void { + if (asyncMapContext !== null && asyncMapContext.pending.length) { + const context = asyncMapContext; + const pendingBeforeAfterMap = context.pending.slice(); + context.pending.push( + Promise.all(pendingBeforeAfterMap).then(() => + runInAsyncMapContext(context, () => + deferAsyncAfterMap(fn, depth) + ) + ) + ); + return; + } + + deferAsyncAfterMap(fn, depth); +} + +// Wrap a member failure (sync throw or async rejection) as MapMemberError and +// route it through the mapper's error handler. Module-level so the hot map loop +// allocates no per-member closure for it. +function makeMemberError( + destinationMemberPath: string[], + destinationIdentifier: MetadataIdentifier, + errorHandler: ErrorHandler, + originalError: unknown +): MapMemberError { + const destinationName = + (destinationIdentifier as Constructor)['prototype']?.constructor + ?.name || destinationIdentifier.toString(); + // note: do NOT JSON.stringify(destination) here — it throws on circular + // references and floods logs on large objects. + const error = new MapMemberError( + String(destinationMemberPath), + destinationName, + originalError + ); + errorHandler.handle(error.message); + return error; +} + +// Per-call state handed to each compiled step. Built once per map() call; the +// step closures capture everything that is invariant per mapping (paths, +// transformation fns, precomputed identifier flags) at compile time and read +// only the call-varying bits from here. +interface MapStepContext< + TSource extends Dictionary, + TDestination extends Dictionary +> { + sourceObject: TSource; + 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; + setMemberFn: MapParameter['setMemberFn']; + getMemberFn: MapParameter['getMemberFn']; +} + +type MapStep< + TSource extends Dictionary, + TDestination extends Dictionary +> = (context: MapStepContext) => void; + +// Assign an already-resolved value onto the destination. In an async context a +// thenable is awaited and the *resolved* value assigned (the promise is collected +// as pending); async rejections wrap via makeMemberError. NO try/catch here — the +// caller wraps sync throws so the failure is wrapped exactly once. +function assignResolved< + TSource extends Dictionary, + TDestination extends Dictionary +>( + value: unknown, + destinationMemberPath: string[], + ctx: MapStepContext +): void { + if (isThenable(value)) { + if (asyncMapContext === null) { + throw new Error( + 'Async member mapping returned a Promise during synchronous map(). Use mapAsync() or mapArrayAsync() instead.' + ); + } + + asyncMapContext.pending.push( + Promise.resolve(value) + .then((resolved) => + ctx.setMemberFn( + destinationMemberPath, + ctx.destination, + resolved + ) + ) + .catch((originalError) => { + throw makeMemberError( + destinationMemberPath, + ctx.destinationIdentifier, + ctx.errorHandler, + originalError + ); + }) + ); + return; + } + ctx.setMemberFn(destinationMemberPath, ctx.destination, value); +} + +// Assign a precomputed auto-map value (its production already happened). This is +// the hot path — a same-identifier / primitive / Date / array value assigned +// as-is — so it uses the async-first short-circuit and never tests isThenable on +// the synchronous path. (An auto-mapped source value that is itself a thenable is +// awaited under mapAsync() and assigned as-is under map(), matching the original +// pre-async behavior; the sync-thenable guard intentionally lives on the resolver +// path in assignResolved, where async member resolvers actually originate.) +function assignMember< + TSource extends Dictionary, + TDestination extends Dictionary +>( + value: unknown, + destinationMemberPath: string[], + ctx: MapStepContext +): void { + try { + if (asyncMapContext !== null && isThenable(value)) { + asyncMapContext.pending.push( + Promise.resolve(value) + .then((resolved) => + ctx.setMemberFn( + destinationMemberPath, + ctx.destination, + resolved + ) + ) + .catch((originalError) => { + throw makeMemberError( + destinationMemberPath, + ctx.destinationIdentifier, + ctx.errorHandler, + originalError + ); + }) + ); + return; + } + ctx.setMemberFn(destinationMemberPath, ctx.destination, value); + } catch (originalError) { + throw makeMemberError( + destinationMemberPath, + ctx.destinationIdentifier, + ctx.errorHandler, + originalError + ); + } +} + +// Produce a value (which may throw — nested map(), mapMember(), etc.) then assign +// it. A single try wraps both production and assignment, matching the original +// per-member setMember semantics (one MapMemberError wrap on failure). +function setMemberValue< + TSource extends Dictionary, + TDestination extends Dictionary +>( + valueFn: () => unknown, + destinationMemberPath: string[], + ctx: MapStepContext +): void { + try { + assignResolved(valueFn(), destinationMemberPath, ctx); + } catch (originalError) { + throw makeMemberError( + destinationMemberPath, + ctx.destinationIdentifier, + ctx.errorHandler, + originalError + ); + } +} + +// Specialize one property into a step closure. The TransformationType switch and +// the per-mapping-invariant identifier equality are resolved here, once, so the +// map() loop is straight-line `steps[i](context)`. +function compileStep< + TSource extends Dictionary, + TDestination extends Dictionary +>( + prop: CompiledMappingProperty +): MapStep { + const { + destinationMemberPath, + transformationMapFn, + transformationType, + transformationPreConditionPredicate: preCond, + transformationPreConditionDefaultValue: preCondDefault, + destinationMemberIdentifier, + sourceMemberIdentifier, + } = prop; + + // 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)) { + assignMember(preCondDefault, destinationMemberPath, ctx); + return; + } + setMemberValue( + () => + mapMember( + transformationMapFn, + ctx.sourceObject, + ctx.destination, + destinationMemberPath, + ctx.extraArguments, + ctx.mapper, + sourceMemberIdentifier, + destinationMemberIdentifier + ), + destinationMemberPath, + ctx + ); + }; + } + + // MapInitialize. Hoist the invariant pieces: the initialize fn, the + // typeConverter flag, the identifier-equality *candidate* (the registry + // lookup that completes it stays at runtime), and whether the destination + // identifier is a primitive constructor. + const mapInitializeFn = transformationMapFn[ + MapFnClassId.fn + ] as MapInitializeReturn[MapFnClassId.fn]; + const isTypedConverted = transformationMapFn[MapFnClassId.isConverted]; + const sameIdentifierCandidate = + isMappableIdentifier(destinationMemberIdentifier) && + isMappableIdentifier(sourceMemberIdentifier) && + sourceMemberIdentifier === destinationMemberIdentifier; + const destinationIsPrimitive = isPrimitiveConstructor( + destinationMemberIdentifier + ); + + return (ctx) => { + if (preCond && !preCond(ctx.sourceObject)) { + assignMember(preCondDefault, destinationMemberPath, ctx); + return; + } + + const mapInitializedValue = mapInitializeFn(ctx.sourceObject); + + // 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( + ctx.mapper, + sourceMemberIdentifier as MetadataIdentifier, + destinationMemberIdentifier as MetadataIdentifier, + true + ); + + if ( + mapInitializedValue instanceof Date || + isFileTagged(mapInitializedValue) || + hasSameIdentifier || + isTypedConverted + ) { + assignMember(mapInitializedValue, destinationMemberPath, ctx); + return; + } + + if (Array.isArray(mapInitializedValue)) { + const [first] = mapInitializedValue; + // 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 || + isFileTagged(first) + ) { + assignMember( + mapInitializedValue.slice(), + destinationMemberPath, + ctx + ); + return; + } + + if (isEmpty(first)) { + assignMember([], destinationMemberPath, ctx); + return; + } + + // object array but destination identifier is primitive — skip + if (destinationIsPrimitive) { + return; + } + + setMemberValue( + () => + mapInitializedValue.map((each) => + mapReturn( + getMapping( + ctx.mapper, + sourceMemberIdentifier as MetadataIdentifier, + destinationMemberIdentifier as MetadataIdentifier + ), + each, + ctx.nestedOptions + ) + ), + destinationMemberPath, + ctx + ); + return; + } + + // 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: ctx.nestedOptions, + setMemberFn: setMemberMutateFn(memberValue), + getMemberFn: getMemberMutateFn(memberValue), + }); + } + return; + } + + setMemberValue( + () => + map({ + mapping: nestedMapping, + sourceObject: mapInitializedValue as TSource, + options: ctx.nestedOptions, + setMemberFn: setMemberReturnFn, + }), + destinationMemberPath, + ctx + ); + }; +} + +// Build the full compiled plan for a mapping's properties: flat descriptors, +// configured keys, and the specialized step closures. Called once at createMap. +export function buildMapPlan< + TSource extends Dictionary, + TDestination extends Dictionary +>( + propsToMap: Mapping[MappingClassId.properties], + destinationMetadata: TDestination +): CompiledMapping { + const { props, configuredKeys } = compileMapping( + propsToMap + ); + const steps: MapStep[] = new Array(props.length); + for (let i = 0, length = props.length; i < length; i++) { + steps[i] = compileStep(props[i]); + } + // 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< TSource extends Dictionary, TDestination extends Dictionary @@ -130,7 +715,6 @@ export function map< } = options ?? {}; const errorHandler = getErrorHandler(mapper); - const metadataMap = getMetadataMap(mapper); const destination: TDestination = mapDestinationConstructor( sourceObject, @@ -140,235 +724,139 @@ export function map< // get extraArguments const extraArguments = extraArgs?.(mapping, destination); - // initialize an array of keys that have already been configured - const configuredKeys: string[] = []; - - if (!isMapArray) { - const beforeMap = mapBeforeCallback ?? mappingBeforeCallback; - if (beforeMap) { - beforeMap(sourceObject, destination, extraArguments); - } + // 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, destinationWithMetadata); + mapping[MappingClassId.compiledPlan] = compiledPlan; } + const { steps, unmappedCandidateKeys } = compiledPlan; - // map - for (let i = 0, length = propsToMap.length; i < length; i++) { - // destructure mapping property - const [ - destinationMemberPath, - [ - , - [ - transformationMapFn, - [ - transformationPreConditionPredicate, - transformationPreConditionDefaultValue = undefined, - ] = [], - ], - ], - [destinationMemberIdentifier, sourceMemberIdentifier] = [], - ] = propsToMap[i]; - - let hasSameIdentifier = - !isPrimitiveConstructor(destinationMemberIdentifier) && - !isDateConstructor(destinationMemberIdentifier) && - !isPrimitiveConstructor(sourceMemberIdentifier) && - !isDateConstructor(sourceMemberIdentifier) && - sourceMemberIdentifier === destinationMemberIdentifier; - - if (hasSameIdentifier) { - // at this point, we have a same identifier that aren't primitive or date - // we then check if there is a mapping created for this identifier - hasSameIdentifier = !getMapping( - mapper, - sourceMemberIdentifier as MetadataIdentifier, - destinationMemberIdentifier as MetadataIdentifier, - true - ); - } + // Build the per-call context once, then run each compiled step. All the + // per-member branching/destructuring was resolved at compile time. + const context: MapStepContext = { + sourceObject, + destination, + extraArguments, + extraArgs, + nestedOptions, + mapper, + errorHandler, + destinationIdentifier, + setMemberFn, + getMemberFn, + }; - // Set up a shortcut function to set destinationMemberPath on destination with value as argument - const setMember = (valFn: () => unknown) => { - try { - return setMemberFn(destinationMemberPath, destination)(valFn()); - } catch (originalError) { - const errorMessage = ` -Error at "${destinationMemberPath}" on ${ - (destinationIdentifier as Constructor)['prototype'] - ?.constructor?.name || destinationIdentifier.toString() - } (${JSON.stringify(destination)}) ---------------------------------------------------------------------- -Original error: ${originalError}`; - errorHandler.handle(errorMessage); - throw new Error(errorMessage); + // Synchronous fast path: no mapAsync()/mapArrayAsync() context is active, so + // there is nothing to await, defer, or order by depth. Run before-map → + // steps → unmapped-assert → after-map straight-line with zero per-call + // closures — this is the hot path the compiled plan is built for. (A + // thenable returned by a sync before-map is dropped, matching prior + // behavior; async before-maps require mapAsync().) + if (asyncMapContext === null) { + if (!isMapArray) { + const beforeMap = mapBeforeCallback ?? mappingBeforeCallback; + if (beforeMap) { + beforeMap(sourceObject, destination, extraArguments); } - }; - - // This destination key is being configured. Push to configuredKeys array - configuredKeys.push(destinationMemberPath[0]); + } - // Pre Condition check - if ( - transformationPreConditionPredicate && - !transformationPreConditionPredicate(sourceObject) - ) { - setMember(() => transformationPreConditionDefaultValue); - continue; + for (let i = 0, length = steps.length; i < length; i++) { + (steps[i] as MapStep)(context); } - // Start with all the mapInitialize - if ( - transformationMapFn[MapFnClassId.type] === - TransformationType.MapInitialize - ) { - // check if metadata as destinationMemberPath is null - const destinationMetadata = metadataMap.get(destinationIdentifier); - const hasNullMetadata = - destinationMetadata && - destinationMetadata.find((metadata) => - isPrimitiveArrayEqual( - metadata[MetadataClassId.propertyKeys], - destinationMemberPath - ) - ) === null; - - const mapInitializedValue = ( - transformationMapFn[MapFnClassId.fn] as MapInitializeReturn< - TSource, - TDestination - >[MapFnClassId.fn] - )(sourceObject); - const isTypedConverted = - transformationMapFn[MapFnClassId.isConverted]; - - // if null/undefined - // if isDate, isFile - // if metadata is null, treat as-is - // if it has same identifier that are not primitives or Date - // if the initialized value was converted with typeConverter - if ( - mapInitializedValue == null || - mapInitializedValue instanceof Date || - Object.prototype.toString - .call(mapInitializedValue) - .slice(8, -1) === 'File' || - hasNullMetadata || - hasSameIdentifier || - isTypedConverted - ) { - setMember(() => mapInitializedValue); - continue; + assertUnmappedProperties( + destination, + unmappedCandidateKeys, + sourceIdentifier, + destinationIdentifier, + errorHandler + ); + + if (!isMapArray) { + const afterMap = mapAfterCallback ?? mappingAfterCallback; + if (afterMap) { + afterMap(sourceObject, destination, extraArguments); } + } - // if isArray - if (Array.isArray(mapInitializedValue)) { - const [first] = mapInitializedValue; - // if first item is a primitive - if ( - typeof first !== 'object' || - first instanceof Date || - Object.prototype.toString.call(first).slice(8, -1) === - 'File' - ) { - setMember(() => mapInitializedValue.slice()); - continue; - } - - // if first is empty - if (isEmpty(first)) { - setMember(() => []); - continue; - } - - // if first is object but the destination identifier is a primitive - // then skip completely - if (isPrimitiveConstructor(destinationMemberIdentifier)) { - continue; - } - - setMember(() => - mapInitializedValue.map((each) => - mapReturn( - getMapping( - mapper, - sourceMemberIdentifier as MetadataIdentifier, - destinationMemberIdentifier as MetadataIdentifier - ), - each, - { extraArgs } - ) - ) - ); - continue; + return destination; + } + + // Async path: a mapAsync()/mapArrayAsync() context is active. Track this + // map's depth so nested after-maps settle before their parents, and defer + // after-maps past member resolution. + const currentMapDepth = asyncMapDepth + 1; + + const runStepsAndAssert = () => { + return runAtAsyncMapDepth(currentMapDepth, () => { + for (let i = 0, length = steps.length; i < length; i++) { + (steps[i] as MapStep)(context); } - if (typeof mapInitializedValue === 'object') { - const nestedMapping = getMapping( - mapper, - sourceMemberIdentifier as MetadataIdentifier, - destinationMemberIdentifier as MetadataIdentifier - ); + // Check unmapped properties after mapping steps have had a chance to + // assign destination values. When an async beforeMap gates the steps, + // this assertion runs inside the gated async work as well. + assertUnmappedProperties( + destination, + unmappedCandidateKeys, + sourceIdentifier, + destinationIdentifier, + errorHandler + ); + }); + }; - // nested mutate - if (getMemberFn) { - const memberValue = getMemberFn(destinationMemberPath); - if (memberValue !== undefined) { - map({ - sourceObject: mapInitializedValue as TSource, - mapping: nestedMapping, - options: { extraArgs }, - setMemberFn: setMemberMutateFn(memberValue), - getMemberFn: getMemberMutateFn(memberValue), - }); - } - continue; - } - - setMember(() => - map({ - mapping: nestedMapping, - sourceObject: mapInitializedValue as TSource, - options: { extraArgs }, - setMemberFn: setMemberReturnFn, - }) + let stepsDeferredUntilBeforeMap = false; + const queueAfterMap = () => { + if (!isMapArray) { + const afterMap = mapAfterCallback ?? mappingAfterCallback; + if (afterMap) { + deferAsyncAfterMapAfterPending( + () => afterMap(sourceObject, destination, extraArguments), + currentMapDepth ); - continue; } - - // if is primitive - setMember(() => mapInitializedValue); - continue; } + }; - setMember(() => - mapMember( - transformationMapFn, + if (!isMapArray) { + const beforeMap = mapBeforeCallback ?? mappingBeforeCallback; + if (beforeMap) { + const beforeMapResult = beforeMap( sourceObject, destination, - destinationMemberPath, - extraArguments, - mapper, - sourceMemberIdentifier, - destinationMemberIdentifier - ) - ); - } - - if (!isMapArray) { - const afterMap = mapAfterCallback ?? mappingAfterCallback; - if (afterMap) { - afterMap(sourceObject, destination, extraArguments); + extraArguments + ); + if (isThenable(beforeMapResult)) { + const asyncCtx = asyncMapContext; + stepsDeferredUntilBeforeMap = true; + asyncMapContext.pending.push( + Promise.resolve(beforeMapResult).then(() => + runInAsyncMapContext(asyncCtx, () => { + runStepsAndAssert(); + queueAfterMap(); + }) + ) + ); + } else { + pushAsyncPending(beforeMapResult); + } } } - // Check unmapped properties - assertUnmappedProperties( - destination, - destinationWithMetadata, - configuredKeys, - sourceIdentifier, - destinationIdentifier, - errorHandler - ); + if (!stepsDeferredUntilBeforeMap) { + runStepsAndAssert(); + queueAfterMap(); + } return destination; } diff --git a/packages/core/src/lib/member-map-functions/map-from.ts b/packages/core/src/lib/member-map-functions/map-from.ts index 51eba135e..8b0473110 100644 --- a/packages/core/src/lib/member-map-functions/map-from.ts +++ b/packages/core/src/lib/member-map-functions/map-from.ts @@ -1,9 +1,9 @@ import type { Dictionary, MapFromReturn, + MaybePromise, Resolver, SelectorReturn, - ValueSelector, } from '../types'; import { TransformationType } from '../types'; import { isResolver } from '../utils/is-resolver'; @@ -14,7 +14,7 @@ export function mapFrom< TSelectorReturn = SelectorReturn >( from: - | ValueSelector + | ((source: TSource) => MaybePromise) | Resolver ): MapFromReturn { if (isResolver(from)) { diff --git a/packages/core/src/lib/member-map-functions/map-with-arguments.ts b/packages/core/src/lib/member-map-functions/map-with-arguments.ts index 026010447..8b552c1ec 100644 --- a/packages/core/src/lib/member-map-functions/map-with-arguments.ts +++ b/packages/core/src/lib/member-map-functions/map-with-arguments.ts @@ -1,6 +1,7 @@ import type { Dictionary, MapWithArgumentsReturn, + MaybePromise, Resolver, SelectorReturn, } from '../types'; @@ -16,7 +17,7 @@ export function mapWithArguments< | (( source: TSource, extraArguments: Record - ) => TSelectorReturn) + ) => MaybePromise) | Resolver, TSelectorReturn> ): MapWithArgumentsReturn { if (isResolver(withArgumentsResolver)) { diff --git a/packages/core/src/lib/member-map-functions/specs/convert-using.spec.ts b/packages/core/src/lib/member-map-functions/specs/convert-using.spec.ts index 4c83282aa..010ec2a68 100644 --- a/packages/core/src/lib/member-map-functions/specs/convert-using.spec.ts +++ b/packages/core/src/lib/member-map-functions/specs/convert-using.spec.ts @@ -14,7 +14,10 @@ describe(convertUsing.name, () => { }; it('should return correctly', () => { - const convertUsingFn = convertUsing( + const convertUsingFn = convertUsing< + typeof source, + Record + >( birthdayToStringConverter, (s) => s.birthday ); @@ -26,7 +29,10 @@ describe(convertUsing.name, () => { }); it('should map correctly', () => { - const convertUsingFn = convertUsing( + const convertUsingFn = convertUsing< + typeof source, + Record + >( birthdayToStringConverter, (s) => s.birthday ); diff --git a/packages/core/src/lib/member-map-functions/specs/map-with.spec.ts b/packages/core/src/lib/member-map-functions/specs/map-with.spec.ts index 166045ebf..381ecf8e5 100644 --- a/packages/core/src/lib/member-map-functions/specs/map-with.spec.ts +++ b/packages/core/src/lib/member-map-functions/specs/map-with.spec.ts @@ -7,7 +7,7 @@ describe(mapWith.name, () => { const withDestination = ''; const withSource = ''; - const mapper = { map: jest.fn(), mapArray: jest.fn() }; + const mapper = { map: vi.fn(), mapArray: vi.fn() }; it('should return correctly', () => { const mapWithFn = mapWith(withDestination, withSource, selector); 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 8fcbe8ebe..6b19262f4 100644 --- a/packages/core/src/lib/types.ts +++ b/packages/core/src/lib/types.ts @@ -13,10 +13,18 @@ import type { export type Dictionary = { [key in keyof T]?: unknown }; export type AnyConstructor = new (...args: any[]) => any; +export type AbstractConstructor = abstract new (...args: any[]) => T; export type Constructor = (new (...args: any[]) => T) & - TransformerMetadataFactory; - -export type Primitive = String | Number | Boolean | BigInt; + TransformerMetadataFactory; +export type ClassIdentifier = + | Constructor + | (AbstractConstructor & TransformerMetadataFactory) + // Supports abstract and private constructors, which cannot satisfy a + // public construct signature but are still valid runtime identifiers. + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + | (Function & TransformerMetadataFactory & { prototype: T }); + +export type Primitive = string | number | boolean | bigint; export type PrimitiveExtended = Primitive | Date; export type PrimitiveConstructor = @@ -36,11 +44,11 @@ export type PrimitiveConstructorReturnType< ? InstanceType : ReturnType>; -export interface TransformerMetadataFactory> { +export interface TransformerMetadataFactory { __AUTOMAPPER_METADATA_FACTORY__?: () => [ propertyKey: string, options: { - type: () => Constructor | [Constructor]; + type: () => ClassIdentifier | [ClassIdentifier]; depth: number; isGetterOnly?: boolean; } @@ -64,6 +72,14 @@ export type NamingConventionInput = destination: NamingConvention; }; +// The `= any` generic defaults below (Selector/ValueSelector/Resolver/Converter +// and ModelIdentifier/Constructor) are intentional, not laziness. AutoMapper is a +// runtime, identifier-driven mapper whose types are routinely used without +// explicit generics (`createMap(mapper, Source, Dest)` infers them); an `any` +// default keeps that ergonomic and avoids forcing `unknown` casts through the +// dynamic core, while concrete call sites still infer precise types. Data that +// crosses the user boundary (extraArguments) is `unknown`, and return positions +// are `unknown` rather than `any`. export type Selector< TObject extends Dictionary = any, TReturnType = unknown @@ -73,6 +89,8 @@ export type SelectorReturn> = ReturnType< Selector >; +export type MaybePromise = T | Promise; + export type ValueSelector< TSource extends Dictionary = any, TDestination extends Dictionary = any, @@ -84,7 +102,10 @@ export interface Resolver< TDestination extends Dictionary = any, TResolvedType = SelectorReturn > { - resolve(source: TSource, destination?: TDestination): TResolvedType; + resolve( + source: TSource, + destination?: TDestination + ): MaybePromise; } export interface Converter< @@ -97,17 +118,17 @@ export interface Converter< export type MapCallback< TSource extends Dictionary, TDestination extends Dictionary, - TExtraArgs extends Record = Record + TExtraArgs extends Record = Record > = ( source: TSource, destination: TDestination, extraArguments?: TExtraArgs -) => void; +) => MaybePromise; export interface MapOptions< TSource extends Dictionary, TDestination extends Dictionary, - TExtraArgs extends Record = Record + TExtraArgs extends Record = Record > { beforeMap?: MapCallback; afterMap?: MapCallback; @@ -118,7 +139,7 @@ export interface MapOptions< ) => TExtraArgs; } -export type ModelIdentifier = string | symbol | Constructor; +export type ModelIdentifier = string | symbol | ClassIdentifier; export type MetadataIdentifier = Exclude, string>; @@ -157,6 +178,15 @@ export interface Mapper { options?: MapOptions ): TSource; + /** + * Maps `sourceObject` and resolves with the result. + * + * @remarks + * Member mapping itself is synchronous, but any `beforeMap`/`afterMap` + * callbacks that return a promise are collected and awaited before the + * returned promise resolves. Use the synchronous {@link map} when no async + * callbacks are involved. + */ mapAsync< TSource extends Dictionary, TDestination extends Dictionary @@ -357,23 +387,26 @@ export type MapDeferReturn< TDestination extends Dictionary, TSelectorReturn = SelectorReturn > = [ - TransformationType.MapDefer, - DeferFunction + type: TransformationType.MapDefer, + fn: DeferFunction ]; export type MapFromReturn< TSource extends Dictionary, TDestination extends Dictionary, TSelectorReturn = SelectorReturn -> = [TransformationType.MapFrom, Selector]; +> = [ + type: TransformationType.MapFrom, + fn: Selector> +]; export type MapWithReturn< TSource extends Dictionary, TDestination extends Dictionary, TSelectorReturn = SelectorReturn > = [ - TransformationType.MapWith, - ( + type: TransformationType.MapWith, + fn: ( sourceObj: TSource, mapper: Mapper, options?: MapOptions @@ -389,29 +422,32 @@ export type ConditionReturn< TDestination extends Dictionary, TSelectorReturn = SelectorReturn > = [ - TransformationType.Condition, - (source: TSource, sourceMemberPath: string[]) => TSelectorReturn + type: TransformationType.Condition, + fn: (source: TSource, sourceMemberPath: string[]) => TSelectorReturn ]; export type FromValueReturn< TSource extends Dictionary, TDestination extends Dictionary, TSelectorReturn = SelectorReturn -> = [TransformationType.FromValue, () => TSelectorReturn]; +> = [type: TransformationType.FromValue, fn: () => TSelectorReturn]; export type ConvertUsingReturn< TSource extends Dictionary, TDestination extends Dictionary, TSelectorReturn = SelectorReturn -> = [TransformationType.ConvertUsing, Selector]; +> = [ + type: TransformationType.ConvertUsing, + fn: Selector +]; export type NullSubstitutionReturn< TSource extends Dictionary, TDestination extends Dictionary, TSelectorReturn = SelectorReturn > = [ - TransformationType.NullSubstitution, - (source: TSource, sourceMemberPath: string[]) => TSelectorReturn + type: TransformationType.NullSubstitution, + fn: (source: TSource, sourceMemberPath: string[]) => TSelectorReturn ]; export type UndefinedSubstitutionReturn< @@ -419,22 +455,25 @@ export type UndefinedSubstitutionReturn< TDestination extends Dictionary, TSelectorReturn = SelectorReturn > = [ - TransformationType.UndefinedSubstitution, - (source: TSource, sourceMemberPath: string[]) => TSelectorReturn + type: TransformationType.UndefinedSubstitution, + fn: (source: TSource, sourceMemberPath: string[]) => TSelectorReturn ]; export type IgnoreReturn< TSource extends Dictionary, TDestination extends Dictionary -> = [TransformationType.Ignore]; +> = [type: TransformationType.Ignore]; export type MapWithArgumentsReturn< TSource extends Dictionary, TDestination extends Dictionary, TSelectorReturn = SelectorReturn > = [ - TransformationType.MapWithArguments, - (source: TSource, extraArguments: Record) => TSelectorReturn + type: TransformationType.MapWithArguments, + fn: ( + source: TSource, + extraArguments: Record + ) => MaybePromise ]; export type MapInitializeReturn< @@ -442,9 +481,9 @@ export type MapInitializeReturn< TDestination extends Dictionary, TSelectorReturn = SelectorReturn > = [ - TransformationType.MapInitialize, - Selector, - boolean? + type: TransformationType.MapInitialize, + fn: Selector, + isConverted?: boolean ]; export const enum MappingTransformationClassId { @@ -486,6 +525,8 @@ export const enum MappingPropertiesClassId { export const enum MappingCallbacksClassId { beforeMap, afterMap, + beforeMapArray, + afterMapArray, } export const enum NestedMappingPairClassId { @@ -494,10 +535,56 @@ export const enum NestedMappingPairClassId { } export type NestedMappingPair = [ - MetadataIdentifier | Primitive | Date, - MetadataIdentifier | Primitive | Date + destination: MetadataIdentifier | Primitive | Date, + source: MetadataIdentifier | Primitive | Date ]; +// --- Compiled mapping plan ------------------------------------------------- +// A mapping's `properties` are positional tuples whose shape is fixed at +// createMap time. Rather than re-destructuring that nested tuple on every +// map() call, we destructure once into a flat descriptor list and hang it on +// the mapping itself (MappingClassId.compiledPlan), built eagerly at createMap. +// `sourceMemberIdentifier`/`destinationMemberIdentifier` equality that depends +// on the mapper's registry (which can grow later) is NOT baked in here. +export interface CompiledMappingProperty< + TSource extends Dictionary = any, + TDestination extends Dictionary = any +> { + destinationMemberPath: string[]; + transformationMapFn: MemberMapReturn; + transformationType: TransformationType; + transformationPreConditionPredicate?: (source: TSource) => boolean; + transformationPreConditionDefaultValue?: unknown; + destinationMemberIdentifier?: MetadataIdentifier | Primitive | Date; + sourceMemberIdentifier?: MetadataIdentifier | Primitive | Date; +} + +// A single property's work, specialized once at compile time so the map() loop +// runs `steps[i](context)` with no per-member type switch or descriptor +// destructuring. The context is map()-internal; kept opaque here. +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[]; +} + export const enum MappingClassId { identifiers, identifierMetadata, @@ -508,6 +595,7 @@ export const enum MappingClassId { typeConverters, callbacks, namingConventions, + compiledPlan, } export type Mapping< @@ -564,12 +652,16 @@ export type Mapping< >, callbacks?: [ beforeMap?: MapCallback, - afterMap?: MapCallback + afterMap?: MapCallback, + beforeMapArray?: MapCallback, + afterMapArray?: MapCallback ], namingConventions?: [ source: NamingConvention, destination: NamingConvention - ] + ], + // built eagerly at createMap; flat per-property descriptors consumed by map() + compiledPlan?: CompiledMapping ]; export type DataMap = Map; diff --git a/packages/core/src/lib/utils/assert-unmapped-properties.ts b/packages/core/src/lib/utils/assert-unmapped-properties.ts index fb8472847..017d72613 100644 --- a/packages/core/src/lib/utils/assert-unmapped-properties.ts +++ b/packages/core/src/lib/utils/assert-unmapped-properties.ts @@ -5,47 +5,84 @@ import type { MetadataIdentifier, } from '../types'; +// 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[] { + let keys = writableKeysCache.get(destinationMetadata); + if (keys === undefined) { + keys = Object.keys(destinationMetadata).filter( + (key) => + Object.getOwnPropertyDescriptor(destinationMetadata, key) + ?.writable === true + ); + writableKeysCache.set(destinationMetadata, keys); + } + 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 unmappedKeys = Object.keys(destinationMetadata).reduce( - (result, key) => { - const isOnDestination = key in destinationObject; - const isAlreadyConfigured = configuredKeys.some( - (configuredKey) => configuredKey === key - ); - const isWritable = - Object.getOwnPropertyDescriptor(destinationMetadata, key) - ?.writable === true; - if ( - !isAlreadyConfigured && - !isOnDestination && - isWritable && - destinationObject[key as keyof typeof destinationObject] === - undefined - ) { - result.push(key); - } - return result; - }, - [] as string[] - ); + if (unmappedCandidateKeys.length === 0) { + return; + } - const sourceText = getTextFromIdentifier(sourceIdentifier); - const destinationText = getTextFromIdentifier(destinationIdentifier); + const unmappedKeys: string[] = []; + for (let i = 0, len = unmappedCandidateKeys.length; i < len; i++) { + const key = unmappedCandidateKeys[i]; + if ( + !(key in destinationObject) && + destinationObject[key as keyof typeof destinationObject] === + undefined + ) { + unmappedKeys.push(key); + } + } if (unmappedKeys.length) { - const parentInfo = `${sourceText} -> ${destinationText}`; + const parentInfo = `${getTextFromIdentifier( + sourceIdentifier + )} -> ${getTextFromIdentifier(destinationIdentifier)}`; errorHandler.handle(` Unmapped properties for ${parentInfo}: ------------------- diff --git a/packages/core/src/lib/utils/get-mapping.ts b/packages/core/src/lib/utils/get-mapping.ts index 6e3f8cf49..b317fc5f7 100644 --- a/packages/core/src/lib/utils/get-mapping.ts +++ b/packages/core/src/lib/utils/get-mapping.ts @@ -1,3 +1,4 @@ +import { MappingNotFoundError } from '../errors'; import { getErrorHandler, getMappings } from '../symbols'; import type { Mapper, @@ -27,10 +28,9 @@ export function getMapping( typeof source === 'function' ? (source.name || String(source)) : String(source); const destinationName = typeof destination === 'function' ? (destination.name || String(destination)) : String(destination); - const errorHandler = getErrorHandler(mapper); - const errorMessage = `Mapping is not found for ${sourceName} and ${destinationName}`; - errorHandler.handle(errorMessage); - throw new Error(errorMessage); + const error = new MappingNotFoundError(sourceName, destinationName); + getErrorHandler(mapper).handle(error.message); + throw error; } return mapping as Mapping; 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 319a8f42e..7cf3124cb 100644 --- a/packages/core/src/lib/utils/get-path.ts +++ b/packages/core/src/lib/utils/get-path.ts @@ -24,6 +24,13 @@ 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) @@ -36,7 +43,9 @@ export function getFlatteningPaths( ); let trueFirstPartOfSource = first; let stopIndex = 0; - let found = hasProperty(src, trueFirstPartOfSource); + let found = + hasProperty(src, trueFirstPartOfSource) && + isRecord(src[trueFirstPartOfSource]); if (!found) { for (let i = 0, len = paths.length; i < len; i++) { @@ -45,7 +54,10 @@ export function getFlatteningPaths( trueFirstPartOfSource, paths[i], ]); - if (hasProperty(src, trueFirstPartOfSource)) { + if ( + hasProperty(src, trueFirstPartOfSource) && + isRecord(src[trueFirstPartOfSource]) + ) { stopIndex = i + 1; found = true; break; @@ -66,6 +78,7 @@ export function getFlatteningPaths( if ( restPaths.length > 1 && + isRecord(src[trueFirstPartOfSource]) && !hasProperty( src[trueFirstPartOfSource] as Record, transformedRestPaths @@ -100,3 +113,7 @@ export function getFlatteningPaths( function hasProperty(obj: Record, property: string): boolean { return Object.prototype.hasOwnProperty.call(obj, property); } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} 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/is-date-constructor.ts b/packages/core/src/lib/utils/is-date-constructor.ts index 0df595a98..5447cab69 100644 --- a/packages/core/src/lib/utils/is-date-constructor.ts +++ b/packages/core/src/lib/utils/is-date-constructor.ts @@ -4,5 +4,9 @@ * @param {Function} value */ export function isDateConstructor(value: unknown): boolean { + // guard: Object.getPrototypeOf(null|undefined) throws. Reachable from + // @automapper/classes get-metadata-list when a user type factory returns + // nullish/[] (isDateConstructor is called before the primitive check there). + if (value == null) return false; return Object.getPrototypeOf(value) === Date || value === Date; } diff --git a/packages/core/src/lib/utils/is-mappable-identifier.ts b/packages/core/src/lib/utils/is-mappable-identifier.ts new file mode 100644 index 000000000..3bae998d7 --- /dev/null +++ b/packages/core/src/lib/utils/is-mappable-identifier.ts @@ -0,0 +1,21 @@ +import { isDateConstructor } from './is-date-constructor'; +import { isPrimitiveConstructor } from './is-primitive-constructor'; + +// Result is constant per identifier but was recomputed (4 predicate calls) per +// member per mapped object — i.e. per element in mapArray. Memoize it. +const cache = new Map(); + +/** + * True when the identifier is a "real" model identifier (not a primitive or Date + * constructor) — i.e. a candidate for nested (member) mapping. + */ +export function isMappableIdentifier(identifier: unknown): boolean { + let result = cache.get(identifier); + if (result === undefined) { + result = + !isPrimitiveConstructor(identifier) && + !isDateConstructor(identifier); + cache.set(identifier, result); + } + return result; +} diff --git a/packages/core/src/lib/utils/logger.ts b/packages/core/src/lib/utils/logger.ts index 1bc18caa3..7494fdff7 100644 --- a/packages/core/src/lib/utils/logger.ts +++ b/packages/core/src/lib/utils/logger.ts @@ -1,34 +1,156 @@ +export type AutoMapperLogFn = ( + message: unknown, + ...optionalParams: unknown[] +) => void; + +export interface AutoMapperLoggerLike { + log?: AutoMapperLogFn; + info?: AutoMapperLogFn; + warn?: AutoMapperLogFn; + error?: AutoMapperLogFn; + debug?: AutoMapperLogFn; + verbose?: AutoMapperLogFn; + fatal?: AutoMapperLogFn; + trace?: AutoMapperLogFn; +} + +type AutoMapperDefaultLogger = Required< + Omit +> & + Pick; + +const AUTO_MAPPER_LOG_LEVELS = [ + 'log', + 'info', + 'warn', + 'error', + 'debug', + 'verbose', + 'fatal', + 'trace', +] as const; + export class AutoMapperLogger { private static readonly AUTOMAPPER_PREFIX = '[AutoMapper]: ' as const; - private static configured = false; - - static configure( - customLogger: Partial< - Pick - > = {} - ) { - if (this.configured) return; - this.configured = true; - Object.entries(customLogger).forEach(([logLevel, logImpl]) => { - if (logImpl !== undefined) { - this[logLevel as 'log' | 'info' | 'error' | 'warn'] = logImpl; - } - }); + + private static readonly defaultLogger: AutoMapperDefaultLogger = { + log: (message, ...optionalParams) => { + console.log.call( + console, + AutoMapperLogger.AUTOMAPPER_PREFIX, + message, + ...optionalParams + ); + }, + info: (message, ...optionalParams) => { + console.info.call( + console, + AutoMapperLogger.AUTOMAPPER_PREFIX, + message, + ...optionalParams + ); + }, + warn: (message, ...optionalParams) => { + console.warn.call( + console, + AutoMapperLogger.AUTOMAPPER_PREFIX, + message, + ...optionalParams + ); + }, + error: (message, ...optionalParams) => { + console.error.call( + console, + AutoMapperLogger.AUTOMAPPER_PREFIX, + message, + ...optionalParams + ); + }, + debug: (message, ...optionalParams) => { + console.debug.call( + console, + AutoMapperLogger.AUTOMAPPER_PREFIX, + message, + ...optionalParams + ); + }, + verbose: (message, ...optionalParams) => { + console.debug.call( + console, + AutoMapperLogger.AUTOMAPPER_PREFIX, + message, + ...optionalParams + ); + }, + fatal: (message, ...optionalParams) => { + console.error.call( + console, + AutoMapperLogger.AUTOMAPPER_PREFIX, + message, + ...optionalParams + ); + }, + trace: undefined, + }; + + private static currentLogger = AutoMapperLogger.defaultLogger; + + static configure(customLogger: AutoMapperLoggerLike = {}) { + const previousLogger = this.currentLogger; + this.currentLogger = this.createLogger(customLogger); + + return () => { + this.currentLogger = previousLogger; + }; + } + + static reset() { + this.currentLogger = this.defaultLogger; + } + + static log(message: unknown, ...optionalParams: unknown[]) { + this.currentLogger.log(message, ...optionalParams); + } + + static warn(message: unknown, ...optionalParams: unknown[]) { + this.currentLogger.warn(message, ...optionalParams); + } + + static error(message: unknown, ...optionalParams: unknown[]) { + this.currentLogger.error(message, ...optionalParams); + } + + static info(message: unknown, ...optionalParams: unknown[]) { + this.currentLogger.info(message, ...optionalParams); } - static log(message: string) { - console.log.bind(console, this.AUTOMAPPER_PREFIX, message); + static debug(message: unknown, ...optionalParams: unknown[]) { + this.currentLogger.debug(message, ...optionalParams); } - static warn(warning: string) { - console.warn.bind(console, this.AUTOMAPPER_PREFIX, warning); + static verbose(message: unknown, ...optionalParams: unknown[]) { + this.currentLogger.verbose(message, ...optionalParams); } - static error(error: string) { - console.error.bind(console, this.AUTOMAPPER_PREFIX, error); + static fatal(message: unknown, ...optionalParams: unknown[]) { + this.currentLogger.fatal(message, ...optionalParams); } - static info(info: string) { - console.info.bind(console, this.AUTOMAPPER_PREFIX, info); + static get trace() { + return this.currentLogger.trace; + } + + private static createLogger(customLogger: AutoMapperLoggerLike) { + const logger: AutoMapperDefaultLogger = { ...this.defaultLogger }; + + for (const logLevel of AUTO_MAPPER_LOG_LEVELS) { + const logImpl = customLogger[logLevel]; + + if (logImpl !== undefined) { + logger[logLevel] = logImpl; + } + } + + return logger; } } 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/set.ts b/packages/core/src/lib/utils/set.ts index 4c8c3a5b3..98bcb3497 100644 --- a/packages/core/src/lib/utils/set.ts +++ b/packages/core/src/lib/utils/set.ts @@ -1,28 +1,32 @@ export function set>( object: T, path: string[], - value: unknown + value: unknown, + index = 0 ): (T & { [p: string]: unknown }) | T { - const { decomposedPath, base } = decomposePath(path); + const obj = object as Record; - if (base === undefined) { + // empty path writes the '' key (preserves prior decomposePath base='' behavior) + if (path.length < 1) { + obj[''] = value; return object; } - // assign an empty object in order to spread object - assignEmpty(object, base); + const base = path[index]; - // Determine if there is still layers to traverse - value = - decomposedPath.length <= 1 - ? value - : set( - object[base] as Record, - decomposedPath.slice(1), - value - ); + // leaf: write directly. Avoids the per-level `Object.assign({[base]: value})` + // temp object and `path.slice(1)` array allocation of the old recursion + // (mirrors setMutate, which already does this). + if (index >= path.length - 1) { + obj[base] = value; + return object; + } - return Object.assign(object, { [base]: value }); + if (!Object.prototype.hasOwnProperty.call(object, base)) { + obj[base] = {}; + } + set(obj[base] as Record, path, value, index + 1); + return object; } export function setMutate>( @@ -64,7 +68,7 @@ function decomposePath(path: string[]): { } function assignEmpty(obj: Record, base: string) { - if (!obj.hasOwnProperty(base)) { + if (!Object.prototype.hasOwnProperty.call(obj, base)) { obj[base] = {}; } } 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-path.spec.ts b/packages/core/src/lib/utils/specs/get-path.spec.ts new file mode 100644 index 000000000..e6c328544 --- /dev/null +++ b/packages/core/src/lib/utils/specs/get-path.spec.ts @@ -0,0 +1,63 @@ +import { CamelCaseNamingConvention } from '../../naming-conventions/camel-case-naming-convention'; +import { SnakeCaseNamingConvention } from '../../naming-conventions/snake-case-naming-convention'; +import { getFlatteningPaths } from '../get-path'; + +describe(getFlatteningPaths.name, () => { + const namingConventions: [ + SnakeCaseNamingConvention, + CamelCaseNamingConvention + ] = [new SnakeCaseNamingConvention(), new CamelCaseNamingConvention()]; + + it('should keep similar-prefix source paths direct when the prefix is not an object', () => { + const source = { + deleted: undefined, + deleted_by: undefined, + }; + + expect( + getFlatteningPaths(source, ['deleted_by'], namingConventions) + ).toEqual(['deleted_by']); + }); + + it('should keep longer similar-prefix source paths direct when the prefix is not an object', () => { + const source = { + inspection_order_status: undefined, + inspection_order_status_id: undefined, + }; + + expect( + getFlatteningPaths( + source, + ['inspection_order_status_id'], + namingConventions + ) + ).toEqual(['inspection_order_status_id']); + }); + + it('should not throw when the first similar-prefix source member is undefined', () => { + const source = { + password: undefined, + password_reset_token: undefined, + }; + + expect( + getFlatteningPaths( + source, + ['password_reset_token'], + namingConventions + ) + ).toEqual(['password_reset_token']); + }); + + it('should still flatten real nested object paths', () => { + const source = { + job: { + title: undefined, + }, + }; + + expect( + getFlatteningPaths(source, ['job_title'], namingConventions) + ).toEqual(['job', 'title']); + }); +}); 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/specs/logger.spec.ts b/packages/core/src/lib/utils/specs/logger.spec.ts new file mode 100644 index 000000000..5dd954f93 --- /dev/null +++ b/packages/core/src/lib/utils/specs/logger.spec.ts @@ -0,0 +1,112 @@ +import { AutoMapperLogger } from '../logger'; + +describe(AutoMapperLogger.name, () => { + afterEach(() => { + AutoMapperLogger.reset(); + vi.restoreAllMocks(); + }); + + it.each([ + ['log', 'log', 'message'], + ['warn', 'warn', 'warning'], + ['error', 'error', 'error'], + ['info', 'info', 'info'], + ['debug', 'debug', 'debug'], + ['verbose', 'debug', 'verbose'], + ['fatal', 'error', 'fatal'], + ] as const)( + 'should route AutoMapperLogger.%s to console.%s', + (method, consoleMethod, message) => { + const spy = vi + .spyOn(console, consoleMethod) + .mockImplementation(() => { + // noop + }); + + AutoMapperLogger[method](message, 'context'); + + expect(spy).toHaveBeenCalledWith( + '[AutoMapper]: ', + message, + 'context' + ); + } + ); + + it('should not provide trace by default', () => { + expect(AutoMapperLogger.trace).toBeUndefined(); + }); + + it('should configure trace when provided', () => { + const trace = vi.fn(); + + AutoMapperLogger.configure({ trace }); + AutoMapperLogger.trace?.('diagnostic', { source: 'test' }); + + expect(trace).toHaveBeenCalledWith('diagnostic', { source: 'test' }); + }); + + it('should override previous custom configuration instead of merging with it', () => { + const customLog = vi.fn(); + const customWarn = vi.fn(); + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => { + // noop + }); + + AutoMapperLogger.configure({ log: customLog }); + AutoMapperLogger.log('custom'); + + AutoMapperLogger.configure({ warn: customWarn }); + AutoMapperLogger.warn('warning'); + AutoMapperLogger.log('default'); + + expect(customLog).toHaveBeenCalledWith('custom'); + expect(customWarn).toHaveBeenCalledWith('warning'); + expect(customLog).not.toHaveBeenCalledWith('default'); + expect(consoleLog).toHaveBeenCalledWith('[AutoMapper]: ', 'default'); + }); + + it('should restore the logger state captured before configure', () => { + const firstLog = vi.fn(); + const secondLog = vi.fn(); + + AutoMapperLogger.configure({ log: firstLog }); + const restore = AutoMapperLogger.configure({ log: secondLog }); + + AutoMapperLogger.log('second'); + restore(); + AutoMapperLogger.log('first'); + + expect(secondLog).toHaveBeenCalledWith('second'); + expect(firstLog).toHaveBeenCalledWith('first'); + }); + + it('should reset to the default logger', () => { + const customError = vi.fn(); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => { + // noop + }); + + AutoMapperLogger.configure({ error: customError }); + AutoMapperLogger.error('custom'); + + AutoMapperLogger.reset(); + AutoMapperLogger.error('default'); + + expect(customError).toHaveBeenCalledWith('custom'); + expect(customError).not.toHaveBeenCalledWith('default'); + expect(consoleError).toHaveBeenCalledWith('[AutoMapper]: ', 'default'); + }); + + it('should route a previously bound method through the current logger', () => { + const boundError = AutoMapperLogger.error.bind(AutoMapperLogger); + const customError = vi.fn(); + + AutoMapperLogger.configure({ error: customError }); + boundError('bound'); + + expect(customError).toHaveBeenCalledWith('bound'); + }); +}); 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/core/tsconfig.lib.json b/packages/core/tsconfig.lib.json index 2214039d4..c3bd0416e 100644 --- a/packages/core/tsconfig.lib.json +++ b/packages/core/tsconfig.lib.json @@ -1,10 +1,11 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../dist/out-tsc", + "outDir": "../../dist/out-tsc/core/lib", + "composite": true, "declaration": true, "types": [] }, - "include": ["**/*.ts"], + "include": ["src/**/*.ts"], "exclude": ["**/*.spec.ts", "**/*.test.ts"] } diff --git a/packages/core/tsconfig.spec.json b/packages/core/tsconfig.spec.json index 0eabbc4eb..7605bc918 100644 --- a/packages/core/tsconfig.spec.json +++ b/packages/core/tsconfig.spec.json @@ -1,9 +1,24 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../dist/out-tsc", + "outDir": "../../dist/out-tsc/core/spec", "module": "commonjs", - "types": ["jest", "node", "reflect-metadata"] + "composite": true, + "declaration": true, + "types": [ + "vitest/globals", + "node", + "reflect-metadata" + ] }, - "include": ["**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] + "include": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/*.d.ts" + ], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] } diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 000000000..87e7ee687 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,3 @@ +import { vitestConfig } from '../../vitest.shared'; + +export default vitestConfig(); diff --git a/packages/documentation/.gitignore b/packages/documentation/.gitignore deleted file mode 100644 index b2d6de306..000000000 --- a/packages/documentation/.gitignore +++ /dev/null @@ -1,20 +0,0 @@ -# Dependencies -/node_modules - -# Production -/build - -# Generated files -.docusaurus -.cache-loader - -# Misc -.DS_Store -.env.local -.env.development.local -.env.test.local -.env.production.local - -npm-debug.log* -yarn-debug.log* -yarn-error.log* diff --git a/packages/documentation/README.md b/packages/documentation/README.md deleted file mode 100644 index aaba2fa1e..000000000 --- a/packages/documentation/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Website - -This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. - -### Installation - -``` -$ yarn -``` - -### Local Development - -``` -$ yarn start -``` - -This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. - -### Build - -``` -$ yarn build -``` - -This command generates static content into the `build` directory and can be served using any static contents hosting service. - -### Deployment - -Using SSH: - -``` -$ USE_SSH=true yarn deploy -``` - -Not using SSH: - -``` -$ GIT_USER= yarn deploy -``` - -If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. diff --git a/packages/documentation/babel.config.js b/packages/documentation/babel.config.js deleted file mode 100644 index e00595dae..000000000 --- a/packages/documentation/babel.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - presets: [require.resolve('@docusaurus/core/lib/babel/preset')], -}; diff --git a/packages/documentation/blog/2019-05-28-first-blog-post.md b/packages/documentation/blog/2019-05-28-first-blog-post.md deleted file mode 100644 index 02f3f81bd..000000000 --- a/packages/documentation/blog/2019-05-28-first-blog-post.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -slug: first-blog-post -title: First Blog Post -authors: - name: Gao Wei - title: Docusaurus Core Team - url: https://github.com/wgao19 - image_url: https://github.com/wgao19.png -tags: [hola, docusaurus] ---- - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet diff --git a/packages/documentation/blog/2019-05-29-long-blog-post.md b/packages/documentation/blog/2019-05-29-long-blog-post.md deleted file mode 100644 index 26ffb1b1f..000000000 --- a/packages/documentation/blog/2019-05-29-long-blog-post.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -slug: long-blog-post -title: Long Blog Post -authors: endi -tags: [hello, docusaurus] ---- - -This is the summary of a very long blog post, - -Use a `` comment to limit blog post size in the list view. - - - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet diff --git a/packages/documentation/blog/2021-08-01-mdx-blog-post.mdx b/packages/documentation/blog/2021-08-01-mdx-blog-post.mdx deleted file mode 100644 index c04ebe323..000000000 --- a/packages/documentation/blog/2021-08-01-mdx-blog-post.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -slug: mdx-blog-post -title: MDX Blog Post -authors: [slorber] -tags: [docusaurus] ---- - -Blog posts support [Docusaurus Markdown features](https://docusaurus.io/docs/markdown-features), such as [MDX](https://mdxjs.com/). - -:::tip - -Use the power of React to create interactive blog posts. - -```js - -``` - - - -::: diff --git a/packages/documentation/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg b/packages/documentation/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg deleted file mode 100644 index 11bda0928..000000000 Binary files a/packages/documentation/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg and /dev/null differ diff --git a/packages/documentation/blog/2021-08-26-welcome/index.md b/packages/documentation/blog/2021-08-26-welcome/index.md deleted file mode 100644 index 9455168f1..000000000 --- a/packages/documentation/blog/2021-08-26-welcome/index.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -slug: welcome -title: Welcome -authors: [slorber, yangshun] -tags: [facebook, hello, docusaurus] ---- - -[Docusaurus blogging features](https://docusaurus.io/docs/blog) are powered by the [blog plugin](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-blog). - -Simply add Markdown files (or folders) to the `blog` directory. - -Regular blog authors can be added to `authors.yml`. - -The blog post date can be extracted from filenames, such as: - -- `2019-05-30-welcome.md` -- `2019-05-30-welcome/index.md` - -A blog post folder can be convenient to co-locate blog post images: - -![Docusaurus Plushie](./docusaurus-plushie-banner.jpeg) - -The blog supports tags as well! - -**And if you don't want a blog**: just delete this directory, and use `blog: false` in your Docusaurus config. diff --git a/packages/documentation/blog/authors.yml b/packages/documentation/blog/authors.yml deleted file mode 100644 index bcb299156..000000000 --- a/packages/documentation/blog/authors.yml +++ /dev/null @@ -1,17 +0,0 @@ -endi: - name: Endilie Yacop Sucipto - title: Maintainer of Docusaurus - url: https://github.com/endiliey - image_url: https://github.com/endiliey.png - -yangshun: - name: Yangshun Tay - title: Front End Engineer @ Facebook - url: https://github.com/yangshun - image_url: https://github.com/yangshun.png - -slorber: - name: Sébastien Lorber - title: Docusaurus maintainer - url: https://sebastienlorber.com - image_url: https://github.com/slorber.png diff --git a/packages/documentation/docs/fundamentals/auto-flattening.mdx b/packages/documentation/docs/fundamentals/auto-flattening.mdx deleted file mode 100644 index ca3a8de74..000000000 --- a/packages/documentation/docs/fundamentals/auto-flattening.mdx +++ /dev/null @@ -1,123 +0,0 @@ ---- -id: auto-flattening -title: Auto Flattening -sidebar_label: Auto Flattening -sidebar_position: 4 ---- - -One of the common usages of object-object mapping is to take a complex object model and flatten it to a simpler model. If we set up the models following the convention, AutoMapper can help with flattening automatically. To enable Auto Flattening, we need to set the [NamingConvention](./naming-convention) for our models. - -Let's assume we have the following complex models: - -```ts -class Product { - @AutoMap() - price!: number; - @AutoMap() - name!: string; - - constructor(price: number, name: string) { - this.price = price; - this.name = name; - } -} - -class Customer { - @AutoMap() - name!: string; - - constructor(name: string) { - this.name = name; - } -} - -class OrderItem { - @AutoMap(() => Product) - product!: Product; - @AutoMap() - quantity!: number; - - constructor(product: Product, quantity: number) { - this.product = product; - this.quantity = quantity; - } - - get total() { - return this.product.price * this.quantity; - } -} - -class Order { - @AutoMap(() => [OrderItem]) - items: OrderItem[] = []; - @AutoMap(() => Customer) - customer!: Customer; - - constructor(customer: Customer) { - this.customer = customer; - } - - @AutoMap() // 👇 need to specify the type for getter - get total(): number { - return this.items.reduce((sum, item) => sum + item.total, 0); - } - - addItem(product: Product, quantity: number) { - this.items.push(new OrderItem(product, quantity)); - } -} -``` - -Now, we want to map the complex `Order` model to a simpler `OrderDto` that contains only the data needed for a certain scenario: - -```ts -class OrderDto { - @AutoMap() - customerName!: string; - @AutoMap() - total!: number; -} -``` - -Assuming we have Auto Flattening enabled by applying a `NamingConvention`, AutoMapper will attempt to match the properties on `OrderDto` against `Order` and its child models (`Product` and `Customer`) when we call `createMap(mapper, Order, OrderDto)` - -```ts -// complex model -const product = new Product(5, 'Fried Chicken'); -const customer = new Customer('Chau Tran'); -const order = new Order(customer); -order.addItem(product, 10); -/** - * Order { - * customer: Customer { - * name: 'Chau Tran' - * }, - * items: [ - * OrderItem { - * product: Product { - * price: 5, - * name: 'Fried Chicken - * }, - * quantity: 10 - * } - * ] - * } - */ - -// configure AutoMapper with NamingConvention -const mapper = createMapper({ - strategyInitializer: classes(), - // 👇 apply the CamelCaseNamingConvention - namingConventions: new CamelCaseNamingConvention(), -}); - -createMap(mapper, Order, OrderDto); - -const dto = mapper.map(order, Order, OrderDto); -/** - * OrderDto { - * customerName: 'Chau Tran', - * total: 50 - * } - */ -``` diff --git a/packages/documentation/docs/fundamentals/mapper.mdx b/packages/documentation/docs/fundamentals/mapper.mdx deleted file mode 100644 index 7e1c69c78..000000000 --- a/packages/documentation/docs/fundamentals/mapper.mdx +++ /dev/null @@ -1,80 +0,0 @@ ---- -id: mapper -title: Mapper -sidebar_label: Mapper -sidebar_position: 1 ---- - -## Mapper - -`Mapper` is the main character in AutoMapper TypeScript. Everything starts with a `Mapper`. To create a `Mapper`, call `createMapper()` along with a [Strategy](#mappingstrategy) - -```ts -const mapper = createMapper({ - strategyInitializer: classes(), -}); -``` - -## MappingStrategy - -In order for `Mapper` to map properties, `Mapper` needs to know about the properties' metadata. That's what the Strategy provides to the `Mapper`. A Strategy deals with: - -- Discover the metadata: Based on the user input, a strategy needs to know how to discover the metadata of the models from that input. E.g: `@automapper/classes` can discover the metadata with `@AutoMap` decorator. -- Retrieve the metadata: A strategy has its own "storage" to store the raw metadata. In order for `Mapper` to work consistently, a Strategy needs to provide a way to retrieve this metadata in the shape that `Mapper` can understand. E.g: `@automapper/classes` stores the metadata in `Reflect` object. -- Apply the metadata: By default, `Mapper` knows how to apply the metadata for a specific model. However, the consumers can customize this behavior by passing in a custom `applyMetadata` function. - -In addition to dealing with metadata, a Strategy also provides two hooks: - -- `preMap`: Runs before the map operation starts. This step is usually to prepare the `sourceObject` before it goes into the `map` operation. -- `postMap`: Runs after the map operation starts. This step is usually to massage the result `destinationObject` before it gets returned to the user. - -### Official Strategies - -AutoMapper TypeScript comes with 4 official strategies: - -| package | strategy | description | -| ----------------------- | ------------- | ----------------------------------------------------------------- | -| `@automapper/classes` | `classes()` | Works with TS/ES6 Classes | -| `@automapper/pojos` | `pojos()` | Works with Interfaces/Types | -| `@automapper/mikro` | `mikro()` | Works with TS/ES6 Classes and [MikroORM](https://mikro-orm.io/) | -| `@automapper/sequelize` | `sequelize()` | Works with TS/ES6 Classes and [Sequelize](https://sequelize.org/) | - -:::info - -`mikro()` and `sequelize()` are extensions of `classes()`. They call `classes()` with different `MappingStrategyInitializerOptions` - -::: - -## Logger - -AutoMapper TypeScript exposes a logger via the symbol `AutoMapperLogger`. There are 4 log levels: - -- `log` -- `warn` -- `info` -- `error` - -By default, these log levels are implemented using the `console`'s counterparts in addition to having `[AutoMapper]` as the prefix. - -```ts -console.log('some log'); // some log -AutoMapperLogger.log('some log'); // [AutoMapper]: some log -``` - -### Customization - -We can customize `AutoMapperLogger` via `AutoMapperLogger.configure()` - -```ts -// in your application's entry point, at the top -AutoMapperLogger.configure({ - warn: null, // nullify "warn" completely -}); -``` - -There is one caveat in terms of customizing `AutoMapperLogger` which is **having to** call `AutoMapperLogger.configure()` **as soon as possible** so we need to pick a file that is guaranteed to run **early**, preferably earlier than **decorators**. - -- In NestJS, this can be `app.module.ts` -- In Angular, this can be `polyfills.ts` - -The caveat stems from the fact that `@AutoMap` decorator from `@automapper/classes` does have a `AutoMapperLogger.warn` call when it fails to infer the type from Reflection. This might not be a desired behavior in certain environments. diff --git a/packages/documentation/docs/fundamentals/mapping.mdx b/packages/documentation/docs/fundamentals/mapping.mdx deleted file mode 100644 index f4d2ff3f9..000000000 --- a/packages/documentation/docs/fundamentals/mapping.mdx +++ /dev/null @@ -1,49 +0,0 @@ ---- -id: mapping -title: Mapping -sidebar_label: Mapping -sidebar_position: 2 ---- - -## Mapping - -`Mapping` is a contract between a `Source` model and a `Destination` model. `Mapping` is created by invoking `createMap()`. Within a `Mapper`, a `Mapping` is **unidirectional** and **unique**. - -```ts -createMap(mapper, Source, Destination); // Mapping -createMap(mapper, Destination, Source); // Mapping -``` - -:::info - -We can also create a Mapping between the same model (identifier). Read more about [Self Mapping](../misc/self-mapping) - -::: - -## MappingProperty - -`MappingProperty` is a set of information about a particular property on the `Destination` - -- Property path -- [MappingTransformation](#mappingtransformation) of that property -- Nested metadata modifier of the property and its counterpart on the `Source` (e.g: `Bio#birthday` is `Date`, `BioDto#birthday` is `String`. So the data stored on `MappingProperty` is `[String, Date]`) - -## MappingTransformation - -`MappingTransformation` is the instruction of how `Mapper` should map the property. `MappingTransformation` operates based on different types of `TransformationType` - -There are currently 10 `TransformationType` - -| type | member map function | description | -| --------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Ignore | `ignore()` | Ignore a member on the `Destination` | -| MapFrom | `mapFrom()` | Customize instruction for a member with a `Selector` or a [Resolver](../mapping-configuration/for-member/map-from#value-resolver) | -| Condition | `condition()` | If the member on the `Destination` matches with another member on the `Source`, this will conditionally map the member on the `Source` to `Destination` if some predicate is evaluated to truthy | -| FromValue | `fromValue()` | Map a raw value to the member | -| MapWith | `mapWith()` | In some cases where nested models do not work automatically, this is to specify the nested `Destination` of the member as well as the nested `Source` | -| ConvertUsing | `convertUsing()` | Map a member using [Converters](../mapping-configuration/for-member/convert-using) | -| MapInitialize | `mapInitialize()` | This is used internally to initialize the `MappingProperty` with the `Destination` metadata | -| NullSubstitution | `nullSubstitution()` | If the member on `Source` is `null`, this will substitute the `null` value with a different value for that member on `Destination` | -| UndefinedSubstitution | `undefinedSubstitution()` | If the member on `Source` is `undefined`, this will substitute the `undefined` value with a different value for that member on `Destination` | -| MapWithArguments | `mapWithArguments()` | This can be used to map with extra arguments where the arguments come in at runtime when `map()` is invoked | -| MapDefer | `mapDefer()` | This can be used to defer a `TransformationType` with the `Source`. For example, if `Source` has data A, we want `MapFrom` but if `Source` has B, we want to `Ignore` | diff --git a/packages/documentation/docs/fundamentals/mutation.mdx b/packages/documentation/docs/fundamentals/mutation.mdx deleted file mode 100644 index 8c5c3f0ba..000000000 --- a/packages/documentation/docs/fundamentals/mutation.mdx +++ /dev/null @@ -1,31 +0,0 @@ ---- -id: mutation -title: Mutation -sidebar_label: Mutation -sidebar_position: 5 ---- - -AutoMapper provides a way to map _mutably_ using the `mutate()` API (and its variants) - -```ts -mapper.map(); -mapper.mapAsync(); -mapper.mapArray(); -mapper.mapArrayAsync(); - -mapper.mutate(); -mapper.mutateAsync(); -mapper.mutateArray(); -mapper.mutateArrayAsync(); -``` - -The `mutate()` API returns `void` as we would pass in the `destinationObject` to mutate - -```ts -// map() -const dto = mapper.map(user, User, UserDto); - -// mutate() -let dto = {}; -mapper.mutate(user, dto, User, UserDto); -``` diff --git a/packages/documentation/docs/fundamentals/naming-convention.mdx b/packages/documentation/docs/fundamentals/naming-convention.mdx deleted file mode 100644 index 23e4751c2..000000000 --- a/packages/documentation/docs/fundamentals/naming-convention.mdx +++ /dev/null @@ -1,51 +0,0 @@ ---- -id: naming-convention -title: Naming Conventions -sidebar_label: Naming Conventions -sidebar_position: 3 ---- - -`NamingConvention` allows AutoMapper to map models with different casing convention in terms of properties' names. Out of the box, AutoMapper provides 3 conventions: - -- `CamelCaseNamingConvention` -- `PascalCaseNamingConvention` -- `SnakeCaseNamingConvention` - -By default, AutoMapper does not set a default convention. [Auto Flattening](./auto-flattening) can only be applied when `NamingConvention` is set on the models, even if they share the same casing. - -```ts -// Mapper level conventions (global for all Mappings) -const mapper = createMapper({ - strategyInitializer: classes(), - namingConventions: new CamelCaseNamingConvention(), -}); - -// Mapping level conventions (applied for one specific Mapping) -createMap( - mapper, - User, - UserDto, - namingConventions(new CamelCaseNamingConvention()) -); - -// Profile level conventions (applied for ALL Mappings inside a Profile) -addProfile( - mapper, - userProfile, - namingConventions(new CamelCaseNamingConvention()) -); -``` - -### Different conventions for `Source` and `Destination` - -All variants of `namingConventions` also accept an object type `{ source: NamingConvention, destination: NamingConvention }` to set different conventions on different models. - -```ts -const mapper = createMapper({ - strategyInitializer: classes(), - namingConventions: { - source: new PascalCaseNamingConvention(), - destination: new CamelCaseNamingConvention(), - }, -}); -``` diff --git a/packages/documentation/docs/getting-started/installation.mdx b/packages/documentation/docs/getting-started/installation.mdx deleted file mode 100644 index bff0dfb9d..000000000 --- a/packages/documentation/docs/getting-started/installation.mdx +++ /dev/null @@ -1,62 +0,0 @@ ---- -id: installation -title: Installation -sidebar_label: Installation -sidebar_position: 3 ---- - -### Core - -Install the `core` module via `npm` or `yarn` along with at least one strategy: - -```shell -npm i @automapper/core @automapper/classes reflect-metadata # for classes -npm i @automapper/core @automapper/pojos # for pojos -``` - -```shell -yarn add @automapper/core @automapper/classes reflect-metadata # for classes -yarn add @automapper/core @automapper/pojos # for pojos -``` - -#### `tsconfig` - -```js -{ - "skipLibCheck": true, - "experimentalDecorators": true, // for @automapper/classes - "emitDecoratorMetadata": true // for @automapper/classes -} -``` - -### ORMs - -If you are using an ORM, AutoMapper TypeScript provides support for [Sequelize](https://sequelize.org/) and [MikroORM](https://mikro-orm.io/). Both work with `@automapper/classes` - -```shell -npm i @automapper/core @automapper/classes reflect-metadata @automapper/mikro # for mikro-orm -npm i @automapper/core @automapper/classes reflect-metadata @automapper/sequelize # for sequelize -``` - -```shell -yarn add @automapper/core @automapper/classes reflect-metadata @automapper/mikro # for mikro-orm -yarn add @automapper/core @automapper/classes reflect-metadata @automapper/sequelize # for sequelize -``` - -### NestJS - -AutoMapper TypeScript provides official integration with [NestJS](https://nestjs.com) - -```shell -npm i @automapper/core @automapper/nestjs @automapper/classes reflect-metadata # for classes -npm i @automapper/core @automapper/nestjs @automapper/classes reflect-metadata @automapper/mikro # for classes + mikro-orm -npm i @automapper/core @automapper/nestjs @automapper/classes reflect-metadata @automapper/sequelize # for classes + sequelize -npm i @automapper/core @automapper/nestjs @automapper/pojos # for pojos -``` - -```shell -yarn add @automapper/core @automapper/nestjs @automapper/classes reflect-metadata # for classes -yarn add @automapper/core @automapper/nestjs @automapper/classes reflect-metadata @automapper/mikro # for classes + mikro-orm -yarn add @automapper/core @automapper/nestjs @automapper/classes reflect-metadata @automapper/sequelize # for classes + sequelize -yarn add @automapper/core @automapper/nestjs @automapper/pojos # for pojos -``` diff --git a/packages/documentation/docs/getting-started/migrate-v8.mdx b/packages/documentation/docs/getting-started/migrate-v8.mdx deleted file mode 100644 index d15900ab0..000000000 --- a/packages/documentation/docs/getting-started/migrate-v8.mdx +++ /dev/null @@ -1,658 +0,0 @@ ---- -id: migrate-to-automapper-v8 -title: Migrating to AutoMapper 8 -sidebar_label: Migrating to AutoMapper 8 -sidebar_position: 2 ---- - -## Overview - -In AutoMapper 8, the overall APIs of AutoMapper have changed from [Fluent API](https://en.wikipedia.org/wiki/Fluent_interface) to a more [Functional](https://en.wikipedia.org/wiki/Functional_programming) approach. AutoMapper 8 also adopts the term **Mapping Strategy** (Strategy) to replace **Mapping Plugin** (Plugin). Strategy API is drastically simplified and made consistent across all Strategies. - -```ts -// before: Plugin Initializer sometimes is a function, other times is a function that needs to be invoked -const mapperOptions = { - // pluginInitializer: classes - // pluginInitializer: pojos - // pluginInitializer: mikro() - // pluginInitializer: sequelize() -}; - -// after: All Strategies are functions that need to be invoked -const mapperOptions = { - // strategyInitializer: classes() - // strategyInitializer: pojos() - // strategyInitializer: mikro() - // strategyInitializer: sequelize() -}; -``` - -## Migrations - -### `skipLibCheck` - -If you haven't had `skipLibCheck: true` in your `tsconfig.json` already, go ahead and do so. - -### `createMapper(CreateMapperOptions)` - -1. `CreateMapperOptions` no longer requires a `name` -2. `CreateMapperOptions` accepts `strategyInitializer` instead of `pluginInitializer` - -```ts -// before -const mapper = createMapper({ - name: 'arbitrary', - pluginInitializer: classes, - /* namingConventions and errorHandler stay the same */ -}); - -// after -const mapper = createMapper({ - strategyInitializer: classes(), - /* namingConventions and errorHandler stay the same */ -}); -``` - -### `createMap()` - -Previously, `createMap()` was a method available on the `Mapper` object (returned by `createMapper()`). Now, `createMap()` is a standalone function instead. - -#### Syntax - -```ts -// before -mapper.createMap(User, UserDto); - -// after -createMap(mapper, User, UserDto); -``` - -#### API - -Previously, `mapper.createMap()` returns a `CreateMapFluentFunctions` which allows you to chain `forMember()` and other methods to customize the `Mapping` you are creating. Now, `createMap()` returns a `Mapping` itself. Customization is provided by using other standalone functions. In AutoMapper 8, we call these functions `MappingConfiguration`. - -```ts -// before -mapper - .createMap(User, UserDto) - .forMember(/* ... */) - .forSelf(/* ... */) - .beforeMap(/* ... */) - .afterMap(/* ... */); - -// after -createMap( - mapper, - User, - UserDto, - forMember(/* ... */), - forSelf(/* ... */), - beforeMap(/* ... */), - afterMap(/* ... */) -); -``` - -This Functional API allows for better tree-shaking and grouping `MappingConfiguration` into reusable functions that you can use on different `createMap()`. - -#### `CreateMapOptions` - -Previously, you can pass in a `CreateMapOptions` to `mapper.createMap()`. Now, those options are provided by different -standalone `MappingConfiguration` as well. - -```ts -// before -mapper.createMap(Base, BaseDto); -mapper.createMap(User, UserDto, { - extend: [mapper.getMapping(Base, BaseDto)], - namingConventions: new PascalCaseNamingConvention(), -}); - -// after -const baseMapping = createMap(mapper, Base, BaseDto); -createMap( - mapper, - User, - UserDto, - extend(baseMapping), - // or you can call: extend(Base, BaseDto) - namingConventions(new PascalCaseNamingConvention()) -); -``` - -### `addProfile()` - -Same as `createMap()`, `addProfile()` was a method on the `Mapper` in previous versions. Now, `addProfile()` is a standalone function - -#### Syntax - -```ts -// before -mapper.addProfile(productProfile).addProfile(userProfile); - -// after -addProfile(mapper, productProfile); -addProfile(mapper, userProfile); -``` - -#### API - -Previously, `mapper.addProfile()` returns the `Mapper` so you can chain `addProfile()`. Now, `addProfile()` is a `void` function. The main difference is `addProfile()` now accepts a list of `MappingConfiguration` as well. The idea is you can have common `MappingConfiguration` that you can pass to ALL the `createMap()` inside of a particular `MappingProfile`. - -```ts -// before -const productProfile: MappingProfile = (mapper) => { - const baseMapping = mapper.getMapping(Base, BaseDto); - const camelCaseConvention = new CamelCaseNamingConvention(); - - const beforeMap = () => { - /* do something common for all Mappings */ - }; - const afterMap = () => { - /* do something common for all Mappings */ - }; - - // duplicate the configurations for all Mappings - mapper - .createMap(Product, ProductDto, { - extend: [baseMapping], - namingConventions: camelCaseConvention, - }) - .beforeMap(beforeMap) - .afterMap(afterMap); - - mapper - .createMap(Product, ProductDetailDto, { - extend: [baseMapping], - namingConventions: camelCaseConvention, - }) - .beforeMap(beforeMap) - .afterMap(afterMap); - - mapper - .createMap(Product, MinimalProductDto, { - extend: [baseMapping], - namingConventions: camelCaseConvention, - }) - .beforeMap(beforeMap) - .afterMap(afterMap); -}; -mapper.addProfile(productProfile); - -// after -const productProfile: MappingProfile = (mapper) => { - createMap(mapper, Product, ProductDto); - createMap(mapper, Product, ProductDetailDto); - createMap(mapper, Product, MinimalProductDto); -}; - -// pass the common configurations to the profile -addProfile( - mapper, - productProfile, - extend(Base, BaseDto), - namingConventions(new CamelCaseNamingConvention()), - beforeMap(() => { - /* do something common for all Mappings */ - }), - afterMap(() => { - /* do something common for all Mappings */ - }) -); -``` - -AutoMapper 8 version is cleaner. How it works is each `MappingProfile` has a `MappingProfileContext` created upon invoked. All the `createMap()` inside a particular `MappingProfile` has access to that `MappingProfileContext` which includes all the common `MappingConfiguration`. - -### `addTypeConverter()` - -Previously, `addTypeConverter()` is a method on the `Mapper` object that allows you to create a converter between two types. The type converter then gets applied to every pair of properties that match the two types. However, type converters added by `addTypeConverter` will be applied for **ALL** mappings, and that might not be the case. - -In AutoMapper 8, `typeConverter()` is a `MappingConfiguration` function that can be passed to `createMap()` (to configure for that `Mapping`) or `addProfile()` (to configure for all mappings inside a `MappingProfile`). There is no equivalence to `Mapper` level type converters in AutoMapper 8. - -```ts -// before -mapper.addTypeConverter(String, Number, (str) => parseInt(str)); - -mapper.createMap(User, UserDto); -mapper.createMap(Product, ProductDto); - -// after -createMap( - mapper, - User, - UserDto, - typeConverter(String, Number, (str) => parseInt(str)) -); -createMap( - mapper, - Product, - ProductDto, - typeConverter(String, Number, (str) => parseInt(str) + 10) -); -``` - -### `map()` - -#### Syntax - -```ts -// before -mapper.map(user, UserDto, User); - -// after -mapper.map(user, User, UserDto); -``` - -This is a subtle breaking change that I've been holding off for a while. The positional arguments for `Source` and `Destination` are now switched to be more logical: "I want to map `sourceObject` from `Source` to `Destination`". This particularly makes it less confusing for `@automapper/pojos` users. - -```ts -// before -mapper.map(user, 'UserDto', 'User'); // weird order - -// after -mapper.map(user, 'User', 'UserDto'); // matching order -``` - -#### API - -##### Mutation - -The **mutation** API of `mapper.map()` has been separated into a whole set of methods on `Mapper`, `mapper.mutate()`. This is to create a clear distinction between `mapping` and `mutating`. - -```ts -// before -const destinationObj = {}; -mapper.map(sourceObj, Destination, Source, destinationObj); - -// after -const destinationObj = {}; -mapper.mutate(sourceObj, destinationObj, Source, Destination); -``` - -The `mapper.mutate()` family has matching APIs with `mapper.map()` - -```ts -mapper.map(); -mapper.mapAsync(); -mapper.mapArray(); -mapper.mapArrayAsync(); - -mapper.mutate(); -mapper.mutateAsync(); -mapper.mutateArray(); -mapper.mutateArrayAsync(); -``` - -##### Extra Arguments - -When invoking a map operation with `mapper.map()`, you have the ability to pass in a `MapOptions` object with a property called: `extraArguments`. This is to allow for passing in dynamic arguments (that are only available at the time the map is invoked) to a particular map operation. - -In AutoMapper 8, `extraArguments` has been renamed to `extraArgs` and is a `Function` instead of just a `Record`. - -```ts -// before -mapper.map(user, UserDto, User, { extraArguments: { extra: 123 } }); - -// after -mapper.map(user, User, UserDto, { - extraArgs: (mapping, destinationObject) => ({ extra: 123 }), -}); -``` - -With `extraArgs` being a function, you will have all the information you need to return a more sophisticated **extra arguments object**. In addition to the `Mapper` and the `sourceObject` that you already have access to (eg: `mapper.map(sourceObject, ...)`), `extraArgs` is called with the `Mapping` and the `destinationObject`, which you don't _easily_ have access to. - -:::caution - -`destinationObject` might **not** be the complete destination object because it might be a particular property's turn with `mapWithArguments`. - -::: - -### `MemberMapFunctions` - -#### `convertUsing` - -The 2nd argument (`Selector`) is now required. - -```ts -// before -const dateToStringConverter: Converter = { - convert(source: User): string { - return source.birthday.toDateString(); - }, -}; -mapper.createMap(User, UserDto).forMember( - (d) => d.birthday, - // 2nd argument is optional. - // If not passed in, convertUsing will call the converter#convert with the whole sourceObject - convertUsing(dateToStringConverter) -); - -// after -const dateToStringConverter: Converter = { - convert(source: Date): string { - return source.toDateString(); - }, -}; -createMap( - mapper, - User, - UserDto, - forMember( - (d) => d.birthday, - // 2nd argument is required. - convertUsing(dateToStringConverter, (src) => src.birthday) - ) -); -``` - -This breaking change encourages better `Converter` usages. In the above example, it makes `dateToStringConverter` easier to reuse. If you have use-cases that use the whole `sourceObject`, consider using `mapFrom(Resolver)` instead. - -## Strategy (previously Plugin) - -Strategy can be customized with `MappingStrategyInitializerOptions` which has the following interface: - -```ts -export interface MappingStrategyInitializerOptions { - applyMetadata?: ApplyMetadata; - destinationConstructor?: DestinationConstructor; - preMap?>(source: TSource): TSource; - postMap?< - TSource extends Dictionary, - TDestination extends Dictionary - >( - source: TSource, - destination: TDestination - ): TDestination; -} - -const mapper = createMapper({ - strategyInitializer: classes({ - applyMetadata, // customize how a Strategy applies the metadata to a model - destinationConstructor, // customize the default constructor of the Destination model - preMap, // customize what to do before a map happens - postMap, // customize what to do after a map happens - }), -}); -``` - -## `@automapper/classes` - -### Initializer - -```ts -// before -const mapper = createMapper({ - pluginInitializer: classes, // no () -}); - -// after -const mapper = createMapper({ - strategyInitializer: classes(), // invoking -}); -``` - -### `AutoMap` - -- `typeFn` has been renamed to `type` - -```ts -// before -@AutoMap({ typeFn: () => User }) - -// after -@AutoMap({ type: () => User }) -``` - -- If you only have `typeFn` in `AutoMapOptions`, you can omit the object altogether - -```ts -// before -@AutoMap({ typeFn: () => User }) - -// after -@AutoMap(() => User) -``` - -- Default `depth` is set to 1 instead of 0. -- `AutoMap` now warns if it cannot infer the type of the property. If you have dynamic type like `any`, `Record`, or something similar, please configure the property via `forMember()`. -- Array metadata is now required to be explicitly specified - -```ts -// before -export class User { - @AutoMap({ typeFn: () => Address }) - addresses: Address[]; -} - -// after -export class User { - @AutoMap(() => [Address]) - addresses: Address[]; -} -``` - -## `@automapper/pojos` - -### Initializer - -```ts -// before -const mapper = createMapper({ - pluginInitializer: pojos, // no () -}); - -// after -const mapper = createMapper({ - strategyInitializer: pojos(), // invoking -}); -``` - -### `createMetadataMap()` - -- Has been replaced with `PojosMetadataMap.create()` - - Accepts `string | symbol` for the key instead of just `string` - - No longer accepts `null` for metadata. - - No longer allows for "extending" previously created metadata. Explicit is better in the case of metadata. -- `PojosMetadataMap.reset()` has been added to clear all the stored metadata (useful for the testing environment) - -```ts -// before -createMetadataMap('SimpleUser', { - firstName: String, - lastName: String, -}); -createMetadataMap('SimpleUserDto', 'SimpleUser', { - fullName: String, -}); - -// after -PojosMetadataMap.create('SimpleUser', { - firstName: String, - lastName: String, -}); -PojosMetadataMap.create('SimpleUserDto', { - firstName: String, - lastName: String, - fullName: String, -}); -``` - -> If you want to share `firstName` and `lastName`, you can always make an object and spread it. - -- Array metadata is now required to be explicitly specified - -```ts -export interface User { - addresses: Address[]; -} - -// before -createMetadataMap('User', { - addresses: 'Address', -}); - -// after -PojosMetadataMap.create('User', { - addresses: ['Address'], -}); -``` - -## NestJS - -### `AutomapperModule.forRoot` - -```ts -// before -AutomapperModule.forRoot({ - singular: true, - options: [ - { - pluginInitializer: classes, - namingConventions: new CamelCaseNamingConvention(), - }, - ], - globalErrorHandler, - globalNamingConventions, -}); - -AutomapperModule.forRoot({ - options: [ - { - name: 'classes', - pluginInitializer: classes, - }, - { - name: 'pojos', - pluginInitializer: pojos, - }, - ], - globalErrorHandler, - globalNamingConventions: new CamelCaseNamingConvention(), -}); - -// after -AutomapperModule.forRoot({ - strategyInitializer: classes(), - namingConventions: new CamelCaseNamingConvention(), -}); -AutomapperModule.forRoot( - [ - { - name: 'classes', - strategyInitializer: classes(), - }, - { - name: 'pojos', - strategyInitializer: pojos(), - }, - ], - { - globalErrorHandler, - globalNamingConventions: new CamelCaseNamingConvention(), - } -); -``` - -### `AutomapperModule.forRootAsync` - -```ts -import { EntityManager } from '@mikro-orm/mongodb'; - -AutomapperModule.forRootAsync({ - inject: [EntityManager], - useFactory: (em: EntityManager) => ({ - strategyInitializer: classes({ - destinationConstructor: (sourceObject, destinationIdentifier) => - em.create(destinationIdentifier, {}), - }), - }), -}); -``` - -### `AutomapperProfile` - -- `mapProfile` has been changed to `get profile` - -```ts -@Injectable() -export class UserProfile extends AutomapperProfile { - constructor(@InjectMapper() mapper: Mapper) { - super(mapper); - } - - // before - override mapProfile(): MappingProfile { - return (mapper) => { - mapper.createMap(/*...*/); - }; - } - - // after - override get profile(): MappingProfile { - return (mapper) => { - createMap(mapper /*...*/); - }; - } -} -``` - -- A new getter `get mappingConfigurations` that returns a `MappingConfiguration[]` to be passed to all `createMap()` inside the Profile - -```ts -@Injectable() -export class UserProfile extends AutomapperProfile { - constructor(@InjectMapper() mapper: Mapper) { - super(mapper); - } - - get profile(): MappingProfile { - return (mapper) => { - createMap(mapper, UserEntity, UserDto); - createMap(mapper, UserEntity, UserInformationDto); - createMap(mapper, UserEntity, AuthUserDto); - }; - } - - protected get mappingConfigurations(): MappingConfiguration[] { - // the 3 createMap() above will get this `extend()` - return [extend(BaseEntity, BaseDto)]; - } -} -``` - -### `MapPipe` - -```ts -export class SomeController { - // before - @Post() - someMethod(@Body(MapPipe(UserDto, User)) dto: UserDto) { - /*..*/ - } - - // after - @Post() - someMethod(@Body(MapPipe(User, UserDto)) dto: UserDto) { - /*..*/ - } -} -``` - -### `MapInterceptor` - -```ts -export class SomeController { - // before - @Get() - @UseInterceptors(MapInterceptor(UserDto, User)) - get() { - /*...*/ - } - - // after - @Get() - @UseInterceptors(MapInterceptor(User, UserDto)) - get() { - /*...*/ - } -} -``` diff --git a/packages/documentation/docs/getting-started/overview.mdx b/packages/documentation/docs/getting-started/overview.mdx deleted file mode 100644 index 23cddbd4e..000000000 --- a/packages/documentation/docs/getting-started/overview.mdx +++ /dev/null @@ -1,44 +0,0 @@ ---- -id: overview -title: Overview -sidebar_label: Overview -sidebar_position: 1 ---- - -## What is AutoMapper? - -AutoMapper (TypeScript) is an object-object mapper by _convention_. When two objects' models are _conventionally matching_, AutoMapper can map the two objects with almost zero mapping configuration. - -## Why use AutoMapper? - -Writing code for mapping (especially when they are _conventionally matching_) is boring; writing tests for these mappings is even more boring. With AutoMapper, you can automate these tasks and separate the logic of transforming one Object Type to another. - -However, AutoMapper is an _opinionated_ tool. While mapping configuration is an essential API of AutoMapper that provides customizations, you are probably better off **not** using AutoMapper if you find your mappings are mostly _manual_ mapping configurations. - -[Jimmy Bogard](https://jimmybogard.com/), the author of .NET AutoMapper, writes a [blog post](https://jimmybogard.com/automappers-design-philosophy/) to express his design philosophy when he worked on .NET AutoMapper. The summary is below - -> AutoMapper works because it enforces a convention. It assumes that your destination types are a subset of the source type. It assumes that everything on your destination type is meant to be mapped. It assumes that the destination member names follow the exact name of the source type. It assumes that you want to flatten complex models into simple ones.

-> All of these assumptions come from our original use case - view models for MVC, where all of those assumptions are in line with our view model design. With AutoMapper, we could enforce our view model design philosophy. This is the true power of conventions - laying down a set of enforceable design rules that help you streamline development along the way.

-> By enforcing conventions, we let our developers focus on the value add activities, and less on the activities that provided zero or negative value, like designing bespoke view models or writing a thousand dumb unit tests.

-> And this is why our usage of AutoMapper has stayed so steady over the years - because our design philosophy for view models hasn't changed. If you find yourself hating a tool, it's important to ask - for what problems was this tool designed to solve? And if those problems are different than yours, perhaps that tool isn't a good fit. - -## Ecosystem - -AutoMapper comes with a `core` library and many **strategies** that allow you to work with different use-case in your JS/TS projects. - -### Core - -As the name suggests, `@automapper/core` deals with tracking models' metadata, setting up mapping configurations, storing Mappings, and executing the mapping operations between the models. - -### Strategies - -| Strategy | Description | -| ----------------------- | --------------------------------------------------------------- | -| `@automapper/classes` | Works with TS/ES6 Class | -| `@automapper/pojos` | Works with plain objects and Interface | -| `@automapper/mikro` | Works with TS/ES6 Class and [MikroORM](https://mikro-orm.io/) | -| `@automapper/sequelize` | Works with TS/ES6 Class and [Sequelize](https://sequelize.org/) | - -### NestJS - -AutoMapper has an official integration with [NestJS](https://nestjs.com) framework with the `@automapper/nestjs` package. diff --git a/packages/documentation/docs/mapping-configuration/after-map.mdx b/packages/documentation/docs/mapping-configuration/after-map.mdx deleted file mode 100644 index 41d20248c..000000000 --- a/packages/documentation/docs/mapping-configuration/after-map.mdx +++ /dev/null @@ -1,74 +0,0 @@ ---- -id: after-map -title: AfterMap -sidebar_label: AfterMap -sidebar_position: 3 ---- - -As the name suggests, `afterMap()` sets up a `MapCallback` to be called **after** the map operation. - -## Configure on `Mapping` - -Pass `afterMap()` in `createMap()` to sets up the `MapCallback` - -```ts -createMap( - mapper, - User, - UserDto, - afterMap((source, destination) => {}) -); -``` - -## Configure on `map()` - -Pass `afterMap` in `MapOptions` when calling `map()` to sets up the `MapCallback` - -```ts -mapper.map(user, User, UserDto, { - afterMap: (source, destination) => {}, -}); -``` - -:::info - -- `afterMap()` on `map()` has precedence over `Mapping` -- For `mapArray` (and its variants), `afterMap()` on `Mapping` is **ignored** because it would be bad for performance if we run `afterMap` for each and every item of the array. `afterMap()` on `mapArray()` will be invoked with `(sourceArray, destinationArray)` instead - -::: - -## Async Mapping - -One of the common use-cases of `afterMap` is to execute some asynchronous operation. Let's assume our `Destination` have some property whose value can only be computed from an asynchronous operation, we can leverage `mapAsync()` and `afterMap()` for it. - -```ts -createMap( - mapper, - User, - UserDto, - // 👇 We are fetching the "fullName" manually - // 👇 👇 so we need to ignore it - forMember((d) => d.fullName, ignore()), - afterMap(async (source, destination) => { - const fullName = await fetchFullName(source); - Object.assign(destination, { fullName }); - }) -); - -// 👇 mapAsync is needed if we use the above "trick" with afterMap -const dto = await mapper.mapAsync(user, User, UserDto); -``` - -:::caution - -Simple asynchronous operations should be fine with this approach. However due to [Fake Async](../misc/fake-async), we should **NOT** use AutoMapper for a particular pair of models if those models require some heavy and complex asynchronous operations. - -::: - -## What about `postMap`? - -When create the `Mapper`, we can customize the `postMap` function on the `MappingStrategy`. The differences between `postMap` and `afterMap` are: - -- `postMap` runs after every **map** operation -- There is only one `postMap` per `Mapper` -- `postMap` runs **AFTER** `afterMap` diff --git a/packages/documentation/docs/mapping-configuration/auto-map.mdx b/packages/documentation/docs/mapping-configuration/auto-map.mdx deleted file mode 100644 index 30438fd13..000000000 --- a/packages/documentation/docs/mapping-configuration/auto-map.mdx +++ /dev/null @@ -1,57 +0,0 @@ ---- -id: auto-map -title: AutoMap -sidebar_label: AutoMap -sidebar_position: 2 ---- - -`autoMap()` is an alternative to the `@AutoMap()` decorator. It trivially maps a property with the **same name and type** on the `Source` and `Destination` objects. - -## Decoratorless entities and DTOs - -Use this specially when your entities and DTOs cannot have the `@AutoMap()` decorator (e.g. when using **constructor assignments**), or when you would rather avoid them: - -```ts -import { - autoMap, - createMap, - forMember, - mapFrom, - Mapper, -} from '@automapper/core'; -export class DecoratorlessUserEntity { - constructor( - public readonly firstName: string, - public readonly lastName: string, - public readonly birthday: Date - ) {} -} -export class DecoratorlessUserDto { - firstName!: string; - lastName!: string; - birthday!: string; - fullName!: string; -} -export function decoratorlessUserProfile(mapper: Mapper) { - createMap( - mapper, - DecoratorlessUserEntity, - DecoratorlessUserDto, - // Use `autoMap()` on properties that can be trivially mapped. - autoMap('firstName'), - autoMap('lastName'), - // Use more elaborate mapping configurations when necessary: - // 'birthday' exists on both `Source` and `Destination`, but with - // different types. - forMember( - (d) => d.birthday, - mapFrom((s) => s.birthday.toDateString()) - ), - // 'fullName' doesn't exist on `Source` and must be mapped manually. - forMember( - (d) => d.fullName, - mapFrom((s) => `${s.firstName} ${s.lastName}`) - ) - ); -} -``` diff --git a/packages/documentation/docs/mapping-configuration/before-map.mdx b/packages/documentation/docs/mapping-configuration/before-map.mdx deleted file mode 100644 index 86cd2981b..000000000 --- a/packages/documentation/docs/mapping-configuration/before-map.mdx +++ /dev/null @@ -1,46 +0,0 @@ ---- -id: before-map -title: BeforeMap -sidebar_label: BeforeMap -sidebar_position: 4 ---- - -As the name suggests, `beforeMap()` sets up a `MapCallback` to be called **before** the map operation. - -## Configure on `Mapping` - -Pass `beforeMap()` in `createMap()` to sets up the `MapCallback` - -```ts -createMap( - mapper, - User, - UserDto, - beforeMap((source, destination) => {}) -); -``` - -## Configure on `map()` - -Pass `beforeMap` in `MapOptions` when calling `map()` to sets up the `MapCallback` - -```ts -mapper.map(user, User, UserDto, { - beforeMap: (source, destination) => {}, -}); -``` - -:::info - -- `beforeMap()` on `map()` has precedence over `Mapping` -- For `mapArray` (and its variants), `beforeMap()` on `Mapping` is **ignored** because it would be bad for performance if we run `beforeMap` for each and every item of the array. `beforeMap()` on `mapArray()` will be invoked with `(sourceArray, [])` instead - -::: - -## What about `preMap`? - -When create the `Mapper`, we can customize the `preMap` function on the `MappingStrategy`. The differences between `preMap` and `beforeMap` are: - -- `preMap` runs before every **map** operation -- There is only one `preMap` per `Mapper` -- `preMap` runs **BEFORE** `beforeMap` diff --git a/packages/documentation/docs/mapping-configuration/construct-using.mdx b/packages/documentation/docs/mapping-configuration/construct-using.mdx deleted file mode 100644 index 727e09ea2..000000000 --- a/packages/documentation/docs/mapping-configuration/construct-using.mdx +++ /dev/null @@ -1,25 +0,0 @@ ---- -id: construct-using -title: ConstructUsing -sidebar_label: ConstructUsing -sidebar_position: 5 ---- - -Call `constructUsing()` and pass in a `DestinationConstructor` to customize how AutoMapper should construct the `Destination` before every map operation against that `Destination`. - -```ts -createMap( - mapper, - User, - UserDto, // 👈 --the destination modifier--👇 - constructUsing((sourceObject, destinationIdentifier) => { - // sourceObject is the data when run: mapper.map(sourceObject...); - }) -); -``` - -:::info - -All built-in `MappingStrategy` should have a default `destinationConstructor` (e.g: `@automapper/classes` default is `new Destination()`). - -::: diff --git a/packages/documentation/docs/mapping-configuration/extend.mdx b/packages/documentation/docs/mapping-configuration/extend.mdx deleted file mode 100644 index 4d823efd5..000000000 --- a/packages/documentation/docs/mapping-configuration/extend.mdx +++ /dev/null @@ -1,28 +0,0 @@ ---- -id: extend -title: Extend -sidebar_label: Extend -sidebar_position: 6 ---- - -Call `extend()` and pass in either a `Mapping` or a pair of models to tell AutoMapper to extend the `MappingProperties` to the `Mapping` we are creating - -```ts -const baseMapping = createMap(mapper, Base, BaseDto); -createMap( - // 👆 the Mapping we are creating - mapper, - User, - UserDto, // 👇 the Mapping we are extending - extend(baseMapping) -); - -// or -createMap(mapper, User, UserDto, extend(Base, BaseDto)); -``` - -:::tip - -We can still override the extended `MappingProperties` with `forMember()` **after** the `extend()` - -::: diff --git a/packages/documentation/docs/mapping-configuration/for-member/condition.mdx b/packages/documentation/docs/mapping-configuration/for-member/condition.mdx deleted file mode 100644 index fba10c262..000000000 --- a/packages/documentation/docs/mapping-configuration/for-member/condition.mdx +++ /dev/null @@ -1,40 +0,0 @@ ---- -id: condition -title: Condition -sidebar_label: Condition -sidebar_position: 4 ---- - -Call `condition()` to _conditionally_ execute the auto-map operation for the property (e.g: `Source#propertyName -> Destination#propertyName`) by passing in a `predicateFn` that will be called with the `sourceObject` - -```ts -createMap( - mapper, - User, - UserDto, - forMember( - (destination) => destination.petName, - condition((source) => source.hasPet) - ) -); -``` - -If `source.hasPet` is evaluated to truthy, then `destination.petName` will be mapped with `source.petName`. Otherwise, `destination.petName` will be `undefined`. - -## Default Value - -`condition()` accepts an optional `defaultValue`. In the case that the `predicateFn` is evaluated to falsy, the `defaultValue` will be used to mapped to the configured property instead of `undefined` - -```ts -createMap( - mapper, - User, - UserDto, - forMember( - (destination) => destination.petName, - condition((source) => source.hasPet, 'default pet name') - ) -); -``` - -`condition()` sets the `TransformationType` to `TransformationType.Condition` diff --git a/packages/documentation/docs/mapping-configuration/for-member/convert-using.mdx b/packages/documentation/docs/mapping-configuration/for-member/convert-using.mdx deleted file mode 100644 index 796ad6c78..000000000 --- a/packages/documentation/docs/mapping-configuration/for-member/convert-using.mdx +++ /dev/null @@ -1,43 +0,0 @@ ---- -id: convert-using -title: ConvertUsing -sidebar_label: ConvertUsing -sidebar_position: 7 ---- - -Call `convertUsing()` and pass in a `Converter` to map the configured property with the `Converter#convert` method. - -```ts -export interface Converter { - convert(source: TheSource): TheResult; -} -``` - -`Converter` can be used to extract common logic where we want to map one data type to another. `Converter` can be reused across different Mappings in the application. For example, we might have a `dateToStringConverter` - -```ts -export const dateToStringConverter: Converter = { - convert(source) { - // maybe handle validation, additional parsing, or format here - return source.toDateString(); - }, -}; - -createMap( - mapper, - User, - UserDto, - forMember( - (destination) => destination.birthday, - convertUsing(dateToStringConverter, (source) => source.birthday) - ) -); -``` - -:::tip - -If we have simple logic, we can use [Type Converter](../type-converters) to also map from one data type to another on the Mapping level instead of `convertUsing` on the property level. - -::: - -`convertUsing()` sets the `TransformationType` to `TransformationType.ConvertUsing` diff --git a/packages/documentation/docs/mapping-configuration/for-member/from-value.mdx b/packages/documentation/docs/mapping-configuration/for-member/from-value.mdx deleted file mode 100644 index 4ca2f4491..000000000 --- a/packages/documentation/docs/mapping-configuration/for-member/from-value.mdx +++ /dev/null @@ -1,19 +0,0 @@ ---- -id: from-value -title: FromValue -sidebar_label: FromValue -sidebar_position: 5 ---- - -Call `fromValue()` and pass in a raw value to map to the configured property. If we pass in an object, the object will be mapped as-is without any consideration for nested Mapping. - -```ts -createMap( - mapper, - User, - UserDto, - forMember((destination) => destination.fullName, fromValue('John Doe')) -); -``` - -`fromValue()` sets the `TransformationType` to `TransformationType.FromValue` diff --git a/packages/documentation/docs/mapping-configuration/for-member/ignore.mdx b/packages/documentation/docs/mapping-configuration/for-member/ignore.mdx deleted file mode 100644 index 5760788fa..000000000 --- a/packages/documentation/docs/mapping-configuration/for-member/ignore.mdx +++ /dev/null @@ -1,19 +0,0 @@ ---- -id: ignore -title: Ignore -sidebar_label: Ignore -sidebar_position: 2 ---- - -Call `ignore()` on a property to skip the map operation for the property while still mark the property as _configured_. This ultimately prevents the ignored property from being checked by `assertUnmappedProperties()` and makes the property `undefined` - -```ts -createMap( - mapper, - User, - UserDto, - forMember((d) => d.fullName, ignore()) -); -``` - -`ignore()` sets the `TransformationType` to `TransformationType.Ignore` diff --git a/packages/documentation/docs/mapping-configuration/for-member/map-defer.mdx b/packages/documentation/docs/mapping-configuration/for-member/map-defer.mdx deleted file mode 100644 index 1ddebada7..000000000 --- a/packages/documentation/docs/mapping-configuration/for-member/map-defer.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -id: map-defer -title: MapDefer -sidebar_label: MapDefer -sidebar_position: 11 ---- - -`mapDefer()` is a _special_ type of `MemberMapFunction` that can be used to _defer_ another `MemberMapFunction` based on some logic in `forMember()` - -Call `mapDefer()` and pass in the `DeferFunction` which will be called with the `sourceObject`. We can then use this `sourceObject` and return another `MemberMapFunction` - -```ts -createMap( - mapper, - User, - UserDto, - forMember( - (destination) => destination.profile, - mapDefer((source) => { - if (source.profile.type === 'A') - return mapWith( - ProfileDto, - ProfileA, - (source) => source.profile - ); - return mapWith(ProfileDto, Profile, (source) => source.profile); - }) - ) -); -``` - -`mapDefer()` sets the `TransformationType` to `TransformationType.MapDefer` diff --git a/packages/documentation/docs/mapping-configuration/for-member/map-from.mdx b/packages/documentation/docs/mapping-configuration/for-member/map-from.mdx deleted file mode 100644 index cb580a393..000000000 --- a/packages/documentation/docs/mapping-configuration/for-member/map-from.mdx +++ /dev/null @@ -1,53 +0,0 @@ ---- -id: map-from -title: MapFrom -sidebar_label: MapFrom -sidebar_position: 3 ---- - -Call `mapFrom()` to select the value, from the `sourceObject`, to map to the property being configured - -## Value Selector - -`mapFrom()` accepts a `ValueSelector` that AutoMapper will use to get the value from the `sourceObject` upon mapping the configured property - -```ts -createMap( - mapper, - User, - UserDto, - forMember( - (d) => d.fullName, - mapFrom((source) => source.firstName + ' ' + source.lastName) - ) -); -``` - -## Value Resolver - -A slightly less common approach is to use a `Resolver` with `mapFrom()`. `Resolver` has the following interface: - -```ts -export interface Resolver { - resolve(source: TheSource, destination?: TheDestination): TheReturnType; -} -``` - -`Resolver#resolve()` is called with the whole `sourceObject` and the `destinationObject` which allows us to handle some complex logic to arrive at the result for the configured property. We can reuse `Resolver` as well as separating `Resolver` in a different file to manage easier. - -```ts -export const taxResolver: Resolver = { - resolve(item): number { - return item.type === 'A' ? item.price * 0.5 : item.price * 0.9; - }, -}; - -createMap( - mapper, - Item, - ItemDto, - forMember((d) => d.tax, mapFrom(taxResolver)) -); -``` - -`mapFrom()` sets the `TransformationType` to `TransformationType.MapFrom` diff --git a/packages/documentation/docs/mapping-configuration/for-member/map-with-arguments.mdx b/packages/documentation/docs/mapping-configuration/for-member/map-with-arguments.mdx deleted file mode 100644 index a0a4d4e2c..000000000 --- a/packages/documentation/docs/mapping-configuration/for-member/map-with-arguments.mdx +++ /dev/null @@ -1,53 +0,0 @@ ---- -id: map-with-arguments -title: MapWithArguments -sidebar_label: MapWithArguments -sidebar_position: 10 ---- - -Call `mapWithArguments()` to map the configured property with extra arguments at the time the map operation occurs (aka `mapper.map()`) - -All `map()` and `mutate()` variants accept an optional `MapOptions` that we can use to pass in `extraArgs` which `mapWithArguments` will have access to. - -## Value Selector - -`mapWithArguments()` accepts a `ValueSelector` that AutoMapper will use to get the value from the `sourceObject` and the `extraArguments` upon mapping the configured property - -```ts -createMap( - mapper, - User, - UserDto, - forMember( - (destination) => destination.fullName, - mapWithArguments((source, { someArgument }) => { - return getFullName(source, someArgument); - }) - ) -); - -mapper.map(user, User, UserDto, { extraArgs: () => ({ someArgument: 'foo' }) }); -``` - -## Value Resolver - -We can also pass in a `Resolver` to `mapWithArguments` - -```ts -export const taxResolver: Resolver = { - resolve(source, { percentage }) { - return source.price * percentage; - }, -}; - -createMap( - mapper, - Item, - ItemDto, - forMember((destination) => destination.tax, mapWithArguments(taxResolver)) -); - -mapper.map(item, Item, ItemDto, { extraArgs: () => ({ percentage: 0.5 }) }); -``` - -`mapWithArguments()` sets the `TransformationType` to `TransformationType.MapWithArguments` diff --git a/packages/documentation/docs/mapping-configuration/for-member/map-with.mdx b/packages/documentation/docs/mapping-configuration/for-member/map-with.mdx deleted file mode 100644 index 8bfc602d0..000000000 --- a/packages/documentation/docs/mapping-configuration/for-member/map-with.mdx +++ /dev/null @@ -1,40 +0,0 @@ ---- -id: map-with -title: MapWith -sidebar_label: MapWith -sidebar_position: 6 ---- - -In some cases where AutoMapper fails to auto-map nested models, call `mapWith()` to take over this operation for the configured property. - -`mapWith()` accepts 3 arguments: - -- `withDestination`: the `NestedDestination` model to map to -- `withSource`: the `NestedSource` model to map from -- `withSourceValue`: the `ValueSelector` to select a property from the parent `sourceObject` whose value is the `NestedSource` - -```ts -createMap( - mapper, - User, - UserDto, - forMember( - (destination) => destination.profile, - mapWith(ProfileDto, Profile, (source) => source.profile) - ) -); -``` - -:::tip - -In the case where the property on `Destination` has a different name than `Source` (e.g: `Source.originalProfile` vs `Destination.otherProfile`), we can use `mapWith()` as well. Although it is possible and valid, we should avoid this case because it is against the _Convention_ - -::: - -:::info - -`mapWith()` calls the normal map operation for `NestedSource` and `NestedDestination`. In other words, it is the same as we call `mapper.map(nestedSourceObject, NestedSource, NestedDestination) ourselves. - -::: - -`mapWith()` sets the `TransformationType` to `TransformationType.MapWith` diff --git a/packages/documentation/docs/mapping-configuration/for-member/null-substitution.mdx b/packages/documentation/docs/mapping-configuration/for-member/null-substitution.mdx deleted file mode 100644 index f2f225d0a..000000000 --- a/packages/documentation/docs/mapping-configuration/for-member/null-substitution.mdx +++ /dev/null @@ -1,31 +0,0 @@ ---- -id: null-substitution -title: NullSubstitution -sidebar_label: NullSubstitution -sidebar_position: 8 ---- - -Call `nullSubstitution()` and pass in a raw value to map to the configured property in the case of the same property on `Source` is `null`. If we pass in an object, AutoMapper will map the object as-is without any consideration for nested mapping. - -:::info - -AutoMapper uses strict equality check against `null` (`=== null`) to make the comparison. Hence, `undefined` value will **not** be substituted. - -::: - -```ts -createMap( - mapper, - User, - UserDto, - forMember( - (destination) => destination.fullName, - nullSubstitution('raw value') - ) -); - -const user = { fullName: null }; -mapper.map(user, User, UserDto); // UserDto { fullName: 'raw value' } -``` - -`nullSubstitution()` sets the `TransformationType` to `TransformationType.NullSubstitution` diff --git a/packages/documentation/docs/mapping-configuration/for-member/overview.mdx b/packages/documentation/docs/mapping-configuration/for-member/overview.mdx deleted file mode 100644 index 36b030fa2..000000000 --- a/packages/documentation/docs/mapping-configuration/for-member/overview.mdx +++ /dev/null @@ -1,46 +0,0 @@ ---- -id: overview -title: Overview -sidebar_label: Overview -sidebar_position: 1 ---- - -Call `forMember()` and pass in a `Selector` along with a `MemberMapFunction` to customize the [MappingTransformation](../../fundamentals/mapping#mappingtransformation) of a particular property on the `Destination` - -
- ForMember gif to showcase developer experience -
- - forMember() is strongly-typed, providing good developer - experience with autocomplete - -
-
- -:::caution - -To iterate once again, if the Mapping has a lot of `forMember()`, it is time to re-evaluate the models. - -::: - -## PreCondition - -Call `preCondition()` before a `MemberMapFunction` will tell AutoMapper to do a pre-check against some condition before executing the `MemberMapFunction`. - -`preCondition()` takes a `predicateFn` that will be called with the `sourceObject` and an optional `defaultValue` that will be assigned to the configured property if `preCondtion()` check yields falsy - -```ts -createMap( - mapper, - User, - UserDto, - forMember( - (destination) => destination.fullName, - preCondition((source) => source.age > 10, 'default full name'), - mapFrom(fullNameResolver) - ) -); -``` diff --git a/packages/documentation/docs/mapping-configuration/for-member/undefined-substitution.mdx b/packages/documentation/docs/mapping-configuration/for-member/undefined-substitution.mdx deleted file mode 100644 index 2ff514371..000000000 --- a/packages/documentation/docs/mapping-configuration/for-member/undefined-substitution.mdx +++ /dev/null @@ -1,30 +0,0 @@ ---- -id: undefined-substitution -title: UndefinedSubstitution -sidebar_label: UndefinedSubstitution -sidebar_position: 9 ---- - -Call `undefinedSubstitution()` and pass in a raw value to map to the configured property in the case of the same property on `Source` is `undefined`. If we pass in an object, AutoMapper will map the object as-is without any consideration for nested mapping. - -:::info - -AutoMapper uses strict equality check against `undefined` (`=== undefined`) to make the comparison. Hence, `null` value will **not** be substituted. - -::: - -```ts -createMap( - mapper, - User, - UserDto, - forMember( - (destination) => destination.fullName, - undefinedSubstitution('raw value') - ) -); - -mapper.map({}, User, UserDto); // UserDto { fullName: 'raw value' } -``` - -`undefinedSubstitution()` sets the `TransformationType` to `TransformationType.UndefinedSubstitution` diff --git a/packages/documentation/docs/mapping-configuration/for-self.mdx b/packages/documentation/docs/mapping-configuration/for-self.mdx deleted file mode 100644 index b43d05d1e..000000000 --- a/packages/documentation/docs/mapping-configuration/for-self.mdx +++ /dev/null @@ -1,127 +0,0 @@ ---- -id: for-self -title: ForSelf -sidebar_label: ForSelf -sidebar_position: 8 ---- - -In previous sections, we've learned that we can have [Auto Flattening](../fundamentals/auto-flattening) with [Naming Conventions](../fundamentals/naming-convention). - -Let's assume we have the following models - -```ts -class Item { - @AutoMap() - name: string; - @AutoMap() - price: number; - @AutoMap() - stock: number; -} - -class CartItem { - @AutoMap(() => Item) - item: Item; - @AutoMap() - quantity: number; -} - -class CartItemDto { - @AutoMap() - itemName: string; - @AutoMap() - itemPrice: number; - @AutoMap() - quantity: number; - - get total() { - return this.price * this.quantity; - } -} -``` - -From [Auto Flattening](../fundamentals/auto-flattening) documentation, we know that `CartItemDto.itemName` will be mapped automatically from `CartItem.item.name` and `CartItemDto.itemPrice` will be mapped automatically from `CartItem.item.price`. - -While that works, we want to keep out DTOs cleaner sometimes without having to prefix some fields to achieve Auto Flattening. Let's adjust our models a little - -```ts -class Item { - @AutoMap() - name: string; - @AutoMap() - price: number; - @AutoMap() - stock: number; -} - -class CartItem { - @AutoMap(() => Item) - item: Item; - @AutoMap() - quantity: number; -} - -class CartItemDto { - // highlight-start - @AutoMap() - name: string; - @AutoMap() - price: number; - // highlight-end - @AutoMap() - quantity: number; - - get total() { - return this.price * this.quantity; - } -} -``` - -There are two approaches we can go about this - -1. Use `forMember()` explicitly - -```ts -createMap( - mapper, - CartItem, - CartItemDto, - forMember( - (destination) => destination.name, - mapFrom((source) => source.item.name) - ), - forMember( - (destination) => destination.price, - mapFrom((source) => source.item.price) - ) -); -``` - -2. Use `forSelf()` - -Call `forSelf()` and pass in the nested model so AutoMapper will map the matching properties between the `Destination` and that nested model. In other words, AutoMapper creates a Mapping between the nested model and `Destination` then have the original Mapping extend it. The second argument lets AutoMapper knows where to find the data whose value is of type nested model. - -```ts -createMap( - mapper, - CartItem, - CartItemDto, - forSelf(Item, (source) => source.item) -); -``` - -:::tip - -`forSelf()` also accepts a `Mapping` instead of just the nested model. - -```ts -const mapping = createMap(mapper, Item, CartItemDto); -createMap( - mapper, - CartItem, - CartItemDto, - forSelf(mapping, (source) => source.item) -); -``` - -::: diff --git a/packages/documentation/docs/mapping-configuration/naming-conventions.mdx b/packages/documentation/docs/mapping-configuration/naming-conventions.mdx deleted file mode 100644 index 72d340ee7..000000000 --- a/packages/documentation/docs/mapping-configuration/naming-conventions.mdx +++ /dev/null @@ -1,36 +0,0 @@ ---- -id: naming-conventions -title: NamingConventions -sidebar_label: NamingConventions -sidebar_position: 9 ---- - -Call `namingConventions()` and pass in a `NamingConventionInput` to customize the Mapping's [NamingConvention](../fundamentals/naming-convention) - -```ts -export type NamingConventionInput = - | NamingConvention - | { - source: NamingConvention; - destination: NamingConvention; - }; -``` - -```ts -createMap( - mapper, - User, - UserDto, - namingConventions(new CamelCaseNamingConvention()) -); - -createMap( - mapper, - Product, - ProductDto, - namingConventions({ - source: new SnakeCaseNamingConvention(), - destination: new CamelCaseNamingConvention(), - }) -); -``` diff --git a/packages/documentation/docs/mapping-configuration/overview.mdx b/packages/documentation/docs/mapping-configuration/overview.mdx deleted file mode 100644 index 8826f77d0..000000000 --- a/packages/documentation/docs/mapping-configuration/overview.mdx +++ /dev/null @@ -1,89 +0,0 @@ ---- -id: overview -title: Overview -sidebar_label: Overview -sidebar_position: 1 ---- - -## Convention over Configuration - -AutoMapper does have _Auto_ in its name. If we follow closely _Conventions_, we rarely need `MappingConfiguration`. - -As we have already seen in previous examples, matching properties are mapped automatically as long as the metadata is provided. - -```ts -class User { - @AutoMap() - firstName!: string; - @AutoMap() - lastName!: string; -} - -class UserDto { - @AutoMap() - firstName!: string; - @AutoMap() - lastName!: string; -} -``` - -Nested models are also _auto-mapped_. - -```ts -class Address { - @AutoMap() - street!: string; -} - -class Bio { - @AutoMap() - text!: string; - @AutoMap(() => [Address]) - addresses: Address[] = []; -} - -class User { - @AutoMap(() => Bio) - bio!: Bio; -} - -class AddressDto { - @AutoMap() - street!: string; -} - -class BioDto { - @AutoMap() - text!: string; - @AutoMap(() => [AddressDto]) - addresses: AddressDto[] = []; -} - -class UserDto { - @AutoMap(() => BioDto) - bio!: BioDto; -} -``` - -In addition, there is [Auto Flattening](../fundamentals/auto-flattening). These features should encourage us to stay as close to the conventions as possible. - -## Custom Configuration - -`MappingConfiguration` are functions that augment a `Mapping`. When creating a `Mapping` with `createMap()`, we can pass in as many `MappingConfiguration` as we like and in any order that we want. - -| mapping configuration | description | -| --------------------- | ----------------------------------------------------------------------- | -| `afterMap()` | Attach a `MapCallback` to run after the map operation | -| `beforeMap()` | Attach a `MapCallback` to run before the map operation | -| `constructUsing()` | Set a custom constructor for the `Destination` before the map operation | -| `extend()` | Extend another `Mapping` | -| `forMember()` | Configure a `MappingTransformation` for a property on the `Destination` | -| `forSelf()` | Configure flattening for `Destination` from a different `Source` | -| `namingConventions()` | Configure the `NamingConvention` for this `Mapping` | -| `typeConverters()` | Configure the `TypeConverter` for this `Mapping` | - -:::caution - -If the Mapping have an absurd amount of custom `MappingConfiguration`, it's time to re-evaluate the models or if AutoMapper is the right tool for the project. - -::: diff --git a/packages/documentation/docs/mapping-configuration/type-converters.mdx b/packages/documentation/docs/mapping-configuration/type-converters.mdx deleted file mode 100644 index 1a14063e0..000000000 --- a/packages/documentation/docs/mapping-configuration/type-converters.mdx +++ /dev/null @@ -1,105 +0,0 @@ ---- -id: type-converters -title: TypeConverters -sidebar_label: TypeConverters -sidebar_position: 10 ---- - -## What is Type Converter? - -Sometimes, we would want to take complete control over the conversion of one data type to another. Suppose we have the following model: - -```ts -export class Source { - @AutoMap() - value1!: string; - @AutoMap() - value2!: string; - @AutoMap() - value3!: string; -} -``` - -and we would like to map it to: - -```ts -export class Destination { - @AutoMap() - value1!: number; - @AutoMap() - value2!: Date; - @AutoMap() - value3!: boolean; -} -``` - -If we were to try and map `Source` to `Destination` as-is, we would end up with mismatch values and types on the `Destination`. For example, `Source.value1` will be mapped to `Destination.value1` even though the types of each `value1` are different. Instead of throwing an error, AutoMapper will map as-is to respect the dynamic nature of JavaScript. To control the conversions for these types when the properties are _matching_, we need to supply Type Converters to a specific Mapping - -Call `typeConverter()` in a `createMap()` to supply a Type Converter to the Mapping - -```ts -createMap( - mapper, - Source, - Destination, - typeConverter(String, Date, (str) => new Date(str)), - typeConverter(String, Number, (str) => parseInt(str, 10)), - typeConverter(String, Boolean, (str) => Boolean(str)) -); -``` - -Here, we're telling AutoMapper: - -- If you are mapping from a `String` to a `Number`, use `parseInt()` -- If you are mapping from a `String` to a `Date`, use `new Date()` -- If you are mapping from a `String` to a `Boolean`, use `Boolean()` - -```ts -const source = new Source(); -source.value1 = '123'; -source.value2 = '10/14/1991'; -source.value3 = 'truthy'; - -const destination = mapper.map(source, Destination, Source); -/** - * Destination { - value1: 123, // number - value2: Mon Oct 14 1991 00:00:00 GMT-0500 (Central Daylight Time), // a Date instance - value3; true // boolean - * } - */ -``` - -:::caution - -Properties (on the Destination) that are subject to Type Converters will be treated as-is in the mapping pipeline. In other words, they will be mapped like a primitive and will **NOT** go through any automatic nested mapping even if the properties' types are **Object** types. - -::: - -## Type of Type Converters - -There are 4 types of Type Converters: - -- `Type -> Type`: This is what we've just used above. A single Type to another single Type -- `Type -> [Type]`: AutoMapper can also set up Type Converter from a single Type to an Array Type. -- `[Type] -> Type`: The opposite is also true -- `[Type] -> [Type]`: Last but not least, AutoMapper can set up Type Converter between two Array Types. - -```ts -// a more complex example -createMap( - mapper, - TypeConverter, - TypeConverterDto, - typeConverter(String, Number, (str) => parseInt(str) + 1), - typeConverter(String, Boolean, (str) => Boolean(str)), - typeConverter(String, Date, (str) => new Date(str)), - typeConverter([String], [Number], (manyStrs) => - manyStrs.map((str) => parseInt(str)) - ), - typeConverter(DateString, String, (dateStr) => dateStr.toDateString()), - typeConverter(TimestampString, String, (timestampStr) => - timestampStr.toISOString() - ) -); -``` diff --git a/packages/documentation/docs/misc/fake-async.mdx b/packages/documentation/docs/misc/fake-async.mdx deleted file mode 100644 index d81a05062..000000000 --- a/packages/documentation/docs/misc/fake-async.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -id: fake-async -title: Fake Async -sidebar_label: Fake Async -sidebar_position: 4 ---- - -## "Fake" Async - -Currently, AutoMapper is manipulating the [Event Loop](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop) to provide a "fake" async support for the `mapAsync()` and `mutateAsync()` variants. - -```ts -// 👇 simplified for brevity -function mapAsync(...args) { - const result = map(...args); - return new Promise((res) => { - setTimeout(res, 0, result); - }); -} - -// 👇 simplified for brevity -function mutateAsync(...args) { - return new Promise((res) => { - mutate(...args); - setTimeout(res); - }); -} -``` - -## Help wanted - -Real async support can be achieved by some Isomorphic Worker that would execute the map operations on the Worker thread. However, AutoMapper implementation is full of `Function` which cannot be serialized (easily) to transfer to the Worker thread. If anyone wants to contribute Asynchronous support, I'm happy to walk you through the repository. diff --git a/packages/documentation/docs/misc/mapped-types.mdx b/packages/documentation/docs/misc/mapped-types.mdx deleted file mode 100644 index e938d785e..000000000 --- a/packages/documentation/docs/misc/mapped-types.mdx +++ /dev/null @@ -1,115 +0,0 @@ ---- -id: mapped-types -title: Mapped Types -sidebar_label: Mapped Types -sidebar_position: 2 ---- - -`@automapper/classes/mapped-types` is part of the public API of `@automapper/classes`. - -`@automapper/classes/mapped-types` is inspired by `@nestjs/mapped-types` to provide mixins to reduce some boilerplate code. - -## Usage - -All `Mapper*Type` are exported from `@automapper/classes/mapped-types` - -### `MapperPickType` - -`MapperPickType` accepts an original class, and an array of property keys to **pick** from the original class. - -```ts -class Foo { - @AutoMap() - foo!: string; - @AutoMap() - bar!: number; - @AutoMap() - baz!: boolean; -} - -class PickFooBar extends MapperPickType(Foo, ['foo', 'bar']) {} - -createMap(mapper, Foo, PickFooBar); - -const foo = new Foo(); -foo.foo = 'foo'; -foo.bar = 123; -foo.baz = true; - -const pickedFooBar = mapper.map(foo, Foo, PickFooBar); -console.log(pickedFooBar); -/** - * PickFooBar { foo: 'foo', bar: 123 } - * only foo and bar have been picked - */ -``` - -### `MapperOmitType` - -`MapperOmitType` accepts an original class, and an array of property keys to **omit** from the original class. - -```ts -class Foo { - @AutoMap() - foo!: string; - @AutoMap() - bar!: number; - @AutoMap() - baz!: boolean; -} - -class OmitFooBar extends MapperOmitType(Foo, ['foo', 'bar']) {} - -createMap(mapper, Foo, OmitFooBar); - -const foo = new Foo(); -foo.foo = 'foo'; -foo.bar = 123; -foo.baz = true; - -const omittedFooBar = mapper.map(foo, Foo, OmitFooBar); -console.log(omittedFooBar); -/** - * OmitFooBar { baz: true } - * foo and bar have been omitted - */ -``` - -### `MapperIntersectionType` - -`MapperIntersectionType` accepts two parent classes to receive all properties from both classes. - -```ts -class Foo { - @AutoMap() - foo!: string; -} - -class Bar { - @AutoMap() - bar!: number; -} - -class IntersectFooBar extends MapperIntersectionType(Foo, Bar) {} - -createMap(mapper, IntersectFooBar, Foo); -createMap(mapper, IntersectFooBar, Bar); - -const intersect = new IntersectFooBar(); -intersect.foo = 'foo'; -intersect.bar = 123; - -const foo = mapper.map(intersect, IntersectFooBar, Foo); -console.log(foo); -/** - * Foo { foo: 'foo' } - */ - -const bar = mapper.map(intersect, IntersectFooBar, Bar); -console.log(bar); -/** - * Bar { bar: 123 } - */ -``` - -> AutoMapper does not have the concept of mapping multiple `Sources` to a `Destination`. Hence, please be cautious when to utilize `MapperIntersectionType` diff --git a/packages/documentation/docs/misc/self-mapping.mdx b/packages/documentation/docs/misc/self-mapping.mdx deleted file mode 100644 index 660fe1fbe..000000000 --- a/packages/documentation/docs/misc/self-mapping.mdx +++ /dev/null @@ -1,55 +0,0 @@ ---- -id: self-mapping -title: Self Mapping (Same Identifier) -sidebar_label: Self Mapping -sidebar_position: 3 ---- - -In some cases, we might want to map a model (identifier) to itself. In AutoMapper TypeScript, this is called **Self Mapping**. Let's explore the following models - -```ts -export class Person { - @AutoMap() - name!: string; // always required - @AutoMap() - nickname?: string; // can be optional -} - -export class Org { - @AutoMap(() => [Person]) - people!: Person[]; -} - -export class OrgDto { - @AutoMap(() => [Person]) - people!: Person[]; -} -``` - -Instead of having a `PersonDto`, our `Org` and `OrgDto` use `Person` for the field `people`. There are situations where this is the case. With this in mind, we can create our mappings as follow: - -```ts -/** - * Short-hand syntax for - * - * createMap( - * mapper, - * Person, - * Person, - * forMember(...) - * ) - */ -createMap(mapper, Person, forMember(d => d.nickname, mapFrom(s => s.nickname ?? s.name)); -createMap(mapper, Org, OrgDto); -``` - -We can then map `Org` to `OrgDto` as normal - -```ts -const dto = mapper.map(org, Org, OrgDto); -// we can also map the "people" -/** - * Short-hand syntax for: mapper.mapArray(org.people, Person, Person); - */ -const mappedPeople = mapper.mapArray(org.people, Person); -``` diff --git a/packages/documentation/docs/misc/transformer-plugin.mdx b/packages/documentation/docs/misc/transformer-plugin.mdx deleted file mode 100644 index 39b9609f5..000000000 --- a/packages/documentation/docs/misc/transformer-plugin.mdx +++ /dev/null @@ -1,281 +0,0 @@ ---- -id: transformer-plugin -title: Transformer Plugin -sidebar_label: Transformer Plugin -sidebar_position: 1 ---- - -`@automapper/classes/transformer-plugin` is part of the public API of `@automapper/classes`. - -## Problem - -Decorating Classes' members with `@AutoMap()` is verbose, even when we're being meticulous about what members are being **auto-configured** vs **custom-configured**. In some other cases, the Classes themselves are being **generated**, and/or from **external libraries** that the consumers **cannot** touch. - -`@automapper/classes/transformer-plugin` is to ease this pain point when using `@automapper/classes` - -## How it works - -Let's look at the following classes - -```ts -class Profile { - bio!: string; - age!: number; -} -class User { - firstName!: string; - lastName!: string; - profile!: Profile; -} -``` - -Throughout the documentation, we all know that the above code will be compiled to - -```js -class Profile {} -class User {} -``` - -The requirement for `@automapper/classes` to work is to decorate all the members of both classes with `@AutoMap` in order for `@automapper/classes` to keep track of the metadata of each class. - -```ts -class Profile { - @AutoMap() - bio!: string; - @AutoMap() - age!: number; -} -class User { - @AutoMap() - firstName!: string; - @AutoMap() - lastName!: string; - @AutoMap(() => Profile) - profile!: Profile; -} -``` - -This will get very verbose very soon. - -`@automapper/classes/transformer-plugin` runs a `before` transformer that affects the **AST** directly before the Compilation step. - -The transformer will - -- Look at files that end with `.entity.ts`, `.model.ts`, `.vm.ts`, and `.dto.ts` (this can be changed via transformer plugin options) -- Iterate through all the members (`PropertyDeclaration`) of each class (`ClassDeclaration`) that it finds -- Store the members in a list that `@automapper/classes` can understand -- Add to each class a `static method` and return the list. - -Let's look at the above snippet again - -```ts -// your code -class Profile { - bio!: string; - age!: number; -} -class User { - firstName!: string; - lastName!: string; - profile!: Profile; -} - -// after "before" transformer runs through your code - -class Profile { - bio!: string; - age!: number; - - static __AUTOMAPPER_METADATA_FACTORY__() { - return [ - ['bio', { type: () => String, depth: 1 }], - ['age', { type: () => Number, depth: 1 }], - ]; - } -} -class User { - firstName!: string; - lastName!: string; - profile!: Profile; - - static __AUTOMAPPER_METADATA_FACTORY__() { - return [ - ['firstName', { type: () => String, depth: 1 }], - ['lastName', { type: () => String, depth: 1 }], - ['profile', { type: () => Profile, depth: 1 }], - ]; - } -} -``` - -After compilation, the members will be gone, but the static function will stay making it available to be called at runtime. Hence, the metadata will be available. `@automapper/classes/transformer-plugin` only adds the minimum amount of code needed to keep track of the metadata. - -## Limitations - -Currently, `@automapper/classes/transformer-plugin` will handle most `Nullable` (`type | null`) and `Maybe` (`propKey?: type`) cases. However, for complex cases where you have unions with different types (`string | number | boolean` or `ClassA | ClassB`), please consider decorate the property (field) manually with `@AutoMap()` decorator. - -## Usage - -This is utilizing an experimental feature of TypeScript. Hence, you need to modify the build step of your project to use `@automapper/classes/transformer-plugin` - -### Ignore a property - -The plugin will automatically construct the metadata for all properties that aren't decorated with `@AutoMap`. However, there are also cases where you neither want `@AutoMap` nor having the plugin processing a property (eg: you want to take the control 100% with manual configuration). To ignore a property completely, you can use a JSDoc tag `@autoMapIgnore` on a property - -```ts -class User { - firstName!: string; - lastName!: string; - profile!: Profile; - /** - * @autoMapIgnore - */ - ignoreMe!: string; -} -``` - -### Options - -```ts -export interface AutomapperTransformerPluginOptions { - modelFileNameSuffix?: string[]; -} -``` - -`modelFileNameSuffix` is default to `['.entity.ts', '.model.ts', '.vm.ts', '.dto.ts']` - -### Webpack - -If you use `ts-loader` or some fork of `ts-loader`, you can configure Webpack config to turn on Transformers - -```ts -// snip -const automapperTransformerPlugin = require('@automapper/classes/transformer-plugin'); -const pluginOptions = { - modelFileNameSuffix: [ - /*...*/ - ], -}; -module.exports = { - // snip - module: { - rules: [ - // snip - { - test: /\.tsx?$/, - loader: 'ts-loader', - options: { - getCustomTransformers: (program) => ({ - before: [ - automapperTransformerPlugin(program, pluginOptions) - .before, - ], - }), - }, - }, - // snip - ], - }, - // snip -}; -``` - -### Rollup - -Use `rollup-plugin-typescript2` as it has `transformers` capability - -```ts -import automapperTransformerPlugin from '@automapper/classes/transformer-plugin'; -import typescript from 'rollup-plugin-typescript2'; -const pluginOptions = { - modelFileNameSuffix: [ - /*...*/ - ], -}; -export default { - // snip - preserveModules: true, // <-- turn on preserveModules - plugins: [ - // snip - typescript({ - transformers: [ - (service) => ({ - before: [ - automapperTransformerPlugin( - service.getProgram(), - pluginOptions - ).before, - ], - }), - ], - }), - // snip - ], -}; -``` - -### ttypescript - -`ttypescript` patches `typescript` in order to use transformers in `tsconfig.json`. See [ttypescript's README](https://github.com/cevek/ttypescript) for how to use this with module bundlers such as webpack or Rollup. - -``` -{ - "compilerOptions": { - ..., - "plugins": [ - { - "transform": "@automapper/classes/transformer-plugin", - "modelFileNameSuffix": [...] - } - ], - ... - } -} -``` - -### NestJS CLI - -`nestjs/cli` can enable Transformers by default. To use this plugin with `nestjs/cli`, modify your `nest-cli.json` - -```json -{ - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "plugins": ["@automapper/classes/transformer-plugin"] - } -} -``` - -or with options - -```json -{ - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "plugins": [ - { - "name": "@automapper/classes/transformer-plugin", - "options": { - "modelFileNameSuffix": [".dto.ts", ".vm.ts"] - } - } - ] - } -} -``` - -### NestJS with Nx - -Nx v12.8 adds support for TypeScript Compiler plugins via an option called `tsPlugins` (now `transformers`) in their `@nrwl/node:webpack` executor, which is what `@nrwl/nest` is using for building the application. - -Read more about the usage here: [Nx 12.8 Blog post](https://blog.nrwl.io/micro-frontends-using-module-federation-presets-for-react-and-storybook-typescript-compiler-4120cf134816) - -#### Pre 12.8 - -**NestJS** in Nx workspace utilizes `nrwl/node:build` executor (formerly, builder) which allows you to pass in a custom Webpack config. However, to turn on Transformer, there's still this [open issue](https://github.com/nrwl/nx/issues/2147) in which you can find multiple solutions/workarounds as of the moment. - -### Angular - -Angular CLI has sophisticated build process so please take a look at [this article](https://indepth.dev/posts/1045/having-fun-with-angular-and-typescript-transformers) and related articles mentioned to come up with your approach of turning on Transformers for Angular projects diff --git a/packages/documentation/docs/nestjs.mdx b/packages/documentation/docs/nestjs.mdx deleted file mode 100644 index ecb3e4dcf..000000000 --- a/packages/documentation/docs/nestjs.mdx +++ /dev/null @@ -1,200 +0,0 @@ ---- -id: nestjs -title: Usage with NestJS -sidebar_label: Usage with NestJS ---- - -`@automapper/nestjs` is the official integration for [NestJS](https://nestjs.com). - -## Installation - -```shell -npm i @automapper/core @automapper/nestjs -``` - -```shell -yarn add @automapper/core @automapper/nestjs -``` - -### Strategy - -Recommendation is to use `@automapper/classes` in a NestJS application. - -## Usage - -- Call `AutomapperModule.forRoot()` in `AppModule` - -```ts -import { AutomapperModule } from '@automapper/nestjs'; -import { classes } from '@automapper/classes'; - -// single strategy -@Module({ - imports: [ - AutomapperModule.forRoot({ - strategyInitializer: classes(), - }), - ], -}) -export class AppModule {} - -// multiple strategies -@Module({ - imports: [ - AutomapperModule.forRoot( - [ - { - name: 'classes', - strategyInitializer: classes(), - }, - { - name: 'pojos', - strategyInitializer: pojos(), - }, - ], - { - globalErrorHandler, - globalNamingConventions, - } - ), - ], -}) -export class AppModule {} -``` - -- Use `@InjectMapper()` to inject the `Mapper` in NestJS's `Injectable` -- `@InjectMapper()` accepts an optional argument `name`. This is the name of the `CreateMapperOptions` passed to `AutomapperModule.forRoot()` -- `AutomapperModule` is a `Global` module, so it is only needed to be imported once to have the `Mapper` available across the application - -## Async Configuration - -TBD: `AutomapperModule.forRootAsync()` - -## `AutomapperProfile` - -`AutomapperProfile` is an `Injectable` in NestJS. Make sure to `extends AutomapperProfile` - -```ts -import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; -import type { Mapper } from '@automapper/core'; -import { Injectable } from '@nestjs/common'; -import { createMap } from '@automapper/core'; - -@Injectable() -export class UserProfile extends AutomapperProfile { - constructor(@InjectMapper() mapper: Mapper) { - super(mapper); - } - - override get profile() { - return (mapper) => { - createMap(mapper, User, UserDto); - }; - } -} -``` - -Then provide `UserProfile` in a `Module` - -```ts -@Module({ - providers: [UserProfile], -}) -export class UserModule {} -``` - -- `AutomapperProfile` enforces the sub-classes to implement a `get profile()` method that returns a `MappingProfile`. -- `AutomapperProfile` can have other Services injected to its constructor if needed. - -### `mappingConfigurations` - -Same concept as [Share Configuration using MappingProfile](./tutorial/mapping-profile#share-mappingconfiguration), `AutomapperProfile` has an optional `protected get mappingConfigurations()` that subclasses can override to provide an array of `MappingConfiguration`. These configurations are passed to all `createMap()` in `get profile()` - -```ts -import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class UserProfile extends AutomapperProfile { - constructor(@InjectMapper() mapper: Mapper) { - super(mapper); - } - - get profile(): MappingProfile { - return (mapper) => { - createMap(mapper, UserEntity, UserDto); - createMap(mapper, UserEntity, UserInformationDto); - createMap(mapper, UserEntity, AuthUserDto); - }; - } - - protected get mappingConfigurations(): MappingConfiguration[] { - // the 3 createMap() above will get this `extend()` - return [extend(BaseEntity, BaseDto)]; - } -} -``` - -## `MapInterceptor` - -`@automapper/nestjs` provides `MapInterceptor`. In cases where you do not care about annotating the correct return type for a **Controller#method** and want your **Service** to be a little cleaner, you can utilize the `MapInterceptor` to execute the mapping. - -```ts -import { UseInterceptors } from '@nestjs/common'; -import { MapInterceptor } from '@automapper/nestjs'; - -export class UserController { - @Get('me') - @UseInterceptors(MapInterceptor(User, UserDto)) - me() { - // userService.getMe() returns a User here and does not have mapping logic in it. - return this.userService.getMe(); - } -} -``` - -`MapInterceptor` has the following signature: - -```ts -MapInterceptor(sourceModelType, destinationModelType, { - isArray?: boolean; - mapperName?: string; -} & MapOptions) -``` - -## `MapPipe` - -`@automapper/nestjs` provides `MapPipe`. When you want to transform the incoming request body before it gets to the route handler, you can utilize `MapPipe` to achieve this behavior - -```ts -import { MapPipe } from '@automapper/nestjs'; - -@Post('/from-body') -postFromBody(@Body(MapPipe(User, UserDto)) user: UserDto) { - // from the request perspective, user coming in as an User object but will be mapped to UserDto with MapPipe - return user; -} -``` - -`MapPipe` only works with `@Body` or `@Query`. - -```ts -import { MapPipe } from '@automapper/nestjs'; - -@Get('/from-query') -getFromQuery(@Query(MapPipe(User, UserDto)) user: UserDto) { - // from the request perspective, user coming in as an User object but will be mapped to UserDto with MapPipe - return user; -} -``` - -> Note that when we send a request with `Body` or `Query`, the data is serialized. Data-type like `Date` will come in the request handler as `string`. Hence, please be cautious of the mapping configuration when you use `MapPipe` - -`MapPipe` has the same signature as `MapInterceptor` - -```ts -MapPipe(sourceModelType, destinationModelType, { - isArray?: boolean; - mapperName?: string; -} & MapOptions) -``` diff --git a/packages/documentation/docs/strategies/classes.mdx b/packages/documentation/docs/strategies/classes.mdx deleted file mode 100644 index 7d3b9da9a..000000000 --- a/packages/documentation/docs/strategies/classes.mdx +++ /dev/null @@ -1,116 +0,0 @@ ---- -id: classes -title: automapper/classes -sidebar_label: automapper/classes -sidebar_position: 1 ---- - -## Overview - -`@automapper/classes` is an official strategy that works with ES6/TS Class-based projects. It is recommended to use with [NestJS](https://nestjs.com) backend as NestJS works very well with Classes. - -## Installation - -```shell -npm i @automapper/core @automapper/classes -``` - -```shell -yarn add @automapper/core @automapper/classes -``` - -## Usage - -We have been using `@automapper/classes` throughout the documentation. Check [Tutorial](../tutorial/preface) for more information about usage. - -## Metadata - -### `AutoMap` - -`AutoMap` is a `PropertyDecorator` to track a property's metadata on a class. `@AutoMap()` has 3 overloads: - -- `@AutoMap()`: Use on primitives like `string`, `number`, and `boolean`. `Date` is also acceptable. -- `@AutoMap(() => Type | [Type])`: Supply a `Type` so `AutoMap` does not have to guess (or cannot guess). Use on nested models, enums, array types, complex union types, or ambiguous types (e.g: `any`, `Record`) - -- `@AutoMap(AutoMapOptions)`: Take complete control of the data that `AutoMap` stores - -#### Enum - -In TypeScript, there are 2 main types of Enums that we can have: String and Numeric. In order for AutoMapper to work correctly with Enums, we need to supply the type of our enums with `AutoMap()` - -```ts -enum Color { - Red, - Green, - Blue, -} - -enum Role { - Admin = 'admin', - User = 'user', -} - -class User { - @AutoMap(() => String) // string enum - role!: Role; - @AutoMap(() => Number) // numeric enum - color!: Color; -} -``` - -#### Array type - -As mentioned above, we need to explicitly supply a type to `AutoMap` if the property has an Array type - -```ts -class User { - @AutoMap(() => [Date]) - logins!: Date[]; -} -``` - -#### Circular Dependency - -It is common to have circular dependency in terms of models when we work with an ORM. By default, AutoMapper sets a `depth` value of `1` for nested models. - -In other words, let's assume we have a circular dependency: `A <-> B` - -```ts -class A { - b: B; -} - -class B { - a: A; -} - -// depth 1 -A { - b: B { - a: A { - b: undefined - } - } -} - -// depth 2 -A { - b: B { - a: A { - b: B { - a: undefined - } - } - } -} -``` - -We can configure the `depth` by using the `AutoMapOptions` with `AutoMap` - -### Transformer Plugin - -This section is meant to be a placeholder because `Transformer Plugin` is related to `@automapper/classes`. Please check the dedicated [TransformerPlugin](../misc/transformer-plugin) - -### Mapped Types - -This section is meant to be a placeholder because `Mapped Types` is related to `@automapper/classes`. Please check the dedicated [MappedTypes](../misc/mapped-types) diff --git a/packages/documentation/docs/strategies/mikro.mdx b/packages/documentation/docs/strategies/mikro.mdx deleted file mode 100644 index 118942076..000000000 --- a/packages/documentation/docs/strategies/mikro.mdx +++ /dev/null @@ -1,40 +0,0 @@ ---- -id: mikro -title: automapper/mikro -sidebar_label: automapper/mikro -sidebar_position: 3 ---- - -## Overview - -`@automapper/mikro` is an official strategy that works with [MikroORM](https://mikro-orm.io/). `@automapper/mikro` is an extension of `@automapper/classes` - -## Installation - -```shell -npm i @automapper/core @automapper/classes @automapper/mikro -``` - -```shell -yarn add @automapper/core @automapper/classes @automapper/mikro -``` - -## Usage - -Using `@automapper/mikro` isn't different from using [`@automapper/classes`](./classes#usage). The difference is `@automapper/mikro` has a custom `preMap` that handles how to serialize the `sourceObject` because of how MikroORM returns the object upon a Retrieve operation. - -### `preMap` - -The default `preMap` from `@automapper/mikro` checks for Collection, Reference, and Entity recursively to serialize the `sourceObject` to their raw JSON data. - -We can customize this `preMap` via `MappingStrategyInitializerOptions` - -```ts -const mapper = createMapper({ - strategyInitializer: mikro({ preMap: customPreMap }), -}); -``` - -## Metadata - -See [@automapper/classes](./classes#metadata) diff --git a/packages/documentation/docs/strategies/pojos.mdx b/packages/documentation/docs/strategies/pojos.mdx deleted file mode 100644 index 2bd1bc35b..000000000 --- a/packages/documentation/docs/strategies/pojos.mdx +++ /dev/null @@ -1,206 +0,0 @@ ---- -id: pojos -title: automapper/pojos -sidebar_label: automapper/pojos -sidebar_position: 2 ---- - -## Overview - -`@automapper/pojos` is an official strategy that works with TS Interface-based projects. - -## Installation - -```shell -npm i @automapper/core @automapper/pojos -``` - -```shell -yarn add @automapper/core @automapper/pojos -``` - -## Usage - -Different from `@automapper/classes`, `@automapper/pojos` exports a singleton `PojosMetadataMap` so we can provide the metadata for `pojos`. - -```ts -interface User { - firstName: string; - lastName: string; -} - -interface UserDto { - firstName: string; - lastName: string; - fullName: string; -} - -export function createUserMetadata() { - PojosMetadataMap.create('User', { - firstName: String, - lastName: String, - }); - - PojosMetadataMap.create('UserDto', { - firstName: String, - lastName: String, - fullName: String, - }); -} - -createUserMetadata(); - -const mapper = createMapper({ strategyInitializer: pojos() }); - -createMap( - mapper, - 'User', // this needs to match what we passed in PojosMetadataMap.create() - 'UserDto', // this needs to match what we passed in PojosMetadataMap.create() - forMember( - (destination) => destination.fullName, - mapFrom((source) => source.firstName + ' ' + source.lastName) - ) -); - -const dto = mapper.map( - { firstName: 'Chau', lastName: 'Tran' }, - 'User', // this needs to match what we passed in PojosMetadataMap.create() - 'UserDto' // this needs to match what we passed in PojosMetadataMap.create() -); // { firstName: 'Chau', lastName: 'Tran', fullName: 'Chau Tran' -``` - -:::caution - -`PojosMetadataMap.create()` needs to be called before we attempt to call `createMap()` - -::: - -## Metadata - -As seen above, `PojosMetadataMap` is a way that `@automapper/pojos` use to keep track of the Interface's metadata. - -### `PojosMetadataMap.create()` - -`PojosMetadataMap.create()` accepts a `string` or a `symbol` as the identifier and a metadata object. - -```ts -PojosMetadataMap.create('User', { - firstName: String, - lastName: String, -}); -``` - -:::tip - -Supply the type parameter `PojosMetadataMap.create()` will provide intellisense for the fields in the metadata object - -::: - -#### Nested model - -Nested model's metadata needs to be created before the parent model. - -```ts -interface Bio { - birthday: Date; -} - -interface User { - firstName: string; - lastName: string; - bio: Bio; -} - -PojosMetadataMap.create('Bio', { - birthday: Date, -}); - -PojosMetadataMap.create('User', { - firstName: String, - lastName: String, - bio: 'Bio', // <-- use what we passed in PojosMetadataMap.create() for Bio -}); -``` - -#### Enum - -Same as `@automapper/classes`, we still need to explicitly supply the Enum type - -```ts -enum Role { - Admin = 'admin', - User = 'user', -} - -interface User { - role: Role; -} - -PojosMetadataMap.create('User', { - role: String, -}); -``` - -#### Array type - -Same as `@automapper/classes`, we still need to explicitly supply the Array type - -```ts -interface Address { - street: string; -} - -interface Bio { - birthday: Date; - addresses: Address[]; -} - -interface User { - firstName: string; - lastName: string; - bio: Bio; - logins: Date[]; -} - -PojosMetadataMap.create
('Address', { - street: String, -}); - -PojosMetadataMap.create('Bio', { - birthday: Date, - addresses: ['Address'], // <-- array of the identifier -}); - -PojosMetadataMap.create('User', { - firstName: String, - lastName: String, - bio: 'Bio', - logins: [Date], // <-- array of the Date constructor -}); -``` - -#### Circular Dependency - -Same as `@automapper/classes`, we can also have circular dependency with Interfaces. - -```ts -PojosMetadataMap.create('Bio', { - birthday: Date, - addresses: ['Address'], -}); - -PojosMetadataMap.create('User', { - firstName: String, - lastName: String, - bio: { - // pass in an object instead - type: () => 'Bio', - depth: 2, // default to 1 - }, - logins: [Date], -}); -``` - -### `PojosMetadataMap.reset()` - -To clear all the metadata, we can call `PojosMetadataMap.reset()`. This is useful in testing environment where we want to start fresh for each test suite. diff --git a/packages/documentation/docs/strategies/sequelize.mdx b/packages/documentation/docs/strategies/sequelize.mdx deleted file mode 100644 index 42cf91ea2..000000000 --- a/packages/documentation/docs/strategies/sequelize.mdx +++ /dev/null @@ -1,47 +0,0 @@ ---- -id: sequelize -title: automapper/sequelize -sidebar_label: automapper/sequelize -sidebar_position: 4 ---- - -## Overview - -`@automapper/sequelize` is an official strategy that works with [Sequelize](https://sequelize.org/). `@automapper/sequelize` is an extension of `@automapper/classes` - -## Installation - -```shell -npm i @automapper/core @automapper/classes @automapper/sequelize -``` - -```shell -yarn add @automapper/core @automapper/classes @automapper/sequelize -``` - -## Usage - -Using `@automapper/sequelize` isn't different from using [`@automapper/classes`](./classes#usage). The difference is `@automapper/sequelize` has a custom `preMap` that handles how to serialize the `sourceObject` because of how Sequelize returns the object upon a Retrieve operation and a custom `destinationConstructor`. - -### `preMap` - -Use `Sequelize#Model.get()` if it's available to serialize the `sourceObject` - -### `destinationConstructor` - -Use `Sequelize#Model.build()` if it's available to construct the `destinationObject`. - -We can customize this `preMap` via `MappingStrategyInitializerOptions` - -```ts -const mapper = createMapper({ - strategyInitializer: sequelize({ - preMap: customPreMap, - destinationConstructor: customDestinationConstructor, - }), -}); -``` - -## Metadata - -See [@automapper/classes](./classes#metadata) diff --git a/packages/documentation/docs/tutorial/create-mapper.mdx b/packages/documentation/docs/tutorial/create-mapper.mdx deleted file mode 100644 index 73195996e..000000000 --- a/packages/documentation/docs/tutorial/create-mapper.mdx +++ /dev/null @@ -1,165 +0,0 @@ ---- -id: create-mapper -title: Create a Mapper -sidebar_label: Create a Mapper -sidebar_position: 2 ---- - -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - -## Installation - -First step is to install `@automapper/*` via `npm` or `yarn` - -```shell -npm i @automapper/core @automapper/classes reflect-metadata -``` - -```shell -yarn add @automapper/core @automapper/classes reflect-metadata -``` - -:::info - -- We are using `@automapper/classes` for this tutorial. Check [Installation](../getting-started/installation) for more details. -- The rest of the tutorial will apply to `@automapper/classes` only. If you're using `@automapper/pojos`, please check [POJOs Strategy](../strategies/pojos) - -::: - -Next, let's adjust the `tsconfig` of your project. Make sure that you have the following options: - -```json -{ - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "skipLibCheck": true -} -``` - -## Create your first Mapper - -Now that you have everything installed and configured, let's start creating the Mapper. - -```ts title="/src/mappings/mapper.ts" -import { createMapper } from '@automapper/core'; -import { classes } from '@automapper/classes'; - -// Create and export the mapper -export const mapper = createMapper({ - strategyInitializer: classes(), -}); -``` - -:::tip - -A typical project should only have a singleton `Mapper` with a certain `MappingStrategy`. - -::: - -:::info - -`createMapper()` accepts a `CreateMapperOptions` which allows to customize the `Mapper`. Read more at [createMapper() API](../api/core/modules#createmapper) - -::: - -## Metadata Discovery - -For AutoMapper to do the _Auto_ part, you need to tell AutoMapper about your models. In other words, let AutoMapper know what your models have, what properties, what types those properties have. - -With `@automapper/classes`, you will use `@AutoMap()` decorator to do so. Let's bring back the models - - - - -```ts -export class User { - @AutoMap() - firstName: string; - - @AutoMap() - lastName: string; - - @AutoMap() - username: string; - - password: string; // <- we purposely left this one out because we don't want to map "password" - - @AutoMap(() => Bio) - bio: Bio; -} - -export class Bio { - @AutoMap(() => Job) - job: Job; - - @AutoMap() - birthday: Date; - - @AutoMap() - avatarUrl: string; -} - -export class Job { - @AutoMap() - title: string; - - @AutoMap() - salary: number; -} -``` - - - - -```ts -export class UserDto { - @AutoMap() - firstName: string; - - @AutoMap() - lastName: string; - - @AutoMap() - fullName: string; - - @AutoMap() - username: string; - - @AutoMap(() => BioDto) - bio: BioDto; -} - -export class BioDto { - @AutoMap() - jobTitle: string; - - @AutoMap() - jobSalary: number; - - @AutoMap() - birthday: string; - - @AutoMap() - avatarUrl: string; -} -``` - - - - -In addition to attaching `@AutoMap()` on most properties of both `User` and `UserDto`, we also remove all the existing mapping logics in `UserDto`. The result is `UserDto` no longer needs to be aware of `User`. - -`@AutoMap()` can help AutoMapper to track the property and its type. For nested model like `Bio`, we help `@AutoMap()` by providing a function that returns the type (e.g.: `() => Bio`) - -:::info - -Read more about [@AutoMap()](../strategies/classes#automap) - -::: - -:::tip - -If you want to remove the verbosity with `@AutoMap()`, check out [Transformer Plugin](../misc/transformer-plugin) - -::: diff --git a/packages/documentation/docs/tutorial/create-mapping.mdx b/packages/documentation/docs/tutorial/create-mapping.mdx deleted file mode 100644 index 085c9e11c..000000000 --- a/packages/documentation/docs/tutorial/create-mapping.mdx +++ /dev/null @@ -1,89 +0,0 @@ ---- -id: create-mapping -title: Create Mappings -sidebar_label: Create Mappings -sidebar_position: 3 ---- - -Our models have been prepped from the last step. It is time to create the `Mapping` between them. - -:::tip - -Mappings creation are fast and should be placed where the application starts so that they are only created **once**. - -::: - -## `createMap()` - -```ts title="/src/main.ts" -import { createMap } from '@automapper/core'; -import { mapper } from './mappings/mapper'; -import { Bio, BioDto } from './models/bio'; -import { User, UserDto } from './models/user'; - -createMap(mapper, Bio, BioDto); -createMap(mapper, User, UserDto); -``` - -:::tip - -Order of `createMap()` matters. Since `Bio` and `BioDto` are nested models on `User` and `UserDto`, the `Mapping` needs to be created first in order for `Mapping` to be initialized correctly. - -::: - -`createMap(mapper, Source, Destination)` will create and return a `Mapping`. Let's test the Mappings out with the following `User` - -```ts -const user = new User(); -user.firstName = 'Chau'; -user.lastName = 'Tran'; -user.username = 'ctran'; -user.password = '123456'; -user.bio = new Bio(); -user.bio.avatarUrl = 'google.com'; -user.bio.birthday = new Date(); -user.bio.job = new Job(); -user.bio.job.title = 'Developer'; -user.bio.job.salary = 99999; -``` - -## `map()` - -To execute a map operation from `User` to `UserDto`, you invoke: - -```ts -const dto = mapper.map(user, User, UserDto); -``` - -You will notice that the call runs successfully but with two errors logged to the console. - -``` -[AutoMapper] -Unmapped properties for Bio -> BioDto: -------------------- -jobTitle, -jobSalary - -[AutoMapper] -Unmapped properties for User -> UserDto: -------------------- -fullName -``` - -Let's also take a look at the `dto` - -``` -UserDto { - bio: BioDto { - avatarUrl: 'google.com', - birthday: 2022-03-23T22:46:17.110Z - }, - username: 'ctran', - lastName: 'Tran', - firstName: 'Chau' -} -``` - -It is clear that `bio.jobTitle`, `bio.jobSalary`, and `fullName` are missing. The errors are correct. Additionally, there's also another "error": `UserDto#birthday` is a `string` but it holds a `Date` value now because it was mapped from `User#birthday`. - -Let's fix the issues one by one in the next section. diff --git a/packages/documentation/docs/tutorial/mapping-configurations.mdx b/packages/documentation/docs/tutorial/mapping-configurations.mdx deleted file mode 100644 index 75e2f70f0..000000000 --- a/packages/documentation/docs/tutorial/mapping-configurations.mdx +++ /dev/null @@ -1,251 +0,0 @@ ---- -id: mapping-configurations -title: Use Mapping Configurations -sidebar_label: Use Mapping Configurations -sidebar_position: 4 ---- - -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - -## `fullName` - -In this particular case, you can call `fullName` a _computed_ property because the Source (`User`) -does not have a `fullName` property. Hence, it must be computed from something else. To configure the mapping instruction for a property, you can use `forMember()` with `createMap()` - -```ts -import { createMap, forMember, mapFrom } from '@automapper/core'; - -createMap( - mapper, - User, - UserDto, - // highlight-start - forMember( - (destination) => destination.fullName, - mapFrom((source) => source.firstName + ' ' + source.lastName) - ) - // highlight-end -); -``` - -`forMember()` accepts two arguments: - -- A `Selector` to select the property you want to configure the instruction for. -- A `MemberMapFn` that carries a specific instruction. - -:::info - -Read more about [ForMember](../mapping-configuration/for-member/overview) - -::: - -Here, `forMember()` is saying: For `destination.fullName`, use `source.firstName + ' ' + source.lastName` as the result. - -## `birthday` - -For `birthday`, let's try using another `MappingConfiguration` function: `typeConverter`. Here, we want to convert from `Date` to `string`. - -```ts -createMap( - mapper, - Bio, - BioDto, - // highlight-next-line - typeConverter(Date, String, (date) => date.toDateString()) -); -``` - -`typeConverter()` is saying: If a property on the Source is of type `Date` and the matching property on the Destination is of type `String`, use the conversion of `date.toDateString()` - -:::note - -- `typeConverter` will apply `Date -> String` conversion for ALL pairs of `Date, String`, not just `birthday` -- Instead of `typeConverter`, you can also use `forMember()` to map `BioDto#birthday` by itself. - -::: - -## `jobTitle` and `jobSalary` - -These two properties are a bit different from `fullName` and `birthday`. If you notice, you will see that `jobTitle` and `jobSalary` are **flatten** properties of `job.title` and `job.salary`. - -AutoMapper supports [Auto Flattening](../fundamentals/auto-flattening) out of the box with the concept of [Naming Conventions](../fundamentals/naming-convention). With that in mind, let's provide a `NamingConvention` for `Mapping` by using yet another `MappingConfiguration` function: `namingConventions()` - -```ts -createMap( - mapper, - Bio, - BioDto, - typeConverter(Date, String, (date) => date.toDateString()), - // highlight-next-line - namingConventions(new CamelCaseNamingConvention()) -); -``` - -Let's execute the map operation again, you'll get the complete `UserDto` 🎉 - -``` -UserDto { - bio: BioDto { - avatarUrl: 'google.com', - birthday: 'Wed Mar 23 2022', - jobSalary: 99999, - jobTitle: 'Developer' - }, - username: 'ctran', - lastName: 'Tran', - firstName: 'Chau', - fullName: 'Chau Tran' -} -``` - -## Summary - -- Without AutoMapper, mapping logic is repetitive and hard to scale. DTOs and Entities are coupling. -- With AutoMapper, matching properties are mapped _automatically_ (`firstName`, `lastName`, `username`, `bio`, and `bio.avatarUrl`) -- [Auto Flattening](../fundamentals/auto-flattening) with [Naming Conventions](../fundamentals/naming-convention) -- [Type Converter](../mapping-configuration/type-converters) allows for using the same conversion for the same pair of types. - - - - -```ts -export class User { - @AutoMap() - firstName: string; - - @AutoMap() - lastName: string; - - @AutoMap() - username: string; - - password: string; // <- we purposely left this one out because we don't want to map "password" - - @AutoMap(() => Bio) - bio: Bio; -} - -export class Bio { - @AutoMap(() => Job) - job: Job; - - @AutoMap() - birthday: Date; - - @AutoMap() - avatarUrl: string; -} - -export class Job { - @AutoMap() - title: string; - - @AutoMap() - salary: number; -} -``` - - - - -```ts -export class UserDto { - @AutoMap() - firstName: string; - - @AutoMap() - lastName: string; - - @AutoMap() - fullName: string; - - @AutoMap() - username: string; - - @AutoMap(() => BioDto) - bio: BioDto; -} - -export class BioDto { - @AutoMap() - jobTitle: string; - - @AutoMap() - jobSalary: number; - - @AutoMap() - birthday: string; - - @AutoMap() - avatarUrl: string; -} -``` - - - - -```ts -import { createMapper } from '@automapper/core'; -import { classes } from '@automapper/classes'; - -// Create and export the mapper -export const mapper = createMapper({ - strategyInitializer: classes(), -}); -``` - - - - -```ts -createMap( - mapper, - Bio, - BioDto, - typeConverter(Date, String, (date) => date.toDateString()), - namingConventions(new CamelCaseNamingConvention()) -); -createMap( - mapper, - User, - UserDto, - forMember( - (destination) => destination.fullName, - mapFrom((source) => source.firstName + ' ' + source.lastName) - ) -); -``` - - - - -```ts -export class UserService { - testMapping() { - const user = new User(); - user.firstName = 'Chau'; - user.lastName = 'Tran'; - user.username = 'ctran'; - user.password = '123456'; - user.bio = new Bio(); - user.bio.avatarUrl = 'google.com'; - user.bio.birthday = new Date(); - user.bio.job = new Job(); - user.bio.job.title = 'Developer'; - user.bio.job.salary = 99999; - - const dto = mapper.map(user, User, UserDto); - } -} -``` - - - - -