├── .nvmrc ├── package.cjs.json ├── package.esm.json ├── packages ├── moment │ ├── src │ │ ├── index.ts │ │ ├── index.spec.ts │ │ ├── Vmoment.ts │ │ └── Vmoment.spec.ts │ ├── .npmignore │ ├── tsconfig.json │ ├── tsconfig.cjs.json │ ├── package.json │ └── README.md ├── core │ ├── .npmignore │ ├── src │ │ ├── index.spec.ts │ │ ├── index.ts │ │ ├── typing.ts │ │ ├── testUtil.spec.ts │ │ ├── objectValidatorBuilder.ts │ │ ├── schema.ts │ │ └── V.ts │ ├── tsconfig.json │ ├── tsconfig.cjs.json │ └── package.json ├── diff │ ├── .npmignore │ ├── src │ │ ├── index.ts │ │ ├── index.spec.ts │ │ ├── Diff.ts │ │ ├── DiffNode.spec.ts │ │ ├── VersionInfo.ts │ │ ├── VersionInfo.spec.ts │ │ ├── DiffNode.ts │ │ └── Diff.spec.ts │ ├── tsconfig.json │ ├── tsconfig.cjs.json │ ├── package.json │ ├── README.md │ └── CHANGELOG.md ├── luxon │ ├── .npmignore │ ├── src │ │ ├── index.ts │ │ ├── index.spec.ts │ │ └── Vluxon.ts │ ├── tsconfig.json │ ├── tsconfig.cjs.json │ ├── package.json │ └── README.md ├── path │ ├── .npmignore │ ├── src │ │ ├── index.spec.ts │ │ ├── index.ts │ │ ├── jsonClone.ts │ │ ├── jsonClone.spec.ts │ │ ├── Projection.ts │ │ ├── matchers.ts │ │ ├── PathMatcher.ts │ │ ├── Path.ts │ │ ├── Path.spec.ts │ │ ├── Projection.spec.ts │ │ └── PathMatcher.spec.ts │ ├── tsconfig.json │ ├── tsconfig.cjs.json │ └── package.json └── path-parser │ ├── .npmignore │ ├── src │ ├── index.ts │ ├── index.spec.ts │ ├── parsePath.ts │ ├── parsePathMatcher.ts │ ├── parsePath.spec.ts │ ├── pathGrammar.ne │ ├── parsePathMatcher.spec.ts │ ├── matcherGrammar.ne │ ├── pathGrammar.ts │ └── matcherGrammar.ts │ ├── tsconfig.json │ ├── tsconfig.cjs.json │ ├── package.json │ ├── README.md │ └── CHANGELOG.md ├── lerna.json ├── codecov.yml ├── vitest.config.ts ├── .github ├── dependabot.yml ├── workflows │ ├── continuous_integration.yml │ └── code_coverage.yml └── CONTRIBUTING.md ├── publish.sh ├── tsconfig.json ├── .devcontainer └── devcontainer.json ├── LICENSE ├── package.json ├── .vscode └── launch.json ├── .gitignore └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /package.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs" 3 | } 4 | -------------------------------------------------------------------------------- /package.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /packages/moment/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Vmoment.js'; 2 | -------------------------------------------------------------------------------- /packages/core/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig.json 3 | tsconfig.tsbuildinfo 4 | -------------------------------------------------------------------------------- /packages/diff/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig.json 3 | tsconfig.tsbuildinfo 4 | -------------------------------------------------------------------------------- /packages/luxon/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig.json 3 | tsconfig.tsbuildinfo 4 | -------------------------------------------------------------------------------- /packages/moment/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig.json 3 | tsconfig.tsbuildinfo 4 | -------------------------------------------------------------------------------- /packages/path/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig.json 3 | tsconfig.tsbuildinfo 4 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "10.2.0-alpha.0", 3 | "npmClient": "yarn" 4 | } 5 | -------------------------------------------------------------------------------- /packages/path-parser/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig.json 3 | tsconfig.tsbuildinfo 4 | -------------------------------------------------------------------------------- /packages/luxon/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Vluxon.js'; 2 | export * from './luxon.js'; 3 | -------------------------------------------------------------------------------- /packages/diff/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Diff.js'; 2 | export * from './DiffNode.js' 3 | export * from './VersionInfo.js'; 4 | -------------------------------------------------------------------------------- /packages/path-parser/src/index.ts: -------------------------------------------------------------------------------- 1 | export { parsePath } from './parsePath.js'; 2 | export { parsePathMatcher } from './parsePathMatcher.js'; 3 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: 2 | layout: 'diff, flags, tree' 3 | behavior: default 4 | require_changes: false 5 | ignore: 6 | # Only exports 7 | - "packages/*/src/index.ts" 8 | -------------------------------------------------------------------------------- /packages/core/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { V } from './index.js'; 3 | 4 | test('index coverage', () => expect(V).toBeDefined()); 5 | -------------------------------------------------------------------------------- /packages/diff/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { Diff } from './index.js'; 3 | 4 | test('index coverage', () => expect(Diff).toBeDefined()); 5 | -------------------------------------------------------------------------------- /packages/path/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { Path } from './index.js'; 3 | 4 | test('index coverage', () => expect(Path).toBeDefined()); 5 | -------------------------------------------------------------------------------- /packages/path-parser/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { parsePath } from './index'; 3 | 4 | test('index coverage', () => expect(parsePath).toBeDefined()); 5 | -------------------------------------------------------------------------------- /packages/luxon/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { LuxonDateTime } from './index.js'; 3 | 4 | test('index coverage', () => expect(LuxonDateTime).toBeDefined()); 5 | -------------------------------------------------------------------------------- /packages/moment/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { MomentValidator } from './index.js'; 3 | 4 | test('index coverage', () => expect(MomentValidator).toBeDefined()); 5 | -------------------------------------------------------------------------------- /packages/path/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Path, PathComponent } from './Path.js'; 2 | export * from './PathMatcher.js'; 3 | export * from './matchers.js'; 4 | export * from './Projection.js'; 5 | export * from './jsonClone.js'; 6 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './V.js'; 2 | export * from './validators.js'; 3 | export * from './objectValidator.js'; 4 | export * from './objectValidatorBuilder.js'; 5 | export * from './typing.js'; 6 | export * from './schema.js'; 7 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "module": "es2022", 6 | "outDir": "dist/esm" 7 | }, 8 | "exclude": ["dist", "**/*.spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/diff/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "module": "es2022", 6 | "outDir": "dist/esm" 7 | }, 8 | "exclude": ["dist", "**/*.spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/luxon/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "module": "es2022", 6 | "outDir": "dist/esm" 7 | }, 8 | "exclude": ["dist", "**/*.spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/moment/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "module": "es2022", 6 | "outDir": "dist/esm" 7 | }, 8 | "exclude": ["dist", "**/*.spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/path/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "module": "es2022", 6 | "outDir": "dist/esm" 7 | }, 8 | "exclude": ["dist", "**/*.spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "module": "commonjs", 6 | "outDir": "dist/cjs" 7 | }, 8 | "exclude": ["dist", "**/*.spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/diff/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "module": "commonjs", 6 | "outDir": "dist/cjs" 7 | }, 8 | "exclude": ["dist", "**/*.spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/luxon/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "module": "commonjs", 6 | "outDir": "dist/cjs" 7 | }, 8 | "exclude": ["dist", "**/*.spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/moment/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "module": "commonjs", 6 | "outDir": "dist/cjs" 7 | }, 8 | "exclude": ["dist", "**/*.spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/path-parser/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "module": "es2022", 6 | "outDir": "dist/esm" 7 | }, 8 | "exclude": ["dist", "**/*.spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/path/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "module": "commonjs", 6 | "outDir": "dist/cjs" 7 | }, 8 | "exclude": ["dist", "**/*.spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/path-parser/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "module": "commonjs", 6 | "outDir": "dist/cjs" 7 | }, 8 | "exclude": ["dist", "**/*.spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | enabled: true, 7 | include: ['**/*.ts'], 8 | exclude: ['**/*.d.ts', '**/*.spec.ts', '**/*.test.ts', 'vitest.config.ts'], 9 | provider: 'v8', 10 | reporter: [['lcov'], ['text'], ['text-summary']], 11 | reportsDirectory: './coverage', 12 | }, 13 | setupFiles: [], 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /packages/path-parser/src/parsePath.ts: -------------------------------------------------------------------------------- 1 | import { Path } from '@finnair/path'; 2 | import nearley from 'nearley'; 3 | import pathGrammar from './pathGrammar.js'; 4 | 5 | export function parsePath(str: string): Path { 6 | const parser = new nearley.Parser(nearley.Grammar.fromCompiled(pathGrammar)); 7 | parser.feed(str); 8 | if (parser.results[0]) { 9 | return Path.of(...parser.results[0]); 10 | } else { 11 | throw new Error(`Unrecognized Path: ${str}`); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for more information: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | # https://containers.dev/guide/dependabot 6 | 7 | version: 2 8 | updates: 9 | - package-ecosystem: "devcontainers" 10 | directory: "/" 11 | schedule: 12 | interval: weekly 13 | -------------------------------------------------------------------------------- /packages/path-parser/src/parsePathMatcher.ts: -------------------------------------------------------------------------------- 1 | import nearley from 'nearley'; 2 | import matcherGrammar from './matcherGrammar.js'; 3 | import { PathMatcher } from '@finnair/path'; 4 | 5 | export function parsePathMatcher(str: string): PathMatcher { 6 | const parser = new nearley.Parser(nearley.Grammar.fromCompiled(matcherGrammar)); 7 | parser.feed(str); 8 | if (parser.results[0]) { 9 | return PathMatcher.of(...parser.results[0]); 10 | } else { 11 | throw new Error(`Unrecognized PathMatcher: ${str}`); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | if [[ -z "${GH_TOKEN}" ]]; then 5 | echo 'Error GH_TOKEN env variable missing' 6 | exit 1; 7 | fi 8 | 9 | echo 'npm login?' 10 | npm whoami 11 | 12 | set -v 13 | 14 | yarn clean 15 | yarn install 16 | yarn build 17 | yarn test --coverage 18 | npm config set access public 19 | npx lerna version ${1:-minor} --no-private --conventional-commits --force-publish --create-release github 20 | npx lerna publish from-package --no-private 21 | echo 'Check/update CHANGELOG.md files and draft a Github Release manually' 22 | -------------------------------------------------------------------------------- /.github/workflows/continuous_integration.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Controls when the action will run. Triggers the workflow on push or pull request 4 | # events but only for the master branch 5 | on: 6 | push: 7 | branches: [master] 8 | pull_request: 9 | branches: [master] 10 | 11 | jobs: 12 | build: 13 | runs-on: [ubuntu-latest] 14 | strategy: 15 | matrix: 16 | node: ['20', '22', '24'] 17 | name: Node ${{ matrix.node }} sample 18 | timeout-minutes: 5 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Setup node 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node }} 25 | cache: 'yarn' 26 | - run: yarn 27 | - run: yarn build 28 | - run: yarn test 29 | -------------------------------------------------------------------------------- /.github/workflows/code_coverage.yml: -------------------------------------------------------------------------------- 1 | name: Code Coverage 2 | 3 | # Controls when the action will run. Triggers the workflow on push or pull request 4 | # events but only for the master branch 5 | on: 6 | push: 7 | branches: [master] 8 | pull_request: 9 | branches: [master] 10 | 11 | jobs: 12 | build: 13 | runs-on: [ubuntu-latest] 14 | timeout-minutes: 5 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Setup node 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: '20' 21 | cache: 'yarn' 22 | - run: yarn 23 | - run: yarn build 24 | - run: yarn test --coverage 25 | - uses: codecov/codecov-action@v4 26 | with: 27 | token: ${{ secrets.CODECOV_TOKEN }} 28 | name: v-validation # optional 29 | fail_ci_if_error: true # optional (default = false) 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "ES2022", 5 | "composite": true, 6 | "strict": true, 7 | "resolveJsonModule": true, 8 | "noImplicitAny": true, 9 | "noImplicitReturns": true, 10 | "allowSyntheticDefaultImports": true, 11 | "downlevelIteration": true, 12 | "noUnusedLocals": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "esModuleInterop": true, 15 | "typeRoots": ["./node_modules/@types"], 16 | "lib": ["ES2022"], 17 | "rootDir": ".", 18 | "baseUrl": ".", 19 | "paths": { 20 | "@finnair/v-validation": ["packages/core/*"], 21 | "@finnair/v-validation-moment": ["packages/moment/*"], 22 | "@finnair/v-validation-luxon": ["packages/luxon/*"], 23 | "@finnair/path": ["packages/path/*"], 24 | "@finnair/path-parser": ["packages/path-parser/*"] 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/typescript-node:20-bullseye", 7 | "features": { 8 | "ghcr.io/devcontainers-contrib/features/lerna-npm:": {} 9 | }, 10 | // Features to add to the dev container. More info: https://containers.dev/features. 11 | // "features": {}, 12 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 13 | // "forwardPorts": [], 14 | // Use 'postCreateCommand' to run commands after the container is created. 15 | "postCreateCommand": "yarn install" 16 | // Configure tool-specific properties. 17 | // "customizations": {}, 18 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 19 | // "remoteUser": "root" 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Finnair Open Source 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/path-parser/src/parsePath.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { parsePath } from './parsePath.js'; 3 | import { Path } from '@finnair/path'; 4 | 5 | describe('parsePath', () => { 6 | test('root', () => expect(parsePath('$')).toEqual(Path.ROOT)); 7 | 8 | test('all components', () => 9 | expect(parsePath(`$.prop[123]["foo\\nbar\\\\"]['\\"quoted\\u00E4\\"']`)).toEqual(Path.of('prop', 123, 'foo\nbar\\', '"quotedä"'))); 10 | 11 | test('unicode escaped single quote', () => expect(Array.from(parsePath(`$['\\u0027']`))).toEqual(["'"])); 12 | 13 | test.each([ 14 | '', 15 | `$['single'quote']`, 16 | `$['single\'quote']`, 17 | '$.$', 18 | '$.white space', 19 | '$[0,1]', 20 | '$[0.1]', 21 | '$[foo', 22 | 'foo', 23 | '$"misquoted\'', 24 | '$[ "whitespace" ]', 25 | ])(`"%s" is not valid path`, path => expect(() => parsePath(path)).toThrow()); 26 | 27 | test('documentation examples', () => { 28 | expect(parsePath(`$['\\u0027']`)).toEqual(Path.of("'")); 29 | expect(Array.from(parsePath(`$.array[1]["\\"property\\" with spaces and 'quotes'"]`))).toEqual(['array', 1, `"property" with spaces and 'quotes'`]); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "repository": "github:finnair/v-validation", 4 | "type": "module", 5 | "scripts": { 6 | "build": "lerna run build", 7 | "test": "vitest --run", 8 | "clean": "yarn clean:dist && yarn clean:modules && yarn clean:tscache", 9 | "clean:dist": "find . -type f -path '*/dist/*' -delete && find . -type d -name dist -empty -delete", 10 | "clean:modules": "find . -type f -path '*/node_modules/*' -delete && find . -type d -name node_modules -empty -delete", 11 | "clean:tscache": "find . -type f -name 'tsconfig.tsbuildinfo' -delete" 12 | }, 13 | "devDependencies": { 14 | "@types/luxon": "3.7.1", 15 | "@types/node": "20.19.4", 16 | "@vitest/coverage-v8": "3.2.4", 17 | "lerna": "8.2.3", 18 | "luxon": "3.7.2", 19 | "moment": "2.30.1", 20 | "prettier": "3.6.2", 21 | "ts-node": "10.9.2", 22 | "typescript": "5.9.2", 23 | "vitest": "3.2.4" 24 | }, 25 | "workspaces": { 26 | "packages": [ 27 | "packages/*" 28 | ] 29 | }, 30 | "prettier": { 31 | "trailingComma": "all", 32 | "tabWidth": 2, 33 | "printWidth": 160, 34 | "singleQuote": true, 35 | "arrowParens": "avoid" 36 | }, 37 | "engines": { 38 | "node": ">= 20" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/path/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@finnair/path", 3 | "version": "10.2.0-alpha.0", 4 | "private": false, 5 | "description": "Simple object path as array of strings and numbers", 6 | "main": "./dist/cjs/index.js", 7 | "module": "./dist/esm/index.js", 8 | "types": "./dist/cjs/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "require": { 12 | "types": "./dist/cjs/index.d.ts", 13 | "default": "./dist/cjs/index.js" 14 | }, 15 | "import": { 16 | "types": "./dist/esm/index.d.ts", 17 | "default": "./dist/esm/index.js" 18 | } 19 | }, 20 | "./package.json": "./package.json" 21 | }, 22 | "license": "MIT", 23 | "homepage": "https://github.com/finnair/v-validation/tree/master/packages/path#readme", 24 | "bugs": "https://github.com/finnair/v-validation/issues", 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/finnair/v-validation.git", 28 | "directory": "packages/path" 29 | }, 30 | "keywords": [ 31 | "json", 32 | "path", 33 | "matcher", 34 | "JsonPath" 35 | ], 36 | "scripts": { 37 | "build": "yarn build:cjs && yarn build:esm", 38 | "build:cjs": "tsc -b tsconfig.cjs.json && cp ../../package.cjs.json dist/cjs/package.json", 39 | "build:esm": "tsc -b . && cp ../../package.esm.json dist/esm/package.json" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/path-parser/src/pathGrammar.ne: -------------------------------------------------------------------------------- 1 | # WARNING! This is the original grammar, but the result has been modified for typescript and ESM! 2 | # Dead code fix RegExp replace: \(lexer\.has\("[a-zA-Z]+"\) \? \{type: "([a-zA-Z]+)"\} : [a-zA-Z]+\) => {type: "$1"} 3 | # Unused id-function coverage: /* v8 ignore next */ 4 | 5 | # Usage: https://nearley.js.org/ 6 | @preprocessor typescript 7 | @{% 8 | import moo from 'moo'; 9 | 10 | const lexer = moo.compile({ 11 | qString: /'(?:\\["bfnrt\/\\]|\\u[a-fA-F0-9]{4}|[^"\\])*?'/, // single quoted string 12 | qqString: /"(?:\\["bfnrt\/\\]|\\u[a-fA-F0-9]{4}|[^"\\])*?"/, // double quoted string 13 | integer: /[0-9]+/, 14 | property: /[a-zA-Z_][a-zA-Z0-9_]*/, 15 | '$': '$', 16 | '[': '[', 17 | ']': ']', 18 | '.': '.', 19 | }); 20 | 21 | function handleQString(qString: string) { 22 | return JSON.parse('"' + qString.substring(1, qString.length-1) + '"'); 23 | } 24 | %} 25 | 26 | @lexer lexer 27 | 28 | Path -> "$" PathExpression:* {% d => d[1].reduce((result: (string | number)[], component: string | number) => result.concat(component), []) %} 29 | 30 | PathExpression -> "." PropertyExpression {% d => d[1] %} 31 | | "[" IndexExpression "]" {% d => d[1] %} 32 | 33 | PropertyExpression -> %property {% d => d[0].value %} 34 | 35 | IndexExpression -> %integer {% d => parseInt(d[0].value) %} 36 | | %qqString {% d => JSON.parse(d[0].value) %} 37 | | %qString {% d => handleQString(d[0].value) %} 38 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | - All commits must be signed-off 4 | - Please follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) guidelines 5 | 6 | ## Requirements 7 | 8 | - node (tested with 12.x, 14.x, 16.x, and 18.x) 9 | - typescript (tested with 4.5.x) 10 | - yarn (tested with 1.22.10) 11 | - lerna (tested with 8.1.2) 12 | - jest (tested with 27.4.7) 13 | 14 | ## Commands 15 | 16 | 1. Install depencencies with Lerna v7+ (automatic in DevContainer) 17 | 18 | ```shell 19 | yarn install 20 | ``` 21 | 22 | 2. Build with 23 | 24 | ```shell 25 | yarn build 26 | ``` 27 | 28 | 3. Test with 29 | 30 | ```shell 31 | yarn test 32 | # or with coverage 33 | jest --coverage 34 | ``` 35 | 36 | 4. Clean dependencies and built files 37 | 38 | ```shell 39 | yarn clean 40 | ``` 41 | 42 | ## Publishing 43 | 44 | Make sure that git remote origin points to `git@github.com:finnair/v-validation.git`. 45 | 46 | Publishing uses [@lerna/version](https://github.com/lerna/lerna/tree/master/commands/version) and [@lerna/publish](https://github.com/lerna/lerna/tree/master/commands/publish). 47 | 48 | Publishing requires npm credentials with access to `finnair` organization and `GH_TOKEN` environment variable, i.e. your GitHub authentication token for `public_repo` scope (under Settings > Developer settings > Personal access tokens). 49 | 50 | ```shell 51 | ./publish.sh [patch|minor|major] 52 | ``` 53 | 54 | After publishing to NPM, check/update CHANGELOG.md files and draft a Github Release manually. 55 | -------------------------------------------------------------------------------- /packages/core/src/typing.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Match both optional and "x | undefined" valued keys. 3 | */ 4 | export type OptionalKeys = Exclude<{ 5 | [K in keyof T]: undefined extends T[K] ? K : never; 6 | }[keyof T], undefined>; 7 | 8 | type OptionalProperties = Pick>; 9 | type RequiredProperties = Omit>; 10 | 11 | type Optional = { 'Optional': T }; 12 | 13 | /** 14 | * Wrap optional/undefined properties with required Optional as TS only requires 15 | * that required properties match for types to be compatible. 16 | */ 17 | type ComparableOptional = T extends object 18 | ? { [K in keyof T]-?: Optional> } 19 | : T; 20 | 21 | type ComparableRequired = T extends object 22 | ? { [K in keyof T]: ComparableType } 23 | : T; 24 | 25 | 26 | export type UndefinedAsOptionalProperties = RequiredProperties & Partial>; 27 | 28 | /** 29 | * Wrap all optional/undefined properties with Optional recursively for strict type check 30 | */ 31 | export type ComparableType = T extends object 32 | ? ComparableRequired> & ComparableOptional> 33 | : T; 34 | 35 | /** 36 | * Verify mutual extensibility. When using this with both types wrapped in`ComparableType`, you get 37 | * proper errors about all differences in `Inferred` and `Custom` types. 38 | */ 39 | export type EqualTypes = true; 40 | 41 | export const assertType = (_: Type): void => void 0; 42 | -------------------------------------------------------------------------------- /packages/diff/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@finnair/diff", 3 | "version": "10.2.0-alpha.0", 4 | "private": false, 5 | "description": "Object Diff Based on Paths And Valudes of an Object", 6 | "main": "./dist/cjs/index.js", 7 | "module": "./dist/esm/index.js", 8 | "types": "./dist/cjs/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "require": { 12 | "types": "./dist/cjs/index.d.ts", 13 | "default": "./dist/cjs/index.js" 14 | }, 15 | "import": { 16 | "types": "./dist/esm/index.d.ts", 17 | "default": "./dist/esm/index.js" 18 | } 19 | }, 20 | "./package.json": "./package.json" 21 | }, 22 | "license": "MIT", 23 | "homepage": "https://github.com/finnair/v-validation/tree/master/packages/diff#readme", 24 | "bugs": "https://github.com/finnair/v-validation/issues", 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/finnair/v-validation.git", 28 | "directory": "packages/diff" 29 | }, 30 | "keywords": [ 31 | "diff", 32 | "versioning", 33 | "patching" 34 | ], 35 | "scripts": { 36 | "build": "yarn build:cjs && yarn build:esm", 37 | "build:cjs": "tsc -b tsconfig.cjs.json && cp ../../package.cjs.json dist/cjs/package.json", 38 | "build:esm": "tsc -b . && cp ../../package.esm.json dist/esm/package.json" 39 | }, 40 | "peerDependencies": { 41 | "@finnair/path": ">=7", 42 | "@finnair/path-parser": ">=7" 43 | }, 44 | "devDependencies": { 45 | "@finnair/path": "^10.2.0-alpha.0", 46 | "@finnair/path-parser": "^10.2.0-alpha.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Test All", 8 | "program": "${workspaceFolder}/node_modules/.bin/vitest", 9 | "args": ["--run", "--pool=forks"], 10 | "console": "integratedTerminal", 11 | "internalConsoleOptions": "neverOpen", 12 | "timeout": 20000 13 | }, 14 | { 15 | "type": "node", 16 | "request": "launch", 17 | "name": "Test Current", 18 | "program": "${workspaceFolder}/node_modules/.bin/vitest", 19 | "args": ["--run", "--pool=forks", "${relativeFile}"], 20 | "console": "integratedTerminal", 21 | "internalConsoleOptions": "neverOpen", 22 | "timeout": 20000 23 | }, 24 | { 25 | "type": "node", 26 | "request": "launch", 27 | "name": "Watch Current", 28 | "program": "${workspaceFolder}/node_modules/.bin/vitest", 29 | "args": ["--pool=forks", "${relativeFile}"], 30 | "console": "integratedTerminal", 31 | "internalConsoleOptions": "neverOpen", 32 | "timeout": 20000 33 | }, 34 | { 35 | "name": "Run Current File", 36 | "type": "node", 37 | "request": "launch", 38 | "runtimeExecutable": "node", 39 | "runtimeArgs": ["--nolazy", "--loader", "ts-node/esm", "-r", "${workspaceFolder}/node_modules/ts-node/register/transpile-only"], 40 | "args": ["${relativeFile}"], 41 | "cwd": "${workspaceRoot}", 42 | "internalConsoleOptions": "openOnSessionStart", 43 | "skipFiles": ["/**", "node_modules/**"] 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@finnair/v-validation", 3 | "version": "10.2.0-alpha.0", 4 | "private": false, 5 | "description": "V-validation core package", 6 | "main": "./dist/cjs/index.js", 7 | "module": "./dist/esm/index.js", 8 | "types": "./dist/cjs/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "require": { 12 | "types": "./dist/cjs/index.d.ts", 13 | "default": "./dist/cjs/index.js" 14 | }, 15 | "import": { 16 | "types": "./dist/esm/index.d.ts", 17 | "default": "./dist/esm/index.js" 18 | } 19 | }, 20 | "./package.json": "./package.json" 21 | }, 22 | "license": "MIT", 23 | "homepage": "https://github.com/finnair/v-validation/tree/master/packages/core#readme", 24 | "bugs": "https://github.com/finnair/v-validation/issues", 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/finnair/v-validation.git", 28 | "directory": "packages/core" 29 | }, 30 | "keywords": [ 31 | "validation", 32 | "validate", 33 | "convert", 34 | "normalize", 35 | "typescript" 36 | ], 37 | "scripts": { 38 | "build": "yarn build:cjs && yarn build:esm", 39 | "build:cjs": "tsc -b tsconfig.cjs.json && cp ../../package.cjs.json dist/cjs/package.json", 40 | "build:esm": "tsc -b . && cp ../../package.esm.json dist/esm/package.json" 41 | }, 42 | "dependencies": { 43 | "@types/uuid": "10.0.0", 44 | "fast-deep-equal": "3.1.3", 45 | "uuid": "10.0.0" 46 | }, 47 | "peerDependencies": { 48 | "@finnair/path": ">=7" 49 | }, 50 | "devDependencies": { 51 | "@finnair/path": "^10.2.0-alpha.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/core/src/testUtil.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest' 2 | import { Validator, Violation, ValidationResult, ValidatorOptions, ValidationContext, violationsOf } from './validators.js'; 3 | import { Path } from '@finnair/path'; 4 | import { fail } from 'assert'; 5 | 6 | export async function expectViolations(value: In, validator: Validator, ...violations: Violation[]) { 7 | await validator.validatePath(value, Path.ROOT, new ValidationContext({})).then( 8 | success => { 9 | fail(`expected violations, got ${success}`) 10 | }, 11 | fail => { 12 | expect(violationsOf(fail)).toEqual(violations); 13 | } 14 | ) 15 | } 16 | 17 | export async function expectValid(value: In, validator: Validator, convertedValue?: Out, ctx?: ValidatorOptions) { 18 | const result = await validator.validate(value, ctx); 19 | return verifyValid(result, value, convertedValue); 20 | } 21 | 22 | export async function expectUndefined(value: any, validator: Validator, convertedValue?: any, ctx?: ValidatorOptions) { 23 | const result = await validator.validate(value, ctx); 24 | expect(result.isSuccess()).toBe(true); 25 | expect(result.getValue()).toBeUndefined(); 26 | } 27 | 28 | export function verifyValid(result: ValidationResult, value: any, convertedValue?: Out) { 29 | expect(result.getViolations()).toEqual([]); 30 | if (convertedValue !== undefined) { 31 | expect(result.getValue()).toEqual(convertedValue); 32 | } else { 33 | expect(result.getValue()).toEqual(value); 34 | } 35 | return result.getValue(); 36 | } 37 | 38 | test.skip('do not fail build because of no tests found', () => { }); 39 | -------------------------------------------------------------------------------- /packages/luxon/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@finnair/v-validation-luxon", 3 | "version": "10.2.0-alpha.0", 4 | "private": false, 5 | "description": "Luxon validators", 6 | "main": "./dist/cjs/index.js", 7 | "module": "./dist/esm/index.js", 8 | "types": "./dist/cjs/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "require": { 12 | "types": "./dist/cjs/index.d.ts", 13 | "default": "./dist/cjs/index.js" 14 | }, 15 | "import": { 16 | "types": "./dist/esm/index.d.ts", 17 | "default": "./dist/esm/index.js" 18 | } 19 | }, 20 | "./package.json": "./package.json" 21 | }, 22 | "license": "MIT", 23 | "homepage": "https://github.com/finnair/v-validation/tree/master/packages/luxon#readme", 24 | "bugs": "https://github.com/finnair/v-validation/issues", 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/finnair/v-validation.git", 28 | "directory": "packages/luxon" 29 | }, 30 | "keywords": [ 31 | "validation", 32 | "validate", 33 | "convert", 34 | "normalize", 35 | "typescript", 36 | "luxon" 37 | ], 38 | "scripts": { 39 | "build": "yarn build:cjs && yarn build:esm", 40 | "build:cjs": "tsc -b tsconfig.cjs.json && cp ../../package.cjs.json dist/cjs/package.json", 41 | "build:esm": "tsc -b . && cp ../../package.esm.json dist/esm/package.json" 42 | }, 43 | "peerDependencies": { 44 | "@finnair/path": ">=7", 45 | "@finnair/v-validation": ">=7", 46 | "luxon": "^3.4.4" 47 | }, 48 | "devDependencies": { 49 | "@finnair/path": "^10.2.0-alpha.0", 50 | "@finnair/v-validation": "^10.2.0-alpha.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/moment/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@finnair/v-validation-moment", 3 | "version": "10.2.0-alpha.0", 4 | "private": false, 5 | "description": "Moment validators", 6 | "main": "./dist/cjs/index.js", 7 | "module": "./dist/esm/index.js", 8 | "types": "./dist/cjs/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "require": { 12 | "types": "./dist/cjs/index.d.ts", 13 | "default": "./dist/cjs/index.js" 14 | }, 15 | "import": { 16 | "types": "./dist/esm/index.d.ts", 17 | "default": "./dist/esm/index.js" 18 | } 19 | }, 20 | "./package.json": "./package.json" 21 | }, 22 | "license": "MIT", 23 | "homepage": "https://github.com/finnair/v-validation/tree/master/packages/moment#readme", 24 | "bugs": "https://github.com/finnair/v-validation/issues", 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/finnair/v-validation.git", 28 | "directory": "packages/moment" 29 | }, 30 | "keywords": [ 31 | "validation", 32 | "validate", 33 | "convert", 34 | "normalize", 35 | "typescript", 36 | "moment" 37 | ], 38 | "scripts": { 39 | "build": "yarn build:cjs && yarn build:esm", 40 | "build:cjs": "tsc -b tsconfig.cjs.json && cp ../../package.cjs.json dist/cjs/package.json", 41 | "build:esm": "tsc -b . && cp ../../package.esm.json dist/esm/package.json" 42 | }, 43 | "peerDependencies": { 44 | "@finnair/path": ">=7", 45 | "@finnair/v-validation": ">=7", 46 | "moment": "^2.30.0" 47 | }, 48 | "devDependencies": { 49 | "@finnair/path": "^10.2.0-alpha.0", 50 | "@finnair/v-validation": "^10.2.0-alpha.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/path-parser/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@finnair/path-parser", 3 | "version": "10.2.0-alpha.0", 4 | "private": false, 5 | "description": "Simple object path as array of strings and numbers", 6 | "main": "./dist/cjs/index.js", 7 | "module": "./dist/esm/index.js", 8 | "types": "./dist/cjs/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "require": { 12 | "types": "./dist/cjs/index.d.ts", 13 | "default": "./dist/cjs/index.js" 14 | }, 15 | "import": { 16 | "types": "./dist/esm/index.d.ts", 17 | "default": "./dist/esm/index.js" 18 | } 19 | }, 20 | "./package.json": "./package.json" 21 | }, 22 | "license": "MIT", 23 | "homepage": "https://github.com/finnair/v-validation/tree/master/packages/path-matcher-parser#readme", 24 | "bugs": "https://github.com/finnair/v-validation/issues", 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/finnair/v-validation.git", 28 | "directory": "packages/path-parser" 29 | }, 30 | "keywords": [ 31 | "json", 32 | "path", 33 | "JsonPath", 34 | "parser" 35 | ], 36 | "scripts": { 37 | "build": "yarn build:cjs && yarn build:esm", 38 | "build:cjs": "tsc -b tsconfig.cjs.json && cp ../../package.cjs.json dist/cjs/package.json", 39 | "build:esm": "tsc -b . && cp ../../package.esm.json dist/esm/package.json" 40 | }, 41 | "dependencies": { 42 | "@types/moo": "0.5.10", 43 | "@types/nearley": "2.11.5", 44 | "nearley": "2.20.1" 45 | }, 46 | "peerDependencies": { 47 | "@finnair/path": ">=7" 48 | }, 49 | "devDependencies": { 50 | "@finnair/path": "^10.2.0-alpha.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/path-parser/src/parsePathMatcher.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { PathMatcher, AnyIndex, AnyProperty, UnionMatcher } from '@finnair/path'; 3 | import { parsePathMatcher } from './parsePathMatcher.js'; 4 | 5 | const basicExpressions = `$.prop[123][*].*["foo\\nbar\\\\"]['\\"quoted\\u00E4\\"']`; 6 | 7 | describe('parsePathMatcher', () => { 8 | describe('parse', () => { 9 | test('parse $', () => expect(parsePathMatcher('$')).toEqual(PathMatcher.of())); 10 | 11 | test('parse all cases', () => 12 | expect(parsePathMatcher(basicExpressions)).toEqual(PathMatcher.of('prop', 123, AnyIndex, AnyProperty, 'foo\nbar\\', '"quotedä"'))); 13 | 14 | test('parse union of all expression types', () => 15 | expect(parsePathMatcher(`$[identifier,"double quoted",'single quoted',123]`)).toEqual( 16 | PathMatcher.of(new UnionMatcher(['identifier', 'double quoted', 'single quoted', 123])), 17 | )); 18 | 19 | test('parse union of two expression', () => expect(parsePathMatcher(`$[123,456]`)).toEqual(PathMatcher.of(new UnionMatcher([123, 456])))); 20 | }); 21 | 22 | describe('toJSON', () => { 23 | test('toString returns original string', () => { 24 | expect(parsePathMatcher(basicExpressions).toJSON()).toEqual(`$.prop[123][*].*["foo\\nbar\\\\"]["\\"quotedä\\""]`); 25 | }); 26 | }); 27 | 28 | test.each(['', '$.$', '$.white space', '$[0.1]', '$[foo', 'foo', '$"misquoted\'', '$[\n"illegal whitespace"\n]'])(`"%s" is not valid path`, path => 29 | expect(() => parsePathMatcher(path)).toThrow(), 30 | ); 31 | 32 | test('documentation example', () => 33 | expect(parsePathMatcher(`$.array[0][*].*['union',"of",properties,1]`)).toEqual( 34 | PathMatcher.of('array', 0, AnyIndex, AnyProperty, new UnionMatcher(['union', 'of', 'properties', 1])), 35 | )); 36 | 37 | test('union matcher allows whitespace', () => 38 | expect(parsePathMatcher(`$.array[0][*].*[ 'union', "of", properties, 1 ]`)).toEqual( 39 | PathMatcher.of('array', 0, AnyIndex, AnyProperty, new UnionMatcher(['union', 'of', 'properties', 1])), 40 | )); 41 | }); 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /packages/path-parser/src/matcherGrammar.ne: -------------------------------------------------------------------------------- 1 | # WARNING! This is the original grammar, but the result has been modified for typescript and ESM! 2 | # Dead code fix RegExp replace: \(lexer\.has\("[a-zA-Z]+"\) \? \{type: "([a-zA-Z]+)"\} : [a-zA-Z]+\) => {type: "$1"} 3 | # Unused id-function coverage: /* v8 ignore next */ 4 | 5 | # Usage: https://nearley.js.org/ 6 | @preprocessor typescript 7 | @{% 8 | import * as matchers from '@finnair/path'; 9 | import moo from 'moo'; 10 | 11 | const lexer = moo.compile({ 12 | qString: /'(?:\\["bfnrt\/\\]|\\u[a-fA-F0-9]{4}|[^"\\])*?'/, // single quoted string 13 | qqString: /"(?:\\["bfnrt\/\\]|\\u[a-fA-F0-9]{4}|[^"\\])*?"/, // double quoted string 14 | integer: /[0-9]+/, 15 | property: /[a-zA-Z_][a-zA-Z0-9_]*/, 16 | comma: /, ?/, 17 | lbracket: /\[ ?/, 18 | rbracket: / ?]/, 19 | '*': '*', 20 | '$': '$', 21 | '.': '.', 22 | }); 23 | 24 | function handleQString(qString: string) { 25 | return JSON.parse('"' + qString.substring(1, qString.length-1) + '"'); 26 | } 27 | %} 28 | 29 | @lexer lexer 30 | 31 | Path -> "$" PathExpression:* {% d => d[1].reduce((result: any[], matcher: any) => result.concat(matcher), []) %} 32 | 33 | PathExpression -> "." PropertyExpression {% d => d[1] %} 34 | | %lbracket IndexExpression %rbracket {% d => d[1] %} 35 | | %lbracket UnionExpression %rbracket {% d => new matchers.UnionMatcher(d[1]) %} 36 | 37 | PropertyExpression -> "*" {% d => matchers.AnyProperty %} 38 | | %property {% d => new matchers.PropertyMatcher(d[0].value) %} 39 | 40 | IndexExpression -> %integer {% d => new matchers.IndexMatcher(parseInt(d[0].value)) %} 41 | | %qqString {% d => new matchers.PropertyMatcher(JSON.parse(d[0].value)) %} 42 | | %qString {% d => new matchers.PropertyMatcher(handleQString(d[0].value)) %} 43 | | "*" {% d => matchers.AnyIndex %} 44 | 45 | UnionExpression -> ComponentExpression %comma ComponentExpression {% d => [d[0], d[2]] %} 46 | | ComponentExpression %comma UnionExpression {% d => [ d[0], ...d[2]] %} 47 | 48 | ComponentExpression -> %integer {% d => parseInt(d[0].value) %} 49 | | %property {% d => d[0].value %} 50 | | %qqString {% d => JSON.parse(d[0].value) %} 51 | | %qString {% d => handleQString(d[0].value) %} 52 | -------------------------------------------------------------------------------- /packages/moment/README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://badge.fury.io/js/%40finnair%2Fv-validation-moment.svg)](https://badge.fury.io/js/%40finnair%2Fv-validation-moment) 2 | 3 | # v-validation-moment 4 | 5 | `@finnair/v-validation-moment` is an extension to `@finnair/v-validation`. 6 | 7 | `Vmoment` extension uses custom Moment extensions to support full JSON roundtrip with strict validation. 8 | 9 | [Documentation for `v-validation`](https://github.com/finnair/v-validation). 10 | 11 | ## Getting Started 12 | 13 | Install v-validation using [`yarn`](https://yarnpkg.com): 14 | 15 | ```bash 16 | yarn add @finnair/v-validation-moment 17 | ``` 18 | 19 | Or [`npm`](https://www.npmjs.com/): 20 | 21 | ```bash 22 | npm install @finnair/v-validation-moment 23 | ``` 24 | 25 | ## Vmoment 26 | 27 | `MomentValidator` can be used to build custom Moment validators/converters by supplying a parse function. However, Moment instances always serialize to JSON 28 | in full date-time format. `V` supports Moment extensions that requires an exact input format and also serialize to JSON using that same format. 29 | 30 | Time zone 00:00 is serialized as `Z`. 31 | 32 | | Vmoment. | Format | Description | 33 | | ----------------- | -------------------------- | ------------------------------------------------------- | 34 | | date | `YYYY-MM-DD` | Local date. | 35 | | dateUtc | `YYYY-MM-DD` | Date in UTC time zone. | 36 | | dateTime | `YYYY-MM-DDTHH:mm:ssZ` | Date and time in local (parsed) time zone. | 37 | | dateTimeUtc | `YYYY-MM-DDTHH:mm:ssZ` | Date and time in UTC time zone. | 38 | | dateTimeMillis | `YYYY-MM-DDTHH:mm:ss.SSSZ` |  Date and time with millis in local (parsed) time zone. | 39 | | dateTimeMillisUtc | `YYYY-MM-DDTHH:mm:ss.SSSZ` |  Date and time with millis in UTC time zone. | 40 | | time | `HH:mm:ss` | Local time. | 41 | | duration | ISO 8601 Duration | `moment.duration` with pattern validation. | 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![CI](https://github.com/finnair/v-validation/workflows/CI/badge.svg?branch=master) 2 | [![codecov](https://codecov.io/gh/finnair/v-validation/branch/master/graph/badge.svg)](https://codecov.io/gh/finnair/v-validation) 3 | [![npm version](https://badge.fury.io/js/%40finnair%2Fv-validation.svg)](https://badge.fury.io/js/%40finnair%2Fv-validation) 4 | 5 | # Packages 6 | 7 | ## V 8 | 9 | V stands for Validation. 10 | 11 | `V` rules define how input is to be converted, normalized and validated to 12 | conform to the expected model. 13 | 14 | [`@finnair/v-validation` README](./packages/core/README.md) 15 | 16 | ## Vluxon 17 | 18 | `Vluxon` contains `v-validation` extensions for [Luxon](https://moment.github.io/luxon/). 19 | 20 | [`@finnair/v-validation-luxon` README](./packages/luxon/README.md) 21 | 22 | ## Vmoment 23 | 24 | NOTE: [Moment is a legacy project in maintenance mode](https://momentjs.com/docs/#/-project-status/). 25 | 26 | `Vmoment` contains `v-validation` extensions for [Moment.js](https://momentjs.com/). 27 | 28 | [`@finnair/v-validation-moment` README](./packages/moment/README.md) 29 | 30 | ## Path 31 | 32 | `@finnair/path` contains partly `JsonPath` compatible path utilities: 33 | 34 | - `Path` - concrete JSON paths used to locate, read or write a of an object. 35 | - `PathMatcher` - a JsonPath like query processor. 36 | - `Projection` - PathMatcher based include/exclude mapper for providing partial results from e.g. an API. 37 | 38 | [`@finnair/path` README](./packages/path/README.md) 39 | 40 | ## Path Parsers 41 | 42 | `@finnair/path-parser` contains [nearley.js](https://nearley.js.org/) based parsers for `Path` and `PathMatcher`. 43 | 44 | [`@finnair/path-parser` README](./packages/path-parser/README.md) 45 | 46 | # Getting Started 47 | 48 | Install desired packages using [`yarn`](https://yarnpkg.com/en/package/jest): 49 | 50 | ```bash 51 | yarn add @finnair/v-validation 52 | yarn add @finnair/v-validation-moment 53 | yarn add @finnair/path 54 | yarn add @finnair/path-parser 55 | ``` 56 | 57 | Or [`npm`](https://www.npmjs.com/): 58 | 59 | ```bash 60 | npm install @finnair/v-validation 61 | npm install @finnair/v-validation-moment 62 | npm install @finnair/path 63 | npm install @finnair/path-parser 64 | ``` 65 | 66 | ## Development 67 | 68 | See [Contributing Guildelines](./.github/CONTRIBUTING.md). 69 | -------------------------------------------------------------------------------- /packages/diff/src/Diff.ts: -------------------------------------------------------------------------------- 1 | import { Node } from '@finnair/path'; 2 | import { arrayOrPlainObject, Change, DiffNode, DiffNodeConfig } from './DiffNode'; 3 | 4 | export interface DiffConfig extends DiffNodeConfig { 5 | readonly includeObjects?: boolean; 6 | } 7 | 8 | export class Diff { 9 | constructor(public readonly config?: DiffConfig) {} 10 | 11 | allPaths(value: any) { 12 | return Diff.allPaths(value, this.config); 13 | } 14 | 15 | changedPaths(oldValue: T, newValue: T) { 16 | return Diff.changedPaths(oldValue, newValue, this.config); 17 | } 18 | 19 | changeset(oldValue: T, newValue: T): Map { 20 | return Diff.changeset(oldValue, newValue, this.config); 21 | } 22 | 23 | pathsAndValues(value: any): Map { 24 | return Diff.pathsAndValues(value, this.config); 25 | } 26 | 27 | static allPaths(value: any, config?: DiffConfig) { 28 | return Diff.changedPaths(getBaseValue(value), value, config); 29 | } 30 | static changedPaths(oldValue: T, newValue: T, config?: DiffConfig) { 31 | const paths = new Set(); 32 | const diffNode = new DiffNode({ oldValue, newValue }, config); 33 | for (const path of diffNode.getChangedPaths(config?.includeObjects)) { 34 | paths.add(path.toJSON()); 35 | } 36 | return paths; 37 | } 38 | 39 | static changeset(oldValue: T, newValue: T, config?: DiffConfig): Map { 40 | const changeset = new Map(); 41 | const diffNode = new DiffNode({ oldValue, newValue }, config); 42 | for (const change of diffNode.getScalarChanges(config?.includeObjects)) { 43 | changeset.set(change.path.toJSON(), change); 44 | } 45 | return changeset; 46 | } 47 | 48 | static pathsAndValues(value: any, config?: DiffConfig): Map { 49 | const map = new Map(); 50 | const diffNode = new DiffNode({ newValue: value }, config); 51 | for (const change of diffNode.getScalarChanges(config?.includeObjects)) { 52 | map.set(change.path.toJSON(), { path: change.path, value: change.newValue }); 53 | } 54 | return map; 55 | } 56 | } 57 | 58 | function getBaseValue(value: any) { 59 | switch (arrayOrPlainObject(value)) { 60 | case 'object': return {}; 61 | case 'array': return []; 62 | default: return undefined; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/path/src/jsonClone.ts: -------------------------------------------------------------------------------- 1 | export type JsonReplacer = ((this: any, key: string, value: any) => any) | (number | string)[] | null; 2 | 3 | export type JsonValue = string | boolean | number | JsonValue[] | null | JsonObject; 4 | 5 | export type JsonObject = { 6 | [key: string]: JsonValue; 7 | } 8 | 9 | export function jsonClone(input: any, replacer?: JsonReplacer): JsonValue { 10 | return _jsonClone('', { '': input }, replacer); 11 | } 12 | 13 | function _jsonClone(key: string, holder: any, replacer?: JsonReplacer) { 14 | const value = _replaceValue(key, holder, replacer); 15 | if (value && typeof value === 'object') { 16 | let clone: JsonValue; 17 | if (Array.isArray(value)) { 18 | clone = []; 19 | for (let i=0; i < value.length; i++) { 20 | clone[i] = _jsonClone(i.toString(), value, replacer) ?? null; 21 | } 22 | } else { 23 | clone = {}; 24 | if (Array.isArray(replacer)) { 25 | const len = replacer.length; 26 | for (let i=0; i < len; i++) { 27 | const nestedKey = replacer[i].toString(); 28 | const keyValue = _jsonClone(nestedKey, value, replacer); 29 | // undefined is not included in the result 30 | if (keyValue !== undefined) { 31 | clone[nestedKey] = keyValue; 32 | } 33 | } 34 | } else { 35 | for (const nestedKey in value) { 36 | const keyValue = _jsonClone(nestedKey, value, replacer); 37 | // undefined is not included in the result 38 | if (keyValue !== undefined) { 39 | clone[nestedKey] = keyValue; 40 | } 41 | } 42 | } 43 | } 44 | return clone; 45 | } else { 46 | switch (typeof value) { 47 | // ignore function and symbol 48 | case 'function': 49 | case 'symbol': 50 | return undefined; 51 | // BigInt is not supported by JSON.stringify 52 | case 'bigint': 53 | throw new TypeError("BigInt value can't be serialized in JSON"); 54 | default: 55 | return value; 56 | } 57 | } 58 | } 59 | 60 | function _replaceValue(key: string, holder: any, replacer?: JsonReplacer) { 61 | let value = holder[key]; 62 | if (typeof value?.toJSON === 'function') { 63 | value = value.toJSON(key); 64 | } 65 | if (typeof replacer === 'function') { 66 | value = replacer.call(holder, key, value); 67 | } 68 | return value; 69 | } 70 | -------------------------------------------------------------------------------- /packages/path-parser/README.md: -------------------------------------------------------------------------------- 1 | # Path Parser 2 | 3 | `@finnair/path-parser` contains [nearley.js](https://nearley.js.org/) based parsers for `Path` and `PathMatcher`. 4 | 5 | See [`@finnair/path`](../path/README.md) or instructions on how to use `Path` and `PathMatcher`. 6 | 7 | ## Getting Started 8 | 9 | Install v-validation using [`yarn`](https://yarnpkg.com/en/package/jest): 10 | 11 | ```bash 12 | yarn add @finnair/path-parser 13 | ``` 14 | 15 | Or [`npm`](https://www.npmjs.com/): 16 | 17 | ```bash 18 | npm install @finnair/path-parser 19 | ``` 20 | 21 | ## Parsing Paths 22 | 23 | ```typescript 24 | import { parsePath } from '@finnair/path-parser'; 25 | 26 | parsePath(`$.array[1]["\\"property\\" with spaces and 'quotes'"]`); // JSON string encoded in brackets! 27 | // Path.of('array', 1, `"property" with spaces and 'quotes'`); 28 | 29 | // Single quotes also work, but encoding is still JSON string so the value cannot contain ' character 30 | parsePath(`$['single quotes']`); // Path.of('single quotes') 31 | parsePath(`$['single\'quote']`); // Fail! 32 | 33 | // ...without using unicode escape 34 | parsePath(`$['\\u0027']`); // Path.of("'"); 35 | ``` 36 | 37 | ## Parsing PathMatchers 38 | 39 | `parsePathMatcher` parses simple JsonPath like expressions. Supported expressions are 40 | 41 | | Expression | Description  | 42 | | ----------------------------------- | --------------------------------------------------------------------- | 43 | | `$.property` | Identifiers matching RegExp `/^[a-zA-Z_][a-zA-Z0-9_]*$/` | 44 | | `$[0]` | Index match | 45 | | `$.*` | Any property matcher, wildcard (matches also array indexes) | 46 | | `$[*]` | Any index matcher, wildcard (matches only array indexes) | 47 | | `$["JSON string encoded property"]` | Property as JSON encoded string | 48 | | `$['JSON string encoded property']` | Property as single quoted, but otherwise JSON encoded, string(\*) | 49 | | `$[union,"of",4,'components']` | Union matcher that also supports identifiers and JSON encoded strings | 50 | 51 | \*) This is the official way of `JsonPath`, but the specification is a bit unclear on the encoding. In this library we prefer proper JSON string encoding with double quotes. 52 | 53 | ```typescript 54 | import { parsePathMatcher } from '@finnair/path-parser'; 55 | 56 | parsePathMatcher(`$.array[0][*].*['union',"of",properties,1]`); 57 | // PathMatcher.of( 58 | // 'array', 59 | // 0, 60 | // AnyIndex, 61 | // AnyProperty, 62 | // UnionMatcher.of('union', 'of', 'properties', 1) 63 | // ) 64 | ``` 65 | -------------------------------------------------------------------------------- /packages/path-parser/src/pathGrammar.ts: -------------------------------------------------------------------------------- 1 | // Generated automatically by nearley, version 2.20.1 2 | // http://github.com/Hardmath123/nearley 3 | // Bypasses TS6133. Allow declared but unused functions. 4 | // NOTE: Manually edited to remove dead code 5 | /* v8 ignore next */ // @ts-ignore 6 | function id(d: any[]): any { return d[0]; } 7 | declare var property: any; 8 | declare var integer: any; 9 | declare var qqString: any; 10 | declare var qString: any; 11 | 12 | import moo from 'moo'; 13 | 14 | const lexer = moo.compile({ 15 | qString: /'(?:\\["bfnrt\/\\]|\\u[a-fA-F0-9]{4}|[^"\\])*?'/, 16 | qqString: /"(?:\\["bfnrt\/\\]|\\u[a-fA-F0-9]{4}|[^"\\])*?"/, 17 | integer: /[0-9]+/, 18 | property: /[a-zA-Z_][a-zA-Z0-9_]*/, 19 | '$': '$', 20 | '[': '[', 21 | ']': ']', 22 | '.': '.', 23 | }); 24 | 25 | function handleQString(qString: string) { 26 | return JSON.parse('"' + qString.substring(1, qString.length-1) + '"'); 27 | } 28 | 29 | interface NearleyToken { 30 | value: any; 31 | [key: string]: any; 32 | }; 33 | 34 | interface NearleyLexer { 35 | reset: (chunk: string, info: any) => void; 36 | next: () => NearleyToken | undefined; 37 | save: () => any; 38 | formatError: (token: never) => string; 39 | has: (tokenType: string) => boolean; 40 | }; 41 | 42 | interface NearleyRule { 43 | name: string; 44 | symbols: NearleySymbol[]; 45 | postprocess?: (d: any[], loc?: number, reject?: {}) => any; 46 | }; 47 | 48 | type NearleySymbol = string | { literal: any } | { type: string } | { test: (token: any) => boolean }; 49 | 50 | interface Grammar { 51 | Lexer: NearleyLexer | undefined; 52 | ParserRules: NearleyRule[]; 53 | ParserStart: string; 54 | }; 55 | 56 | const grammar: Grammar = { 57 | Lexer: lexer, 58 | ParserRules: [ 59 | {"name": "Path$ebnf$1", "symbols": []}, 60 | {"name": "Path$ebnf$1", "symbols": ["Path$ebnf$1", "PathExpression"], "postprocess": (d) => d[0].concat([d[1]])}, 61 | {"name": "Path", "symbols": [{"literal":"$"}, "Path$ebnf$1"], "postprocess": d => d[1].reduce((result: (string | number)[], component: string | number) => result.concat(component), [])}, 62 | {"name": "PathExpression", "symbols": [{"literal":"."}, "PropertyExpression"], "postprocess": d => d[1]}, 63 | {"name": "PathExpression", "symbols": [{"literal":"["}, "IndexExpression", {"literal":"]"}], "postprocess": d => d[1]}, 64 | {"name": "PropertyExpression", "symbols": [{type: "property"}], "postprocess": d => d[0].value}, 65 | {"name": "IndexExpression", "symbols": [{type: "integer"}], "postprocess": d => parseInt(d[0].value)}, 66 | {"name": "IndexExpression", "symbols": [{type: "qqString"}], "postprocess": d => JSON.parse(d[0].value)}, 67 | {"name": "IndexExpression", "symbols": [{type: "qString"}], "postprocess": d => handleQString(d[0].value)} 68 | ], 69 | ParserStart: "Path", 70 | }; 71 | 72 | export default grammar; 73 | -------------------------------------------------------------------------------- /packages/diff/README.md: -------------------------------------------------------------------------------- 1 | ![CI](https://github.com/finnair/v-validation/workflows/CI/badge.svg?branch=master) 2 | [![codecov](https://codecov.io/gh/finnair/v-validation/branch/master/graph/badge.svg)](https://codecov.io/gh/finnair/v-validation) 3 | [![npm version](https://badge.fury.io/js/%40finnair%2Fv-validation.svg)](https://badge.fury.io/js/%40finnair%2Fv-validation) 4 | 5 | # Diff & VersionInfo 6 | 7 | `@finnair/diff` library offers paths and values (Map) based configurable object difference utility. Use `Diff` class to analyze differences of two objects, or `Versioninfo` to compare and transform two versions of the same object. 8 | 9 | `Diff` supports only primitive, array and plain object values. 10 | 11 | JSON serialization of `VersionInfo` offers nice representation of the new version (`current`) with `changedPaths` and configurable set of old values (`previous`). Old values are useful for example in cases where natural identifier of an object changes and the old identifier is needed for targeting an update. `VersionInfo` is great for change based triggers configurable by a [`PathMatcher`](../path/README.md). 12 | 13 | ## Getting Started 14 | 15 | Install v-validation using [`yarn`](https://yarnpkg.com): 16 | 17 | ```bash 18 | yarn add @finnair/diff 19 | ``` 20 | 21 | Or [`npm`](https://www.npmjs.com/): 22 | 23 | ```bash 24 | npm install @finnair/diff 25 | ``` 26 | 27 | ## Features 28 | 29 | ### Changeset 30 | 31 | `Diff.changeset(a:T, b: T)` analyzes changes between `a` and `b` and returns a Map of changed paths to `Change` objects. A `Change` object contains a `path` (as Path), `oldValue` and `newValue`. Changeset can be used to patch/revert changes with `Path.set`: 32 | ```ts 33 | const diff = new Diff(); 34 | const a = {...}; 35 | const b = {...}; 36 | // patch a into b - for revert, use set change.oldValue 37 | diff.changeset(a, b).forEach((change) => change.path.set(a, change.newValue)); 38 | ``` 39 | 40 | ### Change Triggering 41 | 42 | `VersionInfo.matches` and `matchesAny` can be used to trigger functionality based on what has changed. Use `PathMatcher` to specify paths of interest. 43 | 44 | ### Filtering 45 | 46 | `Diff` has a configurable filter that can be used to include/exclude properties/values. This can be used to for example exclude metadata fields from changeset. The default filter excludes `undefined` values. 47 | 48 | ### Mapping/Transforming 49 | 50 | `VersionInfo` supports both sync and async transformations. Both old and new values are transformed and the resulting `VersionInfo` reflects the changes of transformed objects. This helps in conversions from internal to external model. 51 | 52 | ### Nice JSON 53 | 54 | `VersionInfo` is designed to be serialized as JSON. It contains the `current` value, `changedPaths` and configurable old values as `previous`. Only matching previous values are included and only if they have changed. 55 | -------------------------------------------------------------------------------- /packages/core/src/objectValidatorBuilder.ts: -------------------------------------------------------------------------------- 1 | import { V } from "./V.js"; 2 | import { Validator } from "./validators.js"; 3 | import { MapEntryModel, ObjectValidator, PropertyModel, strictUnknownPropertyValidator } from "./objectValidator.js"; 4 | import { UndefinedAsOptionalProperties } from "./typing.js"; 5 | 6 | export class ObjectValidatorBuilder { 7 | private _extends: ObjectValidator[] = []; 8 | private _properties: PropertyModel = {}; 9 | private _localProperties: PropertyModel = {}; 10 | private _additionalProperties: MapEntryModel[] = []; 11 | private _next?: Validator[] = []; 12 | private _localNext?: Validator[] = []; 13 | constructor() {} 14 | extends(parent: ObjectValidator) { 15 | this._extends.push(parent); 16 | return this as ObjectValidatorBuilder; 17 | } 18 | properties(properties: { [K in keyof X]: Validator }) { 19 | for (const key in properties) { 20 | this._properties[key] = properties[key]; 21 | } 22 | return this as ObjectValidatorBuilder, Next, LocalProps, LocalNext>; 23 | } 24 | localProperties(localProperties: { [K in keyof X]: Validator }) { 25 | for (const key in localProperties) { 26 | this._localProperties[key] = localProperties[key]; 27 | } 28 | return this as ObjectValidatorBuilder, LocalNext>; 29 | } 30 | allowAdditionalProperties(allow: boolean) { 31 | if (allow) { 32 | return this.additionalProperties(V.any(), V.any()); 33 | } else { 34 | return this.additionalProperties(V.any(), strictUnknownPropertyValidator); 35 | } 36 | } 37 | additionalProperties(keys: Validator, values: Validator) { 38 | this._additionalProperties.push({ keys, values }); 39 | return this as ObjectValidatorBuilder; 40 | } 41 | next(validator: Validator) { 42 | this._next?.push(validator); 43 | return this as unknown as ObjectValidatorBuilder; 44 | } 45 | localNext(validator: Validator) { 46 | this._localNext?.push(validator); 47 | return this as unknown as ObjectValidatorBuilder; 48 | } 49 | build() { 50 | return new ObjectValidator< 51 | (Next extends {} ? Next : Props) & (LocalNext extends {} ? LocalNext : LocalProps), 52 | (Next extends {} ? Next : Props) 53 | >({ 54 | extends: this._extends, 55 | properties: this._properties, 56 | additionalProperties: this._additionalProperties, 57 | next: this._next, 58 | localProperties: this._localProperties, 59 | localNext: this._localNext, 60 | }); 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /packages/path/src/jsonClone.spec.ts: -------------------------------------------------------------------------------- 1 | import { jsonClone } from './jsonClone'; 2 | import { describe, test, expect } from 'vitest' 3 | 4 | class MyClass { 5 | constructor(public visibleValue: any, public hiddenValue: any) {} 6 | toJSON() { 7 | return { 8 | visibleValue: this.visibleValue, 9 | date: new Date(Date.UTC(2026, 3, 12, 1, 2, 3, 4)), 10 | bigint: 123n, 11 | }; 12 | } 13 | } 14 | 15 | const ignoredSymbol = Symbol('ignoredSymbol'); 16 | 17 | describe('jsonClone', () => { 18 | const funkyArray: any[] = [ 19 | "string", 20 | 1, 21 | ignoredSymbol, 22 | true, 23 | JSON.stringify, 24 | new Date(Date.UTC(2027, 4, 12, 1, 2, 3, 4)) 25 | ]; 26 | (funkyArray).ignoredProperty = 'ignoredProperty'; 27 | 28 | const funkyObject = { 29 | string: "string", 30 | 1: 1, 31 | boolean: true, 32 | object: { 33 | plain: "object", 34 | }, 35 | array: funkyArray, 36 | myClass: new MyClass('visibleValue', 'hiddenValue'), 37 | ignoredFunction() { 38 | return 'ignoredFunction'; 39 | }, 40 | bigint: 456n, 41 | [ignoredSymbol]: 'ignoredSymbol', 42 | toJSON(key: string) { 43 | if (key === '') { 44 | return { 45 | ...this 46 | } 47 | } else { 48 | return null; 49 | } 50 | } 51 | }; 52 | 53 | test('object with toJSON and replacer function', () => { 54 | const replacer = (key: string, value: any) => { 55 | if (typeof value === 'bigint') { 56 | return value.toString(); 57 | } 58 | return value; 59 | }; 60 | 61 | const clone = jsonClone(funkyObject, replacer); 62 | 63 | expect(clone).toStrictEqual({ 64 | string: "string", 65 | 1: 1, 66 | boolean: true, 67 | object: { 68 | plain: "object", 69 | }, 70 | array: [ 71 | "string", 72 | 1, 73 | null, 74 | true, 75 | null, 76 | '2027-05-12T01:02:03.004Z', 77 | ], 78 | myClass: { 79 | visibleValue: 'visibleValue', 80 | date: '2026-04-12T01:02:03.004Z', 81 | bigint: '123', 82 | }, 83 | bigint: '456', 84 | }) 85 | expect(clone).toStrictEqual(JSON.parse(JSON.stringify(funkyObject, replacer))); 86 | }); 87 | 88 | test('array replacer', () => { 89 | const replacer = ['myClass', 'array', 'visibleValue']; 90 | 91 | const clone = jsonClone(funkyObject, replacer); 92 | 93 | expect(clone).toStrictEqual({ 94 | array: [ 95 | "string", 96 | 1, 97 | null, 98 | true, 99 | null, 100 | '2027-05-12T01:02:03.004Z', 101 | ], 102 | myClass: { 103 | visibleValue: 'visibleValue', 104 | }, 105 | }) 106 | expect(clone).toStrictEqual(JSON.parse(JSON.stringify(funkyObject, replacer))); 107 | }); 108 | 109 | test('bigint throws an exception', () => { 110 | expect(() => jsonClone(1n)).toThrow("BigInt value can't be serialized in JSON"); 111 | }); 112 | 113 | test('jsonClone of undefined is undefined', () => { 114 | expect(jsonClone(undefined)).toBeUndefined(); 115 | }); 116 | 117 | test('jsonClone of null is null', () => { 118 | expect(jsonClone(null)).toBe(null); 119 | }); 120 | 121 | test('jsonClone of function is undefined', () => { 122 | expect(jsonClone(JSON.stringify)).toBeUndefined(); 123 | }); 124 | 125 | test('jsonClone of symbol is undefined', () => { 126 | expect(jsonClone(ignoredSymbol)).toBeUndefined(); 127 | }); 128 | 129 | test('jsonClone of number is number', () => { 130 | expect(jsonClone(123)).toBe(123); 131 | }); 132 | 133 | test('jsonClone of boolean is boolean', () => { 134 | expect(jsonClone(true)).toBe(true); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /packages/path/src/Projection.ts: -------------------------------------------------------------------------------- 1 | import { PathMatcher } from './PathMatcher.js'; 2 | import { Path } from './Path.js'; 3 | import { JsonReplacer, JsonValue, jsonClone } from './jsonClone.js'; 4 | 5 | export class Projection { 6 | private readonly allowGaps: boolean; 7 | private constructor( 8 | private readonly includes: PathMatcher[], 9 | private readonly excludes: PathMatcher[], 10 | private readonly always: PathMatcher[], 11 | private readonly replacer?: JsonReplacer 12 | ) { 13 | this.allowGaps = this.includes.some(expression => expression.allowGaps) 14 | || this.excludes.some(expression => expression.allowGaps) 15 | || this.always.some(expression => expression.allowGaps); 16 | 17 | Object.freeze(this.includes); 18 | Object.freeze(this.excludes); 19 | Object.freeze(this.always); 20 | Object.freeze(this); 21 | } 22 | 23 | map(input: T): JsonValue { 24 | // Clone input for safety: nothing invisible to JSON should be accessible! 25 | let safeInput = jsonClone(input, this.replacer); 26 | if (typeof safeInput !== 'object' || safeInput === null) { 27 | throw new Error(`Expected JSON of the input to be non-null object, got ${safeInput}`); 28 | } 29 | let output: JsonValue; 30 | if (this.includes.length) { 31 | output = Array.isArray(safeInput) ? [] : {}; 32 | this.includes.forEach(expression => include(safeInput, expression, output)); 33 | } else if (this.excludes.length) { 34 | output = safeInput; 35 | } else { 36 | return safeInput; 37 | } 38 | 39 | this.excludes.forEach(expression => exclude(output, expression)); 40 | 41 | if (this.includes.length || this.excludes.length) { 42 | this.always.forEach(expression => include(input, expression, output)); 43 | } 44 | 45 | if (this.allowGaps) { 46 | return removeGaps(output); 47 | } 48 | return output; 49 | } 50 | 51 | match(path: Path) { 52 | if (this.always.some(expression => expression.prefixMatch(path))) { 53 | return true; 54 | } 55 | if (this.includes.length && !this.includes.some(expression => expression.partialMatch(path))) { 56 | return false; 57 | } 58 | if (this.excludes.some(expression => expression.prefixMatch(path))) { 59 | return false; 60 | } 61 | return true; 62 | } 63 | 64 | static of(includes?: PathMatcher[], excludes?: PathMatcher[], always?: PathMatcher[], replacer?: JsonReplacer) { 65 | includes = includes ? includes.map(validatePathMatcher) : []; 66 | excludes = excludes ? excludes.map(validatePathMatcher) : []; 67 | always = always ? always.map(validatePathMatcher) : []; 68 | 69 | return new Projection(includes, excludes, always, replacer); 70 | } 71 | } 72 | 73 | export function projection(includes?: PathMatcher[], excludes?: PathMatcher[], always?: PathMatcher[], replacer?: JsonReplacer) { 74 | const projection = Projection.of(includes, excludes, always, replacer); 75 | return (input: T): JsonValue => projection.map(input); 76 | } 77 | 78 | function validatePathMatcher(value: PathMatcher): PathMatcher { 79 | if (value instanceof PathMatcher) { 80 | return value as PathMatcher; 81 | } else { 82 | throw new Error(`Expected an instance of PathMatcher, got ${value}`); 83 | } 84 | } 85 | 86 | function include(input: any, matcher: PathMatcher, output: any) { 87 | matcher.find(input, (path: Path, value: any) => path.set(output, value)); 88 | } 89 | 90 | function exclude(output: any, matcher: PathMatcher) { 91 | matcher.find(output, (path: Path) => path.unset(output)); 92 | } 93 | 94 | function removeGaps(value: any) { 95 | if (typeof value === 'object') { 96 | if (Array.isArray(value)) { 97 | value = value 98 | .filter(item => item !== undefined) 99 | .map(removeGaps); 100 | } else { 101 | for (const key in value) { 102 | value[key] = removeGaps(value[key]); 103 | } 104 | } 105 | } 106 | return value; 107 | } 108 | -------------------------------------------------------------------------------- /packages/path-parser/src/matcherGrammar.ts: -------------------------------------------------------------------------------- 1 | // Generated automatically by nearley, version 2.20.1 2 | // http://github.com/Hardmath123/nearley 3 | // Bypasses TS6133. Allow declared but unused functions. 4 | /* v8 ignore next */ // @ts-ignore 5 | function id(d: any[]): any { return d[0]; } 6 | declare var lbracket: any; 7 | declare var rbracket: any; 8 | declare var property: any; 9 | declare var integer: any; 10 | declare var qqString: any; 11 | declare var qString: any; 12 | declare var comma: any; 13 | 14 | import * as matchers from '@finnair/path'; 15 | import moo from 'moo'; 16 | 17 | const lexer = moo.compile({ 18 | qString: /'(?:\\["bfnrt\/\\]|\\u[a-fA-F0-9]{4}|[^"\\])*?'/, 19 | qqString: /"(?:\\["bfnrt\/\\]|\\u[a-fA-F0-9]{4}|[^"\\])*?"/, 20 | integer: /[0-9]+/, 21 | property: /[a-zA-Z_][a-zA-Z0-9_]*/, 22 | comma: /, ?/, 23 | lbracket: /\[ ?/, 24 | rbracket: / ?]/, 25 | '*': '*', 26 | '$': '$', 27 | '.': '.', 28 | }); 29 | 30 | function handleQString(qString: string) { 31 | return JSON.parse('"' + qString.substring(1, qString.length-1) + '"'); 32 | } 33 | 34 | interface NearleyToken { 35 | value: any; 36 | [key: string]: any; 37 | }; 38 | 39 | interface NearleyLexer { 40 | reset: (chunk: string, info: any) => void; 41 | next: () => NearleyToken | undefined; 42 | save: () => any; 43 | formatError: (token: never) => string; 44 | has: (tokenType: string) => boolean; 45 | }; 46 | 47 | interface NearleyRule { 48 | name: string; 49 | symbols: NearleySymbol[]; 50 | postprocess?: (d: any[], loc?: number, reject?: {}) => any; 51 | }; 52 | 53 | type NearleySymbol = string | { literal: any } | { type: string } | { test: (token: any) => boolean }; 54 | 55 | interface Grammar { 56 | Lexer: NearleyLexer | undefined; 57 | ParserRules: NearleyRule[]; 58 | ParserStart: string; 59 | }; 60 | 61 | const grammar: Grammar = { 62 | Lexer: lexer, 63 | ParserRules: [ 64 | {"name": "Path$ebnf$1", "symbols": []}, 65 | {"name": "Path$ebnf$1", "symbols": ["Path$ebnf$1", "PathExpression"], "postprocess": (d) => d[0].concat([d[1]])}, 66 | {"name": "Path", "symbols": [{"literal":"$"}, "Path$ebnf$1"], "postprocess": d => d[1].reduce((result: any[], matcher: any) => result.concat(matcher), [])}, 67 | {"name": "PathExpression", "symbols": [{"literal":"."}, "PropertyExpression"], "postprocess": d => d[1]}, 68 | {"name": "PathExpression", "symbols": [{type: "lbracket"}, "IndexExpression", {type: "rbracket"}], "postprocess": d => d[1]}, 69 | {"name": "PathExpression", "symbols": [{type: "lbracket"}, "UnionExpression", {type: "rbracket"}], "postprocess": d => new matchers.UnionMatcher(d[1])}, 70 | {"name": "PropertyExpression", "symbols": [{"literal":"*"}], "postprocess": d => matchers.AnyProperty}, 71 | {"name": "PropertyExpression", "symbols": [{type: "property"}], "postprocess": d => new matchers.PropertyMatcher(d[0].value)}, 72 | {"name": "IndexExpression", "symbols": [{type: "integer"}], "postprocess": d => new matchers.IndexMatcher(parseInt(d[0].value))}, 73 | {"name": "IndexExpression", "symbols": [{type: "qqString"}], "postprocess": d => new matchers.PropertyMatcher(JSON.parse(d[0].value))}, 74 | {"name": "IndexExpression", "symbols": [{type: "qString"}], "postprocess": d => new matchers.PropertyMatcher(handleQString(d[0].value))}, 75 | {"name": "IndexExpression", "symbols": [{"literal":"*"}], "postprocess": d => matchers.AnyIndex}, 76 | {"name": "UnionExpression", "symbols": ["ComponentExpression", {type: "comma"}, "ComponentExpression"], "postprocess": d => [d[0], d[2]]}, 77 | {"name": "UnionExpression", "symbols": ["ComponentExpression", {type: "comma"}, "UnionExpression"], "postprocess": d => [ d[0], ...d[2]]}, 78 | {"name": "ComponentExpression", "symbols": [{type: "integer"}], "postprocess": d => parseInt(d[0].value)}, 79 | {"name": "ComponentExpression", "symbols": [{type: "property"}], "postprocess": d => d[0].value}, 80 | {"name": "ComponentExpression", "symbols": [{type: "qqString"}], "postprocess": d => JSON.parse(d[0].value)}, 81 | {"name": "ComponentExpression", "symbols": [{type: "qString"}], "postprocess": d => handleQString(d[0].value)} 82 | ], 83 | ParserStart: "Path", 84 | }; 85 | 86 | export default grammar; 87 | -------------------------------------------------------------------------------- /packages/path/src/matchers.ts: -------------------------------------------------------------------------------- 1 | import { Path, PathComponent } from './Path.js'; 2 | 3 | type Continue = boolean; 4 | 5 | export interface MatchHandler { 6 | (value: any, component: PathComponent): Continue; 7 | } 8 | export interface PathExpression { 9 | find(current: any, callback: MatchHandler): Continue; 10 | test(component: PathComponent): boolean; 11 | readonly allowGaps: boolean; 12 | toString(): string; 13 | } 14 | 15 | export function isPathExpression(component: PathComponent | PathExpression): component is PathExpression { 16 | return !!component && typeof (component as PathExpression).test === 'function' && typeof (component as PathExpression).find === 'function'; 17 | } 18 | 19 | export interface Node { 20 | readonly path: Path; 21 | readonly value: any; 22 | } 23 | 24 | export class IndexMatcher implements PathExpression { 25 | readonly allowGaps = true; 26 | constructor(private readonly index: number) { 27 | Path.validateIndex(index); 28 | } 29 | 30 | find(current: any, callback: MatchHandler): Continue { 31 | if (Array.isArray(current) && this.index < current.length) { 32 | return callback(current[this.index], this.index); 33 | } 34 | return true; 35 | } 36 | 37 | test(component: PathComponent): boolean { 38 | return component === this.index; 39 | } 40 | 41 | toString() { 42 | return Path.indexToString(this.index); 43 | } 44 | } 45 | 46 | export class PropertyMatcher implements PathExpression { 47 | readonly allowGaps = false; 48 | constructor(private readonly property: string) { 49 | Path.validateProperty(property); 50 | } 51 | 52 | find(current: any, callback: MatchHandler): Continue { 53 | if (typeof current === 'object' && current.hasOwnProperty(this.property)) { 54 | return callback(current[this.property], this.property); 55 | } 56 | return true; 57 | } 58 | 59 | test(component: PathComponent): boolean { 60 | return String(component) === this.property; 61 | } 62 | 63 | toString() { 64 | return Path.propertyToString(this.property); 65 | } 66 | } 67 | 68 | export class UnionMatcher implements PathExpression { 69 | constructor(private readonly components: PathComponent[]) { 70 | if (components.length < 2) { 71 | throw new Error('Expected at least 2 properties'); 72 | } 73 | components.forEach(Path.validateComponent); 74 | } 75 | 76 | find(current: any, callback: MatchHandler): Continue { 77 | if (typeof current === 'object') { 78 | for (const component of this.components) { 79 | if (current.hasOwnProperty(component)) { 80 | if (!callback(current[component], component)) { 81 | return false; 82 | } 83 | } 84 | } 85 | } 86 | return true; 87 | } 88 | 89 | test(component: PathComponent): boolean { 90 | const str = String(component); 91 | return this.components.find(component => String(component) === str) !== undefined; 92 | } 93 | 94 | get allowGaps() { 95 | return this.components.some(component => typeof component === 'number'); 96 | } 97 | 98 | toString() { 99 | return `[${this.components.map(this.propertyToString).join(',')}]`; 100 | } 101 | 102 | static of(...components: PathComponent[]) { 103 | return new UnionMatcher(components); 104 | } 105 | 106 | private propertyToString(property: PathComponent) { 107 | return JSON.stringify(property); 108 | } 109 | } 110 | 111 | export const AnyIndex: PathExpression = { 112 | allowGaps: false, 113 | find: (current: any, callback: MatchHandler): boolean => { 114 | if (Array.isArray(current)) { 115 | for (let i = 0; i < current.length; i++) { 116 | if (!callback(current[i], i)) { 117 | return false; 118 | } 119 | } 120 | } 121 | return true; 122 | }, 123 | 124 | test: (component: PathComponent) => { 125 | return typeof component === 'number' && Number.isInteger(component as number) && component >= 0; 126 | }, 127 | 128 | toString: () => { 129 | return '[*]'; 130 | }, 131 | }; 132 | 133 | export const AnyProperty: PathExpression = { 134 | allowGaps: false, 135 | find: (current: any, callback: MatchHandler): Continue => { 136 | if (typeof current === 'object') { 137 | for (let key in current) { 138 | if (!callback(current[key], key)) { 139 | return false; 140 | } 141 | } 142 | } 143 | return true; 144 | }, 145 | 146 | test: () => { 147 | return true; 148 | }, 149 | 150 | toString: () => { 151 | return '.*'; 152 | }, 153 | }; 154 | -------------------------------------------------------------------------------- /packages/diff/src/DiffNode.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import { describe, test, expect } from 'vitest'; 3 | import { DiffNode, DiffNodeConfig } from './DiffNode'; 4 | import { Path } from '@finnair/path'; 5 | 6 | const strictOptionalPropertiesConfig: DiffNodeConfig = { filter: () => true }; 7 | 8 | describe('DiffNode', () => { 9 | describe('getPatch', () => { 10 | const object: any = { 11 | string: 'string', 12 | undefined: undefined, 13 | object: { number: 1 }, 14 | array: [ 1, { boolean: true } ], 15 | }; 16 | 17 | test('new string value', () => { 18 | expect(Array.from(new DiffNode({ newValue: 'string' }).patch)) 19 | .toEqual([{ path: Path.ROOT, value: 'string' }]); 20 | }); 21 | test('new object value', () => { 22 | expect(Array.from(new DiffNode({ newValue: object }).patch)) 23 | .toEqual([{ path: Path.ROOT, value: object }]); 24 | }); 25 | test('nested modification', () => { 26 | const clone = structuredClone(object); 27 | delete clone.object; 28 | clone.array[1].boolean = false; 29 | clone.array[1].newProp = 'newProp'; 30 | 31 | expect(Array.from(new DiffNode({ oldValue: object, newValue: clone }).patch)) 32 | .toEqual([ 33 | { path: Path.of('object') }, 34 | { path: Path.of('array', 1, 'boolean'), value: false }, 35 | { path: Path.of('array', 1, 'newProp'), value: 'newProp' }, 36 | ]); 37 | expect(Array.from(new DiffNode({ oldValue: clone, newValue: object }).patch)) 38 | .toEqual([ 39 | { path: Path.of('array', 1, 'boolean'), value: true }, 40 | { path: Path.of('array', 1, 'newProp') }, 41 | { path: Path.of('object'), value: { number: 1 } }, 42 | ]); 43 | }); 44 | }); 45 | describe('type changes', () => { 46 | test('undefined to string', () => { 47 | const change = new DiffNode({ oldValue: undefined, newValue: 'string'}, strictOptionalPropertiesConfig).getScalarChange(true)!; 48 | expect(change).toEqual({ path: Path.ROOT, oldValue: undefined, newValue: 'string' }); 49 | expect('oldValue' in change).toBe(true); 50 | }); 51 | test('missing to string', () => { 52 | const change = new DiffNode({ newValue: 'string' }, strictOptionalPropertiesConfig).getScalarChange(true)!; 53 | expect(change).toEqual({ path: Path.ROOT, newValue: 'string' }); 54 | expect('oldValue' in change).toBe(false); 55 | }); 56 | describe('array to object', () => { 57 | const node = new DiffNode({ oldValue: [1], newValue: {'0': 1} }, strictOptionalPropertiesConfig); 58 | test('scalar', () => { 59 | expect(Array.from(node.getScalarChanges(true))).toEqual([ 60 | { path: Path.ROOT, oldValue: [], newValue: {} }, 61 | { path: Path.of(0), oldValue: 1 }, 62 | { path: Path.of('0'), newValue: 1 }, 63 | ]); 64 | }); 65 | test('patch', () => { 66 | expect(Array.from(node.patch)).toEqual([ 67 | { path: Path.ROOT, value: { '0': 1 } } 68 | ]); 69 | }); 70 | }); 71 | describe('object to array', () => { 72 | const node = new DiffNode({ oldValue: {'0': 1}, newValue: [1] }, strictOptionalPropertiesConfig); 73 | test('scalar', () => { 74 | expect(Array.from(node.getScalarChanges(true))).toEqual([ 75 | { path: Path.ROOT, oldValue: {}, newValue: [] }, 76 | { path: Path.of('0'), oldValue: 1 }, 77 | { path: Path.of(0), newValue: 1 }, 78 | ]); 79 | }); 80 | test('patch', () => { 81 | expect(Array.from(node.patch)).toEqual([ 82 | { path: Path.ROOT, value: [1] } 83 | ]); 84 | }); 85 | }); 86 | describe('string to object', () => { 87 | const node = new DiffNode({ oldValue: 'string', newValue: { string: 'string' } }); 88 | test('scalar', () => { 89 | expect(Array.from(node.getScalarChanges(true))).toEqual([ 90 | { path: Path.ROOT, oldValue: 'string', newValue: {} }, 91 | { path: Path.of('string'), newValue: 'string' }, 92 | ]); 93 | }); 94 | test('patch', () => { 95 | expect(Array.from(node.patch)).toEqual([ 96 | { path: Path.ROOT, value: { string: 'string' } } 97 | ]); 98 | }); 99 | }); 100 | describe('object to boolean', () => { 101 | const node = new DiffNode({ oldValue: { boolean: true }, newValue: true }); 102 | test('scalar', () => { 103 | expect(Array.from(node.getScalarChanges(true))).toEqual([ 104 | { path: Path.ROOT, oldValue: {}, newValue: true }, 105 | { path: Path.of('boolean'), oldValue: true }, 106 | ]); 107 | }); 108 | test('patch', () => { 109 | expect(Array.from(node.patch)).toEqual([ 110 | { path: Path.ROOT, value: true } 111 | ]); 112 | }); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /packages/diff/src/VersionInfo.ts: -------------------------------------------------------------------------------- 1 | import { Path, PathMatcher } from '@finnair/path'; 2 | import { parsePath, parsePathMatcher } from '@finnair/path-parser'; 3 | import { Diff, DiffConfig } from './Diff.js'; 4 | import { Change, DiffNode, Patch } from './DiffNode.js'; 5 | 6 | export interface VersionInfoConfig { 7 | /** 8 | * @deprecated use diffConfig instead 9 | */ 10 | readonly diff?: Diff; 11 | readonly diffConfig?: DiffConfig; 12 | readonly previousValues?: PathMatcher[]; 13 | } 14 | 15 | const NO_PREVIOUS_VALUES = Object.freeze({}); 16 | 17 | export class VersionInfo { 18 | private _changes?: Map; 19 | private _paths?: Set; 20 | private _previousValues?: any; 21 | private _diffNode?: DiffNode; 22 | public readonly config: VersionInfoConfig; 23 | constructor( 24 | public readonly current: L, 25 | public readonly previous?: L, 26 | config?: VersionInfoConfig 27 | ) { 28 | this.config = { 29 | diffConfig: config?.diffConfig ?? config?.diff?.config, 30 | previousValues: config?.previousValues, 31 | }; 32 | } 33 | map(fn: (version: L) => T, config?: VersionInfoConfig) { 34 | return new VersionInfo( 35 | fn(this.current), 36 | this.previous ? fn(this.previous) : undefined, 37 | config ?? this.config 38 | ); 39 | } 40 | async mapAsync(fn: (version: L) => Promise, config?: VersionInfoConfig) { 41 | return new VersionInfo( 42 | await fn(this.current), 43 | this.previous ? await fn(this.previous) : undefined, 44 | config ?? this.config 45 | ); 46 | } 47 | get changes(): undefined | Map { 48 | if (this.previous) { 49 | if (this._changes === undefined) { 50 | this._changes = new Map(); 51 | for (const change of this.diffNode.getScalarChanges(this.config.diffConfig?.includeObjects)) { 52 | this._changes.set(change.path.toJSON(), change); 53 | } 54 | } 55 | return this._changes; 56 | } 57 | return undefined; 58 | } 59 | get changedPaths(): undefined | Set { 60 | if (this.previous) { 61 | if (this._paths === undefined) { 62 | this._paths = new Set(this.changes!.keys()); 63 | } 64 | return this._paths; 65 | } 66 | return undefined; 67 | } 68 | get paths(): Set { 69 | if (this._paths === undefined) { 70 | if (this.previous) { 71 | this._paths = this.changedPaths!; 72 | } else { 73 | this._paths = new Set(); 74 | for (const path of this.diffNode.getChangedPaths(this.config.diffConfig?.includeObjects)) { 75 | this._paths.add(path.toJSON()); 76 | } 77 | } 78 | } 79 | return this._paths; 80 | } 81 | get previousValues(): any { 82 | if (this.previous && this.config.previousValues?.length) { 83 | if (this._previousValues === undefined) { 84 | this._previousValues = NO_PREVIOUS_VALUES; 85 | for (const [key, value] of this.changes!) { 86 | const path = parsePath(key); 87 | if (this.config.previousValues.some((matcher) => matcher.match(path))) { 88 | if (this._previousValues === NO_PREVIOUS_VALUES) { 89 | this._previousValues = Array.isArray(this.previous) ? [] : {}; 90 | } 91 | path.set(this._previousValues, value.oldValue); 92 | } 93 | } 94 | } 95 | return this._previousValues === NO_PREVIOUS_VALUES ? undefined : this._previousValues; 96 | } 97 | return undefined; 98 | } 99 | get diffNode(): DiffNode { 100 | if (this._diffNode === undefined) { 101 | this._diffNode = new DiffNode({ oldValue: this.previous, newValue: this.current }, this.config.diffConfig); 102 | } 103 | return this._diffNode; 104 | } 105 | get patch(): Patch[] { 106 | return Array.from(this.diffNode.patch); 107 | } 108 | matches(pathExpression: string | PathMatcher) { 109 | const matcher = VersionInfo.toMatcher(pathExpression); 110 | if (this.previous) { 111 | const changedPaths = VersionInfo.parsePaths(this.changedPaths!); 112 | return VersionInfo.matchesAnyPath(matcher, changedPaths) 113 | } else { 114 | return matcher.findFirst(this.current) !== undefined; 115 | } 116 | } 117 | matchesAny(pathExpressions: (string | PathMatcher)[]) { 118 | if (this.previous) { 119 | const changedPaths = VersionInfo.parsePaths(this.changedPaths!); 120 | return pathExpressions.some((pathExpression) => { 121 | const matcher = VersionInfo.toMatcher(pathExpression); 122 | return VersionInfo.matchesAnyPath(matcher, changedPaths) 123 | }); 124 | } else { 125 | return pathExpressions.some((pathExpression) => { 126 | const matcher = VersionInfo.toMatcher(pathExpression); 127 | return matcher.findFirst(this.current) !== undefined; 128 | }); 129 | } 130 | } 131 | toJSON() { 132 | const changedPaths = this.changedPaths; 133 | return { 134 | current: this.current, 135 | changedPaths: changedPaths && Array.from(changedPaths), 136 | previous: this.previousValues, 137 | }; 138 | } 139 | 140 | private static parsePaths(paths: Set) { 141 | const result = []; 142 | for (const path of paths) { 143 | result.push(parsePath(path)); 144 | } 145 | return result; 146 | } 147 | private static matchesAnyPath(matcher: PathMatcher, paths: Path[]) { 148 | return paths.some((path) => matcher.prefixMatch(path)); 149 | } 150 | private static toMatcher(pathExpression: string | PathMatcher): PathMatcher { 151 | return typeof pathExpression === 'string' ? parsePathMatcher(pathExpression) : (pathExpression as PathMatcher); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /packages/diff/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | # [10.2.0-alpha.0](https://github.com/finnair/v-validation/compare/v10.1.0...v10.2.0-alpha.0) (2025-11-28) 7 | 8 | ### Features 9 | 10 | - better support for patching ([#142](https://github.com/finnair/v-validation/issues/142)) ([b097c74](https://github.com/finnair/v-validation/commit/b097c749a7158a1de5580839b714e61adc40ea02)) 11 | 12 | # [10.1.0](https://github.com/finnair/v-validation/compare/v10.0.0...v10.1.0) (2025-11-11) 13 | 14 | **Note:** Version bump only for package @finnair/diff 15 | 16 | # [10.0.0](https://github.com/finnair/v-validation/compare/v9.2.0...v10.0.0) (2025-09-15) 17 | 18 | **Note:** Version bump only for package @finnair/diff 19 | 20 | # [9.2.0](https://github.com/finnair/v-validation/compare/v9.1.1...v9.2.0) (2025-05-21) 21 | 22 | **Note:** Version bump only for package @finnair/diff 23 | 24 | ## [9.1.1](https://github.com/finnair/v-validation/compare/v9.1.0...v9.1.1) (2025-05-19) 25 | 26 | **Note:** Version bump only for package @finnair/diff 27 | 28 | # [9.1.0](https://github.com/finnair/v-validation/compare/v9.0.0...v9.1.0) (2025-05-19) 29 | 30 | **Note:** Version bump only for package @finnair/diff 31 | 32 | # [9.0.0](https://github.com/finnair/v-validation/compare/v8.0.0...v9.0.0) (2025-03-13) 33 | 34 | ### Bug Fixes 35 | 36 | - date instantiation in tests ([#134](https://github.com/finnair/v-validation/issues/134)) ([276954a](https://github.com/finnair/v-validation/commit/276954a4d4a0b397364109b4411fdd7e3c13a133)) 37 | 38 | # [8.0.0](https://github.com/finnair/v-validation/compare/v7.3.0...v8.0.0) (2025-02-03) 39 | 40 | **Note:** Version bump only for package @finnair/diff 41 | 42 | # [7.3.0](https://github.com/finnair/v-validation/compare/v7.2.0...v7.3.0) (2025-01-31) 43 | 44 | **Note:** Version bump only for package @finnair/diff 45 | 46 | # [7.2.0](https://github.com/finnair/v-validation/compare/v7.1.0...v7.2.0) (2025-01-29) 47 | 48 | **Note:** Version bump only for package @finnair/diff 49 | 50 | # [7.1.0](https://github.com/finnair/v-validation/compare/v7.0.0...v7.1.0) (2025-01-23) 51 | 52 | ### Features 53 | 54 | - path-based Diff configuration to allow any value to be handled as "primitive" ([#128](https://github.com/finnair/v-validation/issues/128)) ([4222b6d](https://github.com/finnair/v-validation/commit/4222b6d58610323219af8ebf1bc5832dc06f008b)) 55 | 56 | # [7.0.0](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.9...v7.0.0) (2025-01-07) 57 | 58 | **Note:** Version bump only for package @finnair/diff 59 | 60 | # [7.0.0](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.9...v7.0.0) (2025-01-07) 61 | 62 | **Note:** Version bump only for package @finnair/diff 63 | 64 | # [7.0.0-alpha.9](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.8...v7.0.0-alpha.9) (2024-12-11) 65 | 66 | **Note:** Version bump only for package @finnair/diff 67 | 68 | # [7.0.0-alpha.8](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.7...v7.0.0-alpha.8) (2024-11-21) 69 | 70 | **Note:** Version bump only for package @finnair/diff 71 | 72 | # [7.0.0-alpha.7](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.6...v7.0.0-alpha.7) (2024-11-21) 73 | 74 | ### Bug Fixes 75 | 76 | - package.json exports ([7edf1ee](https://github.com/finnair/v-validation/commit/7edf1ee0b2295c7659802aab10963a0579869e5a)) 77 | 78 | # [7.0.0-alpha.6](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.5...v7.0.0-alpha.6) (2024-11-18) 79 | 80 | **Note:** Version bump only for package @finnair/diff 81 | 82 | # [7.0.0-alpha.5](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.4...v7.0.0-alpha.5) (2024-11-14) 83 | 84 | **Note:** Version bump only for package @finnair/diff 85 | 86 | # [7.0.0-alpha.4](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.3...v7.0.0-alpha.4) (2024-11-14) 87 | 88 | **Note:** Version bump only for package @finnair/diff 89 | 90 | # [7.0.0-alpha.3](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.2...v7.0.0-alpha.3) (2024-11-11) 91 | 92 | **Note:** Version bump only for package @finnair/diff 93 | 94 | # [7.0.0-alpha.2](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.1...v7.0.0-alpha.2) (2024-11-08) 95 | 96 | **Note:** Version bump only for package @finnair/diff 97 | 98 | # [7.0.0-alpha.1](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.0...v7.0.0-alpha.1) (2024-11-08) 99 | 100 | **Note:** Version bump only for package @finnair/diff 101 | 102 | # [7.0.0-alpha.0](https://github.com/finnair/v-validation/compare/v6.1.0...v7.0.0-alpha.0) (2024-11-08) 103 | 104 | **Note:** Version bump only for package @finnair/diff 105 | 106 | # [6.1.0](https://github.com/finnair/v-validation/compare/v6.0.2...v6.1.0) (2024-10-21) 107 | 108 | **Note:** Version bump only for package @finnair/diff 109 | 110 | ## [6.0.2](https://github.com/finnair/v-validation/compare/v6.0.1...v6.0.2) (2024-09-17) 111 | 112 | ### Bug Fixes 113 | 114 | - add missing index.ts ([886bff3](https://github.com/finnair/v-validation/commit/886bff312f2abb081e1683e8d3481ac045cfeee1)) 115 | 116 | ## [6.0.1](https://github.com/finnair/v-validation/compare/v6.0.0...v6.0.1) (2024-09-16) 117 | 118 | **Note:** Version bump only for package @finnair/diff 119 | 120 | # [6.0.0](https://github.com/finnair/v-validation/compare/v5.4.0...v6.0.0) (2024-09-16) 121 | 122 | ### Features 123 | 124 | - @finnair/diff package with Diff and VersionInfo ([#111](https://github.com/finnair/v-validation/issues/111)) ([3b26d49](https://github.com/finnair/v-validation/commit/3b26d49b63851fbcfce9b15efc53ad5418ae4de4)) 125 | 126 | ### BREAKING CHANGES 127 | 128 | - More general and efficient PathMatcher API. 129 | -------------------------------------------------------------------------------- /packages/path/src/PathMatcher.ts: -------------------------------------------------------------------------------- 1 | import { Path, PathComponent } from './Path.js'; 2 | import { Node, PathExpression, PropertyMatcher, IndexMatcher, AnyIndex, AnyProperty, MatchHandler, UnionMatcher, isPathExpression } from './matchers.js'; 3 | 4 | export interface ResultCollector { 5 | /** 6 | * Collect a matching path and value. Return true to continue matching or false to stop. 7 | */ 8 | (path: Path, value: any): boolean; 9 | } 10 | 11 | export class PathMatcher { 12 | readonly allowGaps: boolean; 13 | private constructor(private readonly expressions: PathExpression[]) { 14 | this.allowGaps = expressions.some((expression) => expression.allowGaps); 15 | Object.freeze(this.expressions); 16 | Object.freeze(this); 17 | } 18 | 19 | find(root: any, collector: ResultCollector): void { 20 | if (this.expressions.length === 0) { 21 | collector(Path.ROOT, root); 22 | } 23 | if (typeof root !== 'object') { 24 | return; 25 | } 26 | const currentPath: PathComponent[] = []; 27 | const handlers: MatchHandler[] = []; 28 | for (let i = 0; i < this.expressions.length - 1; i++) { 29 | handlers[i] = intermediateHandler(i, this.expressions); 30 | } 31 | handlers[this.expressions.length - 1] = resultHandler(); 32 | this.expressions[0].find(root, handlers[0]); 33 | 34 | function intermediateHandler(index: number, expressions: PathExpression[]): MatchHandler { 35 | return (value: any, component?: PathComponent) => { 36 | currentPath[index] = component!; 37 | return expressions[index + 1].find(value, handlers[index + 1]); 38 | }; 39 | } 40 | 41 | function resultHandler(): MatchHandler { 42 | return (value: any, component: PathComponent) => { 43 | return collector(Path.of(...currentPath, component), value); 44 | }; 45 | } 46 | } 47 | 48 | findAll(root: any, acceptUndefined?: boolean): Node[] { 49 | const results: Node[] = []; 50 | this.find(root, (path: Path, value: any) => { 51 | if (value !== undefined || acceptUndefined) { 52 | results.push({ path, value }); 53 | return true; 54 | } 55 | return true; 56 | }); 57 | return results; 58 | } 59 | 60 | findFirst(root: any, acceptUndefined?: boolean): undefined | Node { 61 | let result: undefined | Node = undefined; 62 | this.find(root, (path: Path, value: any) => { 63 | if (value !== undefined || acceptUndefined) { 64 | result = { path, value }; 65 | return false; 66 | } 67 | return true; 68 | }); 69 | return result; 70 | } 71 | 72 | findValues(root: any, acceptUndefined?: boolean): any[] { 73 | const results: any[] = []; 74 | this.find(root, (path: Path, value: any) => { 75 | if (value !== undefined || acceptUndefined) { 76 | results.push(value); 77 | return true; 78 | } 79 | return true; 80 | }); 81 | return results; 82 | } 83 | 84 | findFirstValue(root: any, acceptUndefined?: boolean): any { 85 | let result: undefined | any = undefined; 86 | this.find(root, (path: Path, value: any) => { 87 | if (value !== undefined || acceptUndefined) { 88 | result = value; 89 | return false; 90 | } 91 | return true; 92 | }); 93 | return result; 94 | } 95 | 96 | /** 97 | * Exact match: path length must match the number of expressions and all expressions must match. Only sibling paths match. 98 | * 99 | * @param path 100 | * @returns true if path is an exact match to expressions 101 | */ 102 | match(path: Path): boolean { 103 | if (path.length !== this.expressions.length) { 104 | return false; 105 | } 106 | for (let index = 0; index < this.expressions.length; index++) { 107 | if (!this.expressions[index].test(path.componentAt(index))) { 108 | return false; 109 | } 110 | } 111 | return true; 112 | } 113 | 114 | /** 115 | * Prefix match: path length must be equal or longer than the number of expressions and all expressions must match. All sibling and child paths match. 116 | * 117 | * @param path 118 | * @returns true the start the path matches 119 | */ 120 | prefixMatch(path: Path): boolean { 121 | if (path.length < this.expressions.length) { 122 | return false; 123 | } 124 | for (let index = 0; index < this.expressions.length; index++) { 125 | if (!this.expressions[index].test(path.componentAt(index))) { 126 | return false; 127 | } 128 | } 129 | return true; 130 | } 131 | 132 | /** 133 | * Partial match: path length can be less than or more than the number of expressions, but all corresponding expressions must match. All parent, sibling and child paths match. 134 | * 135 | * @param path 136 | * @returns true if all path components match 137 | */ 138 | partialMatch(path: Path): boolean { 139 | for (let index = 0; index < this.expressions.length && index < path.length; index++) { 140 | if (!this.expressions[index].test(path.componentAt(index))) { 141 | return false; 142 | } 143 | } 144 | return true; 145 | } 146 | 147 | toJSON(): string { 148 | return this.expressions.reduce((str: string, expression: PathComponent | PathExpression) => str + expression.toString(), '$'); 149 | } 150 | 151 | static of(...path: (PathComponent | PathExpression)[]): PathMatcher { 152 | return new PathMatcher( 153 | path.map(component => { 154 | const type = typeof component; 155 | if (type === 'number') { 156 | return new IndexMatcher(component as number); 157 | } 158 | if (type === 'string') { 159 | return new PropertyMatcher(component as string); 160 | } 161 | if (isPathExpression(component)) { 162 | return component as PathExpression; 163 | } 164 | throw new Error(`Unrecognized PathComponent: ${component} of type ${type}`); 165 | }), 166 | ); 167 | } 168 | } 169 | 170 | export { AnyIndex, AnyProperty, PropertyMatcher, IndexMatcher, Node, PathExpression, UnionMatcher }; 171 | -------------------------------------------------------------------------------- /packages/path/src/Path.ts: -------------------------------------------------------------------------------- 1 | export type PathComponent = number | string; 2 | 3 | const identifierPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/; 4 | 5 | export class Path { 6 | public static readonly ROOT = new Path([]); 7 | 8 | private readonly path: PathComponent[]; 9 | 10 | private constructor(path: PathComponent[]) { 11 | this.path = path; 12 | Object.freeze(this.path); 13 | Object.freeze(this); 14 | } 15 | 16 | index(index: number): Path { 17 | Path.validateIndex(index); 18 | return new Path(this.path.concat(index)); 19 | } 20 | 21 | property(property: string): Path { 22 | Path.validateProperty(property); 23 | return new Path(this.path.concat(property)); 24 | } 25 | 26 | child(key: number | string): Path { 27 | if (typeof key === 'number') { 28 | return this.index(key); 29 | } 30 | return this.property(key); 31 | } 32 | 33 | connectTo(newRootPath: Path) { 34 | return new Path(newRootPath.path.concat(this.path)); 35 | } 36 | 37 | concat(childPath: Path) { 38 | return new Path(this.path.concat(childPath.path)); 39 | } 40 | 41 | parent(): undefined | Path { 42 | switch (this.path.length) { 43 | case 0: return undefined; 44 | case 1: return Path.ROOT; 45 | default: return new Path(this.path.slice(0, -1)); 46 | } 47 | } 48 | 49 | toJSON(): string { 50 | return this.path.reduce((pathString: string, component: PathComponent) => pathString + Path.componentToString(component), '$'); 51 | } 52 | 53 | equals(other: any) { 54 | if (other instanceof Path) { 55 | const otherLength = other.length; 56 | if (otherLength === this.length) { 57 | for (let i = 0; i < otherLength; i++) { 58 | if (other.componentAt(i) != this.componentAt(i)) { 59 | return false; 60 | } 61 | } 62 | return true; 63 | } 64 | } 65 | return false; 66 | } 67 | 68 | get length(): number { 69 | return this.path.length; 70 | } 71 | 72 | componentAt(index: number) { 73 | return this.path[index]; 74 | } 75 | 76 | [Symbol.iterator]() { 77 | return this.path[Symbol.iterator](); 78 | } 79 | 80 | get(root: any) { 81 | if (this.path.length === 0) { 82 | return root; 83 | } 84 | let current = root; 85 | let index = 0; 86 | for (; index < this.path.length - 1 && typeof current === 'object'; index++) { 87 | const component = this.path[index]; 88 | current = current[component]; 89 | } 90 | if (index === this.path.length - 1 && typeof current === 'object') { 91 | return current[this.path[this.path.length - 1]]; 92 | } 93 | return undefined; 94 | } 95 | 96 | unset(root: any): any { 97 | return this.set(root, undefined); 98 | } 99 | 100 | set(root: any, value: any): any { 101 | if (this.path.length === 0) { 102 | return value; 103 | } 104 | let pathIndex = -1; 105 | const _root = toObject(root, this.path); 106 | let current = _root; 107 | for (pathIndex = 0; pathIndex < this.path.length - 1 && current; pathIndex++) { 108 | const component = this.path[pathIndex]; 109 | const child = toObject(current[component], this.path); 110 | if (child !== undefined) { 111 | current[component] = child; 112 | current = child; 113 | } 114 | } 115 | if (value === undefined) { 116 | if (current !== undefined) { 117 | delete current[this.path[pathIndex]]; 118 | // Truncate undefined tail of an array 119 | if (Array.isArray(current)) { 120 | let i = current.length - 1; 121 | while (i >= 0 && current[i] === undefined) { 122 | i--; 123 | } 124 | current.length = i + 1; 125 | } 126 | } 127 | } else { 128 | current[this.path[pathIndex]] = value; 129 | } 130 | return _root; 131 | 132 | function toObject(current: any, path: PathComponent[]) { 133 | if (typeof current === 'object') { 134 | return current; 135 | } else if (value !== undefined) { 136 | if (typeof path[pathIndex + 1] === 'number') { 137 | return []; 138 | } else { 139 | return {}; 140 | } 141 | } else { 142 | return undefined; 143 | } 144 | } 145 | } 146 | 147 | static property(property: string): Path { 148 | return Path.ROOT.property(property); 149 | } 150 | 151 | static index(index: number): Path { 152 | return Path.ROOT.index(index); 153 | } 154 | 155 | static of(...path: PathComponent[]) { 156 | if (path.length === 0) { 157 | return Path.ROOT; 158 | } 159 | path.forEach(this.validateComponent); 160 | return new Path(path); 161 | } 162 | 163 | static validateComponent(component: any) { 164 | const type = typeof component; 165 | if (type === 'number') { 166 | if (component < 0 || !Number.isInteger(component as number)) { 167 | throw new Error('Expected component to be an integer >= 0'); 168 | } 169 | } else if (type !== 'string') { 170 | throw new Error(`Expected component to be a string or an integer, got ${type}: ${component}`); 171 | } 172 | } 173 | 174 | static validateIndex(index: any) { 175 | if (typeof index !== 'number') { 176 | throw new Error(`Expected index to be a number, got ${index}`); 177 | } 178 | if (index < 0 || !Number.isInteger(index)) { 179 | throw new Error('Expected index to be an integer >= 0'); 180 | } 181 | } 182 | 183 | static validateProperty(property: any) { 184 | if (typeof property !== 'string') { 185 | throw new Error(`Expected property to be a string, got ${property}`); 186 | } 187 | } 188 | 189 | static isValidIdentifier(str: string) { 190 | return identifierPattern.test(str); 191 | } 192 | 193 | static componentToString(component: PathComponent) { 194 | if (typeof component === 'number') { 195 | return Path.indexToString(component); 196 | } else { 197 | return Path.propertyToString(component); 198 | } 199 | } 200 | 201 | static indexToString(index: number) { 202 | return '[' + index + ']'; 203 | } 204 | 205 | static propertyToString(property: string) { 206 | if (Path.isValidIdentifier(property)) { 207 | return '.' + property; 208 | } else { 209 | // JsonPath uses single quotes, but that would require custom encoding of single quotes as JSON string encoding doesn't have escape for it 210 | return '[' + JSON.stringify(property) + ']'; 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /packages/diff/src/VersionInfo.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest'; 2 | import { AnyIndex, Path, PathMatcher } from '@finnair/path'; 3 | import { Diff } from './Diff.js'; 4 | import { VersionInfo, VersionInfoConfig } from './VersionInfo.js'; 5 | import { Change } from './DiffNode.js'; 6 | 7 | describe('VersionInfo', () => { 8 | const a: any = Object.freeze({ 9 | id: 1234, 10 | _timestamp: new Date(Date.UTC(2024, 8, 10)), 11 | name: Object.freeze({ 12 | first: 'first', 13 | }), 14 | 'null': null, 15 | 'undefined': undefined, 16 | }); 17 | 18 | const b: any = Object.freeze({ 19 | id: 12345, 20 | // _timestamp change is ignored 21 | _timestamp: new Date(Date.UTC(2024, 8, 12)), 22 | name: Object.freeze({ 23 | first: 'second', 24 | last: 'last', 25 | }), 26 | }); 27 | 28 | const config: VersionInfoConfig = { 29 | diffConfig: { 30 | isPrimitive: (value: any) => value instanceof Date, 31 | isEqual: (a: any, b: any) => { 32 | if (a instanceof Date && b instanceof Date) { 33 | return a.getTime() === b.getTime(); 34 | } 35 | return false; 36 | }, 37 | filter: (path: Path, value: any) => path.length === 0 || !String(path.componentAt(0)).startsWith('_'), 38 | }, 39 | previousValues: [PathMatcher.of('id')], 40 | }; 41 | // Support still deprecated diff parameter 42 | const altConfig: VersionInfoConfig = { diff: new Diff({ filter: (_path: Path, value: any) => !(value instanceof Date) }) }; 43 | 44 | const mapFn = (o: any) => { 45 | return { 46 | firstName: o.name.first, 47 | lastName: o.name.last, 48 | timestamp: o._timestamp, 49 | }; 50 | }; 51 | 52 | const asyncMapFn = async (o: any) => mapFn(o); 53 | 54 | const mappedChanges = new Map([ 55 | ['$.firstName', { path: Path.of('firstName'), oldValue: 'first', newValue: 'second' }], 56 | ['$.lastName', { path: Path.of('lastName'), newValue: 'last' }], 57 | ['$.timestamp', { path: Path.of('timestamp'), oldValue: a._timestamp, newValue: b._timestamp }], 58 | ]); 59 | 60 | const mappedB = { 61 | firstName: b.name.first, 62 | lastName: b.name.last, 63 | timestamp: b._timestamp, 64 | }; 65 | 66 | test('changes', async () => { 67 | const version = new VersionInfo(b, a, config); 68 | const expectedChangedPaths = new Set([ '$.id', '$.name.first', '$.name.last', '$.null', '$.undefined' ]); 69 | const extpectedPatch = [ 70 | { path: Path.of('id'), value: 12345 }, 71 | { path: Path.of('name', 'first'), value: 'second' }, 72 | { path: Path.of('name', 'last'), value: 'last' }, 73 | { path: Path.of('null') }, 74 | { path: Path.of('undefined') }, 75 | ]; 76 | 77 | expect(version.changes).toEqual(new Map([ 78 | ['$.id', { path: Path.of('id'), oldValue: 1234, newValue: 12345 }], 79 | ['$.name.first', { path: Path.of('name', 'first'), oldValue: 'first', newValue: 'second'}], 80 | ['$.name.last', { path: Path.of('name', 'last'), newValue: 'last' }], 81 | ['$.null', { path: Path.of('null'), oldValue: null }], 82 | ['$.undefined', { path: Path.of('undefined'), oldValue: undefined }], 83 | ])); 84 | expect(version.changedPaths).toEqual(expectedChangedPaths); 85 | expect(version.patch).toEqual(extpectedPatch); 86 | expect(version.paths).toEqual(version.changedPaths); 87 | expect(version.previousValues).toEqual({ id: 1234 }); 88 | expect(version.toJSON()).toEqual({ 89 | current: b, 90 | changedPaths: Array.from(expectedChangedPaths), 91 | previous: { 92 | id: 1234, 93 | }, 94 | }); 95 | expect(version.matches('$.name')).toBe(true); 96 | expect(version.matches('$.name.foo')).toBe(false); 97 | expect(version.matchesAny([PathMatcher.of('null'), '$.name.foo'])).toBe(true); 98 | expect(version.matchesAny(['$.foo', '$.name.foo'])).toBe(false); 99 | 100 | const mappedVersion = version.map(mapFn); 101 | expect(mappedVersion.changes).toEqual(mappedChanges); 102 | expect(mappedVersion.current).toEqual(mappedB); 103 | expect(mappedVersion.previousValues).toBeUndefined(); 104 | expect(version.map(mapFn, altConfig).changedPaths).toEqual(new Set(['$.firstName', '$.lastName'])); 105 | 106 | expect((await version.mapAsync(asyncMapFn, altConfig)).changedPaths).toEqual(new Set(['$.firstName', '$.lastName'])); 107 | }); 108 | 109 | test('apply changes', () => { 110 | const version = new VersionInfo(b, a, config); 111 | const aClone = structuredClone(a); 112 | expect(aClone).toEqual(a); 113 | 114 | version.changes!.forEach((change) => change.path.set(aClone, change.newValue)); 115 | 116 | expect(a).not.toEqual(b); 117 | aClone._timestamp = b._timestamp; 118 | expect(aClone).toEqual(b); 119 | }); 120 | 121 | test('apply patch', () => { 122 | const version = new VersionInfo(b, a, config); 123 | const aClone = structuredClone(a); 124 | expect(aClone).toEqual(a); 125 | 126 | version.patch.forEach((patch) => patch.path.set(aClone, patch.value)); 127 | 128 | expect(a).not.toEqual(b); 129 | aClone._timestamp = b._timestamp; 130 | expect(aClone).toEqual(b); 131 | }); 132 | 133 | test('no changes', () => { 134 | const c = {...b, _timestamp: new Date(Date.UTC(2024, 8, 13)) }; 135 | 136 | const version = new VersionInfo(c, b, config); 137 | 138 | expect(version.paths).toEqual(new Set()); 139 | expect(version.changedPaths).toEqual(version.paths); 140 | expect(version.patch).toEqual([]); 141 | expect(version.previousValues).toBeUndefined(); 142 | expect(version.toJSON()).toEqual({ current: c, changedPaths: [] }); 143 | expect(version.matches('$.name')).toBe(false); 144 | expect(version.matchesAny(['$.null', '$.name.foo'])).toBe(false); 145 | }); 146 | 147 | test('first version', async () => { 148 | const version = new VersionInfo(a, undefined, config); 149 | 150 | expect(version.changedPaths).toBeUndefined(); 151 | expect(version.paths).toEqual(new Set(['$', '$.id', '$.name.first', '$.null', '$.undefined'])); 152 | expect(version.changes).toBeUndefined(); 153 | expect(version.patch).toEqual([{ path: Path.ROOT, value: a }]) 154 | expect(version.matches('$.name')).toBe(true); 155 | expect(version.matchesAny(['$.foo', '$.name'])).toBe(true); 156 | expect(version.previousValues).toBeUndefined(); 157 | 158 | const mappedVersion = version.map(mapFn); 159 | expect(mappedVersion.changes).toEqual(undefined); 160 | expect(mappedVersion.current).toEqual({ 161 | firstName: a.name.first, 162 | lastName: undefined, 163 | timestamp: a._timestamp, 164 | }); 165 | expect((await version.mapAsync(asyncMapFn)).paths).toEqual(new Set(['$', '$.firstName', '$.lastName', '$.timestamp'])); 166 | }); 167 | 168 | test('no previousValues matcher', () => { 169 | expect(new VersionInfo(b, a).previousValues).toBeUndefined(); 170 | }); 171 | 172 | test('array as root', () => { 173 | expect(new VersionInfo([2], [1], { 174 | // Default diff/diffConfig 175 | previousValues: [PathMatcher.of(AnyIndex)], 176 | }).toJSON()).toEqual({ 177 | current: [2], 178 | changedPaths: ['$[0]'], 179 | previous: [1], 180 | }) 181 | }) 182 | }); 183 | -------------------------------------------------------------------------------- /packages/core/src/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Validator, 3 | ValidationContext, 4 | ValidationResult, 5 | isNullOrUndefined, 6 | defaultViolations, 7 | isString, 8 | Violation, 9 | TypeMismatch, 10 | } from './validators.js'; 11 | import { 12 | ObjectValidator, 13 | PropertyModel, 14 | MapEntryModel, 15 | ObjectModel, 16 | } from './objectValidator.js'; 17 | import { Path } from '@finnair/path'; 18 | 19 | export interface DiscriminatorFn { 20 | (value: any): string; 21 | } 22 | 23 | export type Discriminator = string | DiscriminatorFn; 24 | 25 | export interface SchemaModel { 26 | readonly discriminator: Discriminator; 27 | readonly models: { [name: string]: Validator | ClassModel }; 28 | } 29 | 30 | export type ClassParentModel = string | ObjectValidator | (string | ObjectValidator)[]; 31 | 32 | export interface ClassModel { 33 | readonly extends?: ClassParentModel; 34 | readonly localProperties?: PropertyModel; 35 | readonly properties?: PropertyModel; 36 | readonly additionalProperties?: boolean | MapEntryModel | MapEntryModel[]; 37 | readonly next?: Validator; 38 | readonly localNext?: Validator; 39 | } 40 | 41 | export class ModelRef extends Validator { 42 | constructor(private schema: SchemaValidator, public readonly name: string) { 43 | super(); 44 | Object.freeze(this); 45 | } 46 | validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { 47 | return this.schema.validateClass(value, path, ctx, this.name); 48 | } 49 | } 50 | 51 | export class DiscriminatorViolation extends Violation { 52 | constructor(path: Path, value: any, public readonly expectedOneOf: string[]) { 53 | super(path, 'Discriminator', value); 54 | } 55 | } 56 | 57 | export class SchemaValidator extends Validator { 58 | public readonly discriminator: Discriminator; 59 | 60 | private readonly proxies = new Map(); 61 | 62 | private readonly validators: { [name: string]: Validator } = {}; 63 | 64 | constructor(fn: (schema: SchemaValidator) => SchemaModel) { 65 | super(); 66 | const schema = fn(this); 67 | for (const name of this.proxies.keys()) { 68 | if (!schema.models[name]) { 69 | throw new Error('Undefined named model: ' + name); 70 | } 71 | } 72 | this.discriminator = schema.discriminator; 73 | this.compileSchema(schema.models, new Set()); 74 | 75 | Object.freeze(this.validators); 76 | Object.freeze(this); 77 | } 78 | 79 | validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { 80 | return this.validateClass(value, path, ctx); 81 | } 82 | 83 | validateClass(value: any, path: Path, ctx: ValidationContext, expectedType?: string): PromiseLike { 84 | if (isNullOrUndefined(value)) { 85 | return Promise.reject(defaultViolations.notNull(path)); 86 | } 87 | // 1) Validate discriminator 88 | let type: string; 89 | let typePath: Path = path; 90 | if (isString(this.discriminator)) { 91 | type = value[this.discriminator as string]; 92 | typePath = path.property(this.discriminator as string); 93 | } else { 94 | type = (this.discriminator as DiscriminatorFn)(value); 95 | } 96 | const validator = this.validators[type]; 97 | if (!validator) { 98 | return Promise.reject(new DiscriminatorViolation(typePath, type, Object.keys(this.validators))); 99 | } 100 | 101 | // 2) Validate that the type is assignable to the expected type (polymorphism) 102 | if (expectedType) { 103 | const expectedParent = this.validators[expectedType]; 104 | if (!this.isSubtypeOf(validator, expectedParent)) { 105 | return Promise.reject(new TypeMismatch(path, expectedType, type)); 106 | } 107 | } 108 | 109 | // 3) Validate value 110 | return validator.validatePath(value, path, ctx); 111 | } 112 | 113 | of(name: string): Validator { 114 | if (!this.proxies.has(name)) { 115 | if (this.discriminator && !this.validators[name]) { 116 | throw new Error(`Unknown model: ${name}`); 117 | } 118 | this.proxies.set(name, new ModelRef(this, name)); 119 | } 120 | return this.proxies.get(name)!; 121 | } 122 | 123 | raw(name: string): Validator { 124 | if (!this.validators[name]) { 125 | throw new Error(`Validator not found: ${name}`); 126 | } 127 | return this.validators[name]!; 128 | } 129 | 130 | private isSubtypeOf(validator: Validator, expectedParent: Validator): boolean { 131 | // Either validator is expectedParent itself... 132 | if (validator === expectedParent) { 133 | return true; 134 | } 135 | // ...or it extends the expectedParent 136 | if (validator instanceof ObjectValidator) { 137 | return validator.parentValidators.some(parent => this.isSubtypeOf(parent, expectedParent)); 138 | } 139 | return false; 140 | } 141 | 142 | private compileSchema(models: { [name: string]: Validator | ClassModel }, seen: Set) { 143 | for (const name in models) { 144 | this.compileClass(name, models, seen); 145 | } 146 | } 147 | 148 | private compileClass(name: string, models: { [name: string]: Validator | ClassModel }, seen: Set): Validator { 149 | if (seen.has(name)) { 150 | if (this.validators[name]) { 151 | return this.validators[name]; 152 | } 153 | throw new Error(`Cyclic dependency: ${name}`); 154 | } 155 | seen.add(name); 156 | 157 | let validator: Validator; 158 | if (models[name] instanceof Validator) { 159 | validator = models[name] as Validator; 160 | } else { 161 | const classModel = models[name] as ClassModel; 162 | const localProperties = classModel.localProperties || {}; 163 | if (isString(this.discriminator)) { 164 | const discriminatorProperty: string = this.discriminator as string; 165 | if (!localProperties[discriminatorProperty]) { 166 | localProperties[discriminatorProperty] = name; 167 | } 168 | } 169 | const model: ObjectModel = { 170 | extends: this.getParentValidators(classModel.extends, models, seen), 171 | properties: classModel.properties, 172 | additionalProperties: classModel.additionalProperties, 173 | localProperties, 174 | next: classModel.next, 175 | localNext: classModel.localNext, 176 | }; 177 | validator = new ObjectValidator(model); 178 | } 179 | this.validators[name] = validator; 180 | return validator; 181 | } 182 | 183 | getParentValidators(parents: undefined | ClassParentModel, models: { [name: string]: Validator | ClassModel }, seen: Set): ObjectValidator[] { 184 | let parentValidators: any = []; 185 | if (parents) { 186 | parentValidators = parentValidators.concat(parents); 187 | } 188 | return parentValidators.map((nameOrValidator: any) => { 189 | let parent: Validator; 190 | if (isString(nameOrValidator)) { 191 | parent = this.compileClass(nameOrValidator as string, models, seen); 192 | } else { 193 | parent = nameOrValidator as Validator; 194 | } 195 | if (!(parent instanceof ObjectValidator)) { 196 | throw new Error(`Illegal inheritance: objects may only inherit from other objects (ObjectValidators)`); 197 | } 198 | return parent as ObjectValidator; 199 | }); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /packages/luxon/README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://badge.fury.io/js/%40finnair%2Fv-validation-luxon.svg)](https://badge.fury.io/js/%40finnair%2Fv-validation-luxon) 2 | 3 | # v-validation-luxon 4 | 5 | `@finnair/v-validation-luxon` is an extension to `@finnair/v-validation`. 6 | 7 | `Vluxon` extension provides custom wrapper types for Luxon DateTime to support full JSON roundtrip with strict validation. 8 | Also plain DateTime validators are provided and custom formats are easy to define (see the code for examples). 9 | 10 | [Documentation for `v-validation`](https://github.com/finnair/v-validation). 11 | 12 | ## Getting Started 13 | 14 | Install v-validation using [`yarn`](https://yarnpkg.com): 15 | 16 | ```bash 17 | yarn add @finnair/v-validation-luxon 18 | ``` 19 | 20 | Or [`npm`](https://www.npmjs.com/): 21 | 22 | ```bash 23 | npm install @finnair/v-validation-luxon 24 | ``` 25 | 26 | ## Vluxon 27 | 28 | Vluxon contains both plain DateTime validators and also validators that return 29 | DateTime wrappers that guarantee some normalizations (zone, date and/or time) and 30 | especially JSON serialization in the given format. The wrappers are immutable and 31 | they allow easy access to the DateTime instance for further processing. 32 | `validateLuxon` function can be used to build custom DateTime validators/converters 33 | by supplying a RegExp pattern and a parser function. 34 | 35 | ## Supported DateTime Wrapper Types 36 | 37 | | Class | Description | 38 | | ---------------------- | ----------------------------------------------------------------------------------------------------------- | 39 | | LuxonDateTime | Abstract base class for the wrappers. | 40 | | LocalDateLuxon | Input and JSON output in `yyyy-MM-dd` format. Time normalized to midnight UTC. | 41 | | LocalTimeLuxon | Input and JSON output in `HH:mm:ss` format. Date normalized to 1970-01-01 UTC (Unix Epoch). | 42 | | DateTimeLuxon | Input and JSON output in `yyyy-MM-ddTHH:mm:ssZ` format in local/given/parsed zone with milliseconds zeroed. | 43 | | DateTimeUtcLuxon | Input and JSON output in `yyyy-MM-ddTHH:mm:ssZ` format in UTC zone with milliseconds zeroed. | 44 | | DateTimeMillisLuxon | Input and JSON output in `yyyy-MM-ddTHH:mm:ss.SSSZ` format in local/given/parsed zone. | 45 | | DateTimeMillisUtcLuxon | Input and JSON output in `yyyy-MM-ddTHH:mm:ss.SSSZ` format in UTC zone. | 46 | 47 | ### Constructors 48 | 49 | Wrapper types may be constructed with `new` from DateTime instance, but there are also shortcuts: 50 | 51 | | Static Method | Description | 52 | | ---------------------------------------------------------------------- | --------------------------------------------------------------- | 53 | | `now()` | Current time in `DateTime*` types. | 54 | | `nowUtc()` | Current UTC time in `Local*` types. | 55 | | `nowLocal(options?: DateTimeJSOptions)` | Current local time in `Local*` types (defaults to system zone). | 56 | | `fromISO(value: string, options?: DateTimeOptions)` | Parse from ISO format (see Luxon `DateTime.fromISO`). | 57 | | `fromFormat(value: string, format: string, options?: DateTimeOptions)` | Parse from custom format (see Luxon `DateTime.fromFormat`). | 58 | | `fromJSDate(date: Date, options?: { zone?: string \| Zone })` | From JavaScript Date object (see Luxon `DateTime.fromJSDate`). | 59 | | `fromMillis(millis: number, options?: DateTimeJSOptions)` | From Unix millis timestamp (see Luxon `DateTime.fromMillis`). | 60 | 61 | ### Instance Methods 62 | 63 | Wrappers are meant to be as thin as possible with most of the DateTime functionality accessed directly from the wrapped 64 | DateTime instance which is public readonly fiedl. However there are a few convenience methods for working with the wrapper types: 65 | 66 | | Method | Description | 67 | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | 68 | | `as(type)` | Conversion to the given type that extends LuxonDateTime. Note that the conversion is not guaranted to be lossless. | 69 | | `wrap(fn: (dateTime: DateTime) => DateTime)` | Executes the given function on the wrapped DateTime and rewraps the result. | 70 | | `apply(fn: (dateTime: DateTime) => R): R` | Executes the given function on the wrapped DateTime and returns the result as it is. | 71 | | `valueOf()` | Conversion to millis. This allows comparing wrapper types directly using `==`, `<`, `>`, `<=` or `>=`. | 72 | | `equals(other: any)` | Type-aware equality. | 73 | | `toJSON()` | Type-specific serialization (string). | 74 | 75 | ## Build-in Validators 76 | 77 | | Vluxon. | Format | Description | 78 | | ------------------- | -------------------------------- | --------------------------------------------------------- | 79 | | localDate | `yyyy-MM-dd` | Local date (time normalized to midninght UTC). | 80 | | localTime | `HH:mm:ss` | Local time (date normalized to 1970-01-01). | 81 | | dateTime | `yyyy-MM-ddTHH:mm:ssZ` | Date and time in local (parsed) time zone. | 82 | | dateTimeUtc | `yyyy-MM-ddTHH:mm:ssZ` | Date and time in UTC time zone. | 83 | | dateTimeMillis | `yyyy-MM-ddTHH:mm:ss.SSSZ` | Date and time with millis in local (parsed) time zone. | 84 | | dateTimeMillisUtc | `yyyy-MM-ddTHH:mm:ss.SSSZ` | Date and time with millis in UTC time zone. | 85 | | dateTimeFromISO | Any ISO | Plain Luxon DateTime from ISO format. | 86 | | dateTimeFromRFC2822 | RFC2822 | Plain Luxon DateTime from RFC2822 format. | 87 | | dateTimeFromHTTP | HTTP date-time | Plain Luxon DateTime from HTTP format. | 88 | | dateTimeFromSQL | SQL date-time | Plain Luxon DateTime from SQL format. | 89 | | dateTimeFromSeconds | Unix timestamp (number) | Plain Luxon DateTime from Unix timestamp in seconds. | 90 | | dateTimeFromMillis | Unix timestamp (number) | Plain Luxon DateTime from Unix timestamp in milliseconds. | 91 | | duration | ISO 8601 Duration | Luxon `Duration.fromISO` with pattern validation. | 92 | | timeDuration | ISO 8601 time string as Duration | Luxon `Duration.fromISOTime`. | 93 | -------------------------------------------------------------------------------- /packages/diff/src/DiffNode.ts: -------------------------------------------------------------------------------- 1 | import { Path } from "@finnair/path"; 2 | 3 | export interface DiffNodeConfig { 4 | readonly filter?: DiffFilter; 5 | readonly isPrimitive?: (value: any, path: Path) => boolean; 6 | readonly isEqual?: (a: any, b: any, path: Path) => boolean; 7 | } 8 | 9 | export interface DiffFilter { 10 | (path: Path, value: any): boolean; 11 | } 12 | 13 | export interface Patch { 14 | readonly path: Path; 15 | readonly value?: any; 16 | } 17 | 18 | export interface Change { 19 | readonly path: Path; 20 | readonly newValue?: any; 21 | readonly oldValue?: any; 22 | } 23 | 24 | export const defaultDiffFilter = (_path: Path, value: any) => value !== undefined; 25 | 26 | export type ValueType = 'primitive' | 'object' | 'array' | undefined; 27 | 28 | const primitiveTypes: any = { 29 | 'boolean': true, 30 | 'number': true, 31 | 'string': true, 32 | 'bigint': true, 33 | 'symbol': true, 34 | }; 35 | 36 | function isPrimitive(value: any) { 37 | return value === null || value === undefined || !!primitiveTypes[typeof value]; 38 | } 39 | 40 | interface DiffNodeProps { 41 | path?: Path, 42 | oldValue?: any, 43 | newValue?: any, 44 | } 45 | 46 | export class DiffNode { 47 | public readonly path: Path; 48 | public readonly oldType: ValueType; 49 | public readonly oldValue?: any; 50 | public readonly newType: ValueType; 51 | public readonly newValue?: any; 52 | private _children: undefined | Map; 53 | constructor( 54 | props: DiffNodeProps, 55 | private readonly config?: DiffNodeConfig 56 | ) { 57 | this.path = props.path ?? Path.ROOT; 58 | this.oldType = 'oldValue' in props ? this.getValueType(props.oldValue) : undefined; 59 | this.oldValue = props.oldValue; 60 | this.newType = 'newValue' in props ? this.getValueType(props.newValue) : undefined; 61 | this.newValue = props.newValue; 62 | } 63 | 64 | get children(): undefined | Map { 65 | if (this.isComposite && this._children === undefined) { 66 | const nodeProps = new Map(); 67 | if (isCompositeType(this.oldType)) { 68 | this.collectNodes(this.oldType, this.oldValue, 'oldValue', nodeProps); 69 | } 70 | if (isCompositeType(this.newType)) { 71 | this.collectNodes(this.newType, this.newValue, 'newValue', nodeProps); 72 | } 73 | this._children = new Map(); 74 | for (const [key, props] of nodeProps.entries()) { 75 | this._children.set(key, new DiffNode(props, this.config)); 76 | } 77 | } 78 | return this._children; 79 | } 80 | 81 | getScalarChange(includeObjects = false): undefined | Change { 82 | if (this.isScalarChange(includeObjects)) { 83 | const change: { -readonly [P in keyof Change]: Change[P] } = { 84 | path: this.path, 85 | }; 86 | if (this.oldType) { 87 | change.oldValue = scalarValue(this.oldType, this.oldValue); 88 | } 89 | if (this.newType) { 90 | change.newValue = scalarValue(this.newType, this.newValue); 91 | } 92 | return change as Change; 93 | } 94 | return undefined; 95 | } 96 | 97 | isScalarChange(includeObjects = false) { 98 | return this.isChange && (this.isPrimitive || (this.isComposite && includeObjects)); 99 | } 100 | 101 | getScalarChanges(includeObjects = false): Iterable { 102 | return { 103 | [Symbol.iterator]: () => scalarGenerator(this, includeObjects) 104 | }; 105 | } 106 | 107 | get patch(): Iterable { 108 | return { 109 | [Symbol.iterator]: () => patchGenerator(this) 110 | }; 111 | } 112 | 113 | getChangedPaths(includeObjects = false): Iterable { 114 | return { 115 | [Symbol.iterator]: () => changedPathGenerator(this, includeObjects) 116 | }; 117 | } 118 | 119 | get isChange(): boolean { 120 | if (this.newType === this.oldType) { 121 | if (this.newValue === this.oldValue) { 122 | return false; 123 | } else if (this.newType === 'primitive') { 124 | return !this.config?.isEqual?.(this.oldValue, this.newValue, this.path); 125 | } 126 | // both are objects or arrays 127 | return false; 128 | } 129 | // Type has changed! 130 | return true; 131 | } 132 | 133 | get isPrimitive(): boolean { 134 | return isPrimitiveType(this.oldType) || isPrimitiveType(this.newType); 135 | } 136 | 137 | get isComposite(): boolean { 138 | return isCompositeType(this.oldType) || isCompositeType(this.newType); 139 | } 140 | 141 | private collectNodes(type: 'object' | 'array', value: any, role: 'oldValue' | 'newValue', nodeProps: Map) { 142 | const register = (key: string | number, value: any) => { 143 | const props = nodeProps.get(key); 144 | if (props) { 145 | props[role] = value; 146 | } else { 147 | nodeProps.set(key, { path: this.path.child(key), [role]: value}); 148 | } 149 | }; 150 | 151 | if (type === 'object') { 152 | Object.entries(value).forEach(([key, value]) => register(key, value)); 153 | } else { 154 | value.forEach((value: any, index: number) => register(index, value)); 155 | } 156 | } 157 | 158 | private getValueType(value: any): undefined | ValueType { 159 | const filter = this.config?.filter ?? defaultDiffFilter; 160 | if (!filter(this.path, value)) { 161 | return undefined; 162 | } 163 | if (isPrimitive(value) || this.config?.isPrimitive?.(value, this.path)) { 164 | return 'primitive'; 165 | } 166 | const compositeType = arrayOrPlainObject(value); 167 | if (compositeType) { 168 | return compositeType; 169 | } 170 | throw new Error(`only primitives, arrays and plain objects are supported, got "${value?.constructor.name}"`); 171 | } 172 | } 173 | 174 | function scalarValue(valueType: ValueType, value: any) { 175 | switch (valueType) { 176 | case 'object': return {}; 177 | case 'array': return []; 178 | default: return value; 179 | } 180 | } 181 | 182 | export function arrayOrPlainObject(value: any): undefined | 'array' | 'object' { 183 | if (value && typeof value === 'object') { 184 | if (Array.isArray(value)) { 185 | return 'array'; 186 | } else if (value.constructor === Object) { 187 | return 'object'; 188 | } 189 | } 190 | return undefined; 191 | } 192 | 193 | function isPrimitiveType(valueType: ValueType): valueType is 'primitive' | undefined { 194 | return valueType === 'primitive'; 195 | } 196 | 197 | function isCompositeType(valueType: ValueType): valueType is 'object' | 'array' { 198 | return valueType === 'object' || valueType === 'array'; 199 | } 200 | 201 | function* scalarGenerator(node: DiffNode, includeObjects = false): Generator { 202 | const change = node.getScalarChange(includeObjects); 203 | if (change) { 204 | yield change; 205 | } 206 | if (node.children) { 207 | for (const child of node.children.values()) { 208 | yield* scalarGenerator(child, includeObjects); 209 | } 210 | } 211 | } 212 | 213 | function* changedPathGenerator(node: DiffNode, includeObjects = false): Generator { 214 | if (node.isScalarChange(includeObjects)) { 215 | yield node.path; 216 | } 217 | if (node.children) { 218 | for (const child of node.children.values()) { 219 | yield* changedPathGenerator(child, includeObjects); 220 | } 221 | } 222 | } 223 | 224 | function* patchGenerator(node: DiffNode): Generator { 225 | if (node.isChange) { 226 | if (node.newType === undefined) { 227 | yield { path: node.path } 228 | } else { 229 | yield { path: node.path, value: node.newValue } 230 | } 231 | } else if (node.children) { 232 | for (const child of node.children.values()) { 233 | yield* patchGenerator(child); 234 | } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /packages/moment/src/Vmoment.ts: -------------------------------------------------------------------------------- 1 | import { Validator, ValidationContext, isNullOrUndefined, defaultViolations, isString, TypeMismatch } from '@finnair/v-validation'; 2 | import { Path } from '@finnair/path'; 3 | import moment, { Moment, MomentInput } from 'moment'; 4 | 5 | export class MomentValidator extends Validator { 6 | constructor(public readonly type: string, public readonly parse: (value?: MomentInput) => Moment) { 7 | super(); 8 | Object.freeze(this); 9 | } 10 | async validatePath(value: string | Moment, path: Path, ctx: ValidationContext): Promise { 11 | if (isNullOrUndefined(value)) { 12 | return Promise.reject(defaultViolations.notNull(path)); 13 | } 14 | if (isString(value) || moment.isMoment(value)) { 15 | const convertedValue = this.parse(value); 16 | if (convertedValue.isValid()) { 17 | return Promise.resolve(convertedValue); 18 | } 19 | } 20 | return Promise.reject(defaultViolations.date(value, path, this.type)); 21 | } 22 | } 23 | 24 | const durationPattern = 25 | /^P(?!$)(\d+(?:\.\d+)?Y)?(\d+(?:\.\d+)?M)?(\d+(?:\.\d+)?W)?(\d+(?:\.\d+)?D)?(T(?=\d)(\d+(?:\.\d+)?H)?(\d+(?:\.\d+)?M)?(\d+(?:\.\d+)?S)?)?$/; 26 | export class DurationValidator extends Validator { 27 | async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { 28 | if (isNullOrUndefined(value)) { 29 | return Promise.reject(defaultViolations.notNull(path)); 30 | } 31 | if ((isString(value) && durationPattern.test(value)) || moment.isDuration(value)) { 32 | const convertedValue = moment.duration(value); 33 | if (convertedValue.isValid()) { 34 | return Promise.resolve(convertedValue); 35 | } 36 | } 37 | return Promise.reject(new TypeMismatch(path, 'Duration', value)); 38 | } 39 | } 40 | 41 | function maybeDateFormat(value?: MomentInput, dateFormat?: string) { 42 | return isString(value) ? dateFormat : undefined; 43 | } 44 | 45 | const dateFormat = 'YYYY-MM-DD'; 46 | export function dateMoment(value?: MomentInput): Moment { 47 | return Object.setPrototypeOf(moment(value, maybeDateFormat(value, dateFormat), true), dateMoment.prototype); 48 | } 49 | Object.setPrototypeOf(dateMoment.prototype, moment.prototype); 50 | Object.setPrototypeOf(dateMoment, moment); 51 | dateMoment.prototype.toJSON = function toJSON() { 52 | return this.format(dateFormat); 53 | }; 54 | dateMoment.prototype.clone = function clone() { 55 | return dateMoment(this); 56 | }; 57 | 58 | export function dateUtcMoment(value?: MomentInput): Moment { 59 | return Object.setPrototypeOf(moment.utc(value, maybeDateFormat(value, dateFormat), true), dateUtcMoment.prototype); 60 | } 61 | Object.setPrototypeOf(dateUtcMoment.prototype, moment.prototype); 62 | Object.setPrototypeOf(dateUtcMoment, moment); 63 | dateUtcMoment.prototype.toJSON = function toJSON() { 64 | return this.format(dateFormat); 65 | }; 66 | dateUtcMoment.prototype.clone = function clone() { 67 | return dateUtcMoment(this); 68 | }; 69 | 70 | const dateTimeFormat = 'YYYY-MM-DDTHH:mm:ss'; 71 | const dateTimeFormatTz = dateTimeFormat + 'Z'; 72 | export function dateTimeMoment(value?: MomentInput): Moment { 73 | return Object.setPrototypeOf(moment.parseZone(value, maybeDateFormat(value, dateTimeFormatTz), true), dateTimeMoment.prototype); 74 | } 75 | Object.setPrototypeOf(dateTimeMoment.prototype, moment.prototype); 76 | Object.setPrototypeOf(dateTimeMoment, moment); 77 | dateTimeMoment.prototype.toJSON = function toJSON() { 78 | if (this.utcOffset() === 0) { 79 | return this.format(dateTimeFormat) + 'Z'; 80 | } 81 | return this.format(dateTimeFormatTz); 82 | }; 83 | dateTimeMoment.prototype.clone = function clone() { 84 | return dateTimeMoment(this); 85 | }; 86 | 87 | export function dateTimeUtcMoment(value?: MomentInput): Moment { 88 | return Object.setPrototypeOf(moment.utc(value, maybeDateFormat(value, dateTimeFormatTz), true), dateTimeUtcMoment.prototype); 89 | } 90 | Object.setPrototypeOf(dateTimeUtcMoment.prototype, moment.prototype); 91 | Object.setPrototypeOf(dateTimeUtcMoment, moment); 92 | dateTimeUtcMoment.prototype.toJSON = function toJSON() { 93 | return this.format(dateTimeFormat) + 'Z'; 94 | }; 95 | dateTimeUtcMoment.prototype.clone = function clone() { 96 | return dateTimeUtcMoment(this); 97 | }; 98 | dateTimeUtcMoment.prototype.utcOffset = function utcOffset(offset?: number) { 99 | if (offset === undefined) { 100 | return moment.prototype.utcOffset.call(this); 101 | } 102 | const copy = dateTimeMoment(this); 103 | copy.utcOffset(offset); 104 | return copy; 105 | }; 106 | 107 | const dateTimeMillisFormat = 'YYYY-MM-DDTHH:mm:ss.SSS'; 108 | const dateTimeMillisFormatTz = dateTimeMillisFormat + 'Z'; 109 | export function dateTimeMillisMoment(value?: MomentInput): Moment { 110 | return Object.setPrototypeOf(moment.parseZone(value, maybeDateFormat(value, dateTimeMillisFormatTz), true), dateTimeMillisMoment.prototype); 111 | } 112 | Object.setPrototypeOf(dateTimeMillisMoment.prototype, moment.prototype); 113 | Object.setPrototypeOf(dateTimeMillisMoment, moment); 114 | dateTimeMillisMoment.prototype.toJSON = function toJSON() { 115 | if (this.utcOffset() === 0) { 116 | return this.format(dateTimeMillisFormat) + 'Z'; 117 | } 118 | return this.format(dateTimeMillisFormatTz); 119 | }; 120 | dateTimeMillisMoment.prototype.clone = function clone() { 121 | return dateTimeMillisMoment(this); 122 | }; 123 | 124 | export function dateTimeMillisUtcMoment(value?: MomentInput): Moment { 125 | return Object.setPrototypeOf(moment.utc(value, maybeDateFormat(value, dateTimeMillisFormatTz), true), dateTimeMillisUtcMoment.prototype); 126 | } 127 | Object.setPrototypeOf(dateTimeMillisUtcMoment.prototype, moment.prototype); 128 | Object.setPrototypeOf(dateTimeMillisUtcMoment, moment); 129 | dateTimeMillisUtcMoment.prototype.toJSON = function toJSON() { 130 | return this.format(dateTimeMillisFormat) + 'Z'; 131 | }; 132 | dateTimeMillisUtcMoment.prototype.clone = function clone() { 133 | return dateTimeMillisUtcMoment(this); 134 | }; 135 | dateTimeMillisUtcMoment.prototype.utcOffset = function utcOffset(offset?: number) { 136 | if (offset === undefined) { 137 | return moment.prototype.utcOffset.call(this); 138 | } 139 | const copy = dateTimeMillisMoment(this); 140 | copy.utcOffset(offset); 141 | return copy; 142 | }; 143 | 144 | const timeFormat = 'HH:mm:ss'; 145 | export function timeMoment(value?: MomentInput): Moment { 146 | return Object.setPrototypeOf(moment.parseZone(value, maybeDateFormat(value, timeFormat), true), timeMoment.prototype); 147 | } 148 | Object.setPrototypeOf(timeMoment.prototype, moment.prototype); 149 | Object.setPrototypeOf(timeMoment, moment); 150 | timeMoment.prototype.toJSON = function toJSON() { 151 | return this.format(timeFormat); 152 | }; 153 | timeMoment.prototype.clone = function clone() { 154 | return timeMoment(this); 155 | }; 156 | 157 | const dateValidator = new MomentValidator('Date', dateMoment), 158 | dateUtcValidator = new MomentValidator('Date', dateUtcMoment), 159 | dateTimeValidator = new MomentValidator('DateTime', dateTimeMoment), 160 | dateTimeUtcValidator = new MomentValidator('DateTime', dateTimeUtcMoment), 161 | dateTimeMillisValidator = new MomentValidator('DateTimeMillis', dateTimeMillisMoment), 162 | dateTimeMillisUtcValidator = new MomentValidator('DateTimeMillis', dateTimeMillisUtcMoment), 163 | timeValidator = new MomentValidator('Time', timeMoment), 164 | durationValidator = new DurationValidator(); 165 | 166 | export const Vmoment = { 167 | date: () => dateValidator, 168 | dateUtc: () => dateUtcValidator, 169 | dateTime: () => dateTimeValidator, 170 | dateTimeUtc: () => dateTimeUtcValidator, 171 | dateTimeMillis: () => dateTimeMillisValidator, 172 | dateTimeMillisUtc: () => dateTimeMillisUtcValidator, 173 | time: () => timeValidator, 174 | duration: () => durationValidator, 175 | }; 176 | Object.freeze(Vmoment); 177 | -------------------------------------------------------------------------------- /packages/path/src/Path.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { Path, PathComponent } from './Path.js'; 3 | import { PathMatcher } from './PathMatcher.js'; 4 | import { AnyProperty, AnyIndex } from './matchers.js'; 5 | 6 | describe('path', () => { 7 | test('toJSON', () => expect(Path.property('s p a c e s').index(5).property('regular').toJSON()).toEqual('$["s p a c e s"][5].regular')); 8 | 9 | test('Weird properties', () => 10 | expect(Path.property('@foo').property('a5').property('http://xmlns.com/foaf/0.1/name').toJSON()).toEqual('$["@foo"].a5["http://xmlns.com/foaf/0.1/name"]')); 11 | 12 | describe('of', () => { 13 | test('equal to path constructed by builder', () => expect(Path.of(0, 'foo')).toEqual(Path.index(0).property('foo'))); 14 | 15 | test('without root', () => expect(Path.of(0, 'foo')).toEqual(Path.index(0).property('foo'))); 16 | 17 | test('alias for root', () => expect(Path.of()).toEqual(Path.ROOT)); 18 | }); 19 | 20 | describe('iterable path', () => { 21 | test('use path in for..of', () => { 22 | const components: PathComponent[] = []; 23 | for (const component of Path.of(0, 'foo', 'bar')) { 24 | components.push(component); 25 | } 26 | expect(components).toEqual([0, 'foo', 'bar']); 27 | }); 28 | 29 | test('use path in Array.from', () => { 30 | expect(Array.from(Path.of(0, 'foo', 'bar'))).toEqual([0, 'foo', 'bar']); 31 | }); 32 | 33 | test('componentAt', () => { 34 | const path = Path.of('foo', 0, 'bar'); 35 | expect(path.componentAt(0)).toEqual('foo'); 36 | expect(path.componentAt(1)).toEqual(0); 37 | expect(path.componentAt(2)).toEqual('bar'); 38 | }); 39 | 40 | test('length', () => { 41 | expect(Path.of('foo', 0, 'bar').length).toEqual(3); 42 | }); 43 | }); 44 | 45 | describe('get', () => { 46 | test('get root', () => expect(Path.ROOT.get('root')).toEqual('root')); 47 | 48 | test('get property of root', () => expect(Path.of('name').get({ name: 'name' })).toEqual('name')); 49 | 50 | test('get property of child', () => expect(Path.of('child', 'name').get({ child: { name: 'name' } })).toEqual('name')); 51 | 52 | test('get index of root', () => expect(Path.of(1).get([1, 2])).toEqual(2)); 53 | 54 | test('get index of of child', () => expect(Path.of('child', 1).get({ child: [1, 2] })).toEqual(2)); 55 | 56 | test('get property of string', () => expect(Path.of('length').get('string')).toBeUndefined()); 57 | 58 | test('get property of nested string', () => expect(Path.of('child', 'name', 'length').get({ child: { name: 'string' } })).toBeUndefined()); 59 | 60 | test('get index of string', () => expect(Path.of(0).get('string')).toBeUndefined()); 61 | 62 | test('get index of nested string', () => expect(Path.of('child', 'name', 0).get({ child: { name: 'string' } })).toBeUndefined()); 63 | }); 64 | 65 | describe('set', () => { 66 | test('set root', () => expect(Path.of().set('root', { foo: 'baz' })).toEqual({ foo: 'baz' })); 67 | 68 | test('creates necessary nested objects', () => expect(Path.of(0, 'array', 1, 'name').set([], 'name')).toEqual([{ array: [undefined, { name: 'name' }] }])); 69 | 70 | test("doesn't replace root object with array", () => 71 | expect(Path.of(0, 'array', 1, 'name').set({}, 'name')).toEqual({ 0: { array: [undefined, { name: 'name' }] } })); 72 | 73 | test('creates root object if necessary', () => 74 | expect(Path.of(0, 'array', 1, 'name').set(undefined, 'name')).toEqual([{ array: [undefined, { name: 'name' }] }])); 75 | 76 | test('truncates undefined tail from an array', () => { 77 | const obj = { array: [1, undefined, 3] }; 78 | Path.of('array', 2).set(obj, undefined); 79 | expect(obj).toEqual({ array: [1] }); 80 | expect(obj.array.length).toBe(1); 81 | expect(obj.array[2]).toBeUndefined(); 82 | }); 83 | 84 | test('does not create undefined intermediate', () => { 85 | expect('nested' in Path.of('nested', 'value').set({}, undefined)).toBe(false); 86 | }); 87 | }); 88 | 89 | describe('unset', () => { 90 | test('deletes property when setting undefined value', () => expect(Path.of('name').unset({ name: 'name' })).toEqual({})); 91 | 92 | test("delete doesn't create intermediate objects", () => expect(Path.of('nested', 'name').unset({})).toEqual({})); 93 | }); 94 | 95 | test('connectTo', () => { 96 | const parent = Path.of('parent'); 97 | const child = Path.of('child'); 98 | expect(Array.from(child.connectTo(parent))).toEqual(['parent', 'child']); 99 | }); 100 | 101 | test('concat', () => { 102 | const parent = Path.of('parent'); 103 | const child = Path.of('child'); 104 | expect(Array.from(parent.concat(child))).toEqual(['parent', 'child']); 105 | }); 106 | 107 | test('parent', () => { 108 | const path = Path.of('parent', 'nested', 0); 109 | let parent: undefined | Path = path.parent()!; 110 | expect(parent).toEqual(Path.of('parent', 'nested')); 111 | parent = parent.parent()!; 112 | expect(parent).toEqual(Path.of('parent')); 113 | parent = parent.parent()!; 114 | expect(parent).toBe(Path.ROOT); 115 | parent = parent.parent(); 116 | expect(parent).toBeUndefined(); 117 | // original is not modified 118 | expect(path).toEqual(Path.of('parent', 'nested', 0)); 119 | }); 120 | 121 | test('child', () => { 122 | expect(Path.of('foo').child(1).child('bar')).toEqual(Path.of('foo', 1, 'bar')); 123 | }); 124 | 125 | describe('equals', () => { 126 | test('equal path', () => { 127 | expect(Path.of('foo', 0).equals(Path.of('foo', 0))).toBe(true); 128 | }); 129 | test('non equal path', () => { 130 | expect(Path.of('foo', 0).equals(Path.of('foo', 1))).toBe(false); 131 | }); 132 | test('string and number indexes are equal', () => { 133 | expect(Path.of('foo', 0).equals(Path.of('foo', "0"))).toBe(true); 134 | }); 135 | test('shorter path', () => { 136 | expect(Path.of('foo', 'bar').equals(Path.of('foo'))).toBe(false); 137 | }) 138 | test('longer path', () => { 139 | expect(Path.of('foo').equals(Path.of('foo', 'bar'))).toBe(false); 140 | }) 141 | }); 142 | 143 | describe('validate components', () => { 144 | test('string is not valid index', () => { 145 | const component: any = 'foo'; 146 | expect(() => Path.of().index(component)).toThrow(); 147 | }); 148 | 149 | test('decimal is not valid index', () => { 150 | const component: any = 1.2; 151 | expect(() => Path.of().index(component)).toThrow(); 152 | }); 153 | 154 | test('negative value is not valid index', () => { 155 | const component: any = -1; 156 | expect(() => Path.of().index(component)).toThrow(); 157 | }); 158 | 159 | test('number is not valid property', () => { 160 | const component: any = 0; 161 | expect(() => Path.of().property(component)).toThrow(); 162 | }); 163 | 164 | test('array is not valid property', () => { 165 | const component: any = []; 166 | expect(() => Path.of().property(component)).toThrow(); 167 | }); 168 | 169 | describe('of', () => { 170 | test('decimal is not a valid component', () => { 171 | const component: any = 1.2; 172 | expect(() => Path.of(component)).toThrow(); 173 | }); 174 | 175 | test('negative value is not a valid component', () => { 176 | const component: any = -1; 177 | expect(() => Path.of(component)).toThrow(); 178 | }); 179 | 180 | test('array is not a valid component', () => { 181 | const component: any = []; 182 | expect(() => Path.of(component)).toThrow(); 183 | }); 184 | }); 185 | }); 186 | 187 | test('documentation example', () => { 188 | const array: any = [1, 2]; 189 | array.property = 'stupid thing to do'; 190 | expect(PathMatcher.of(AnyProperty).findValues(array)).toEqual([1, 2, 'stupid thing to do']); 191 | expect(PathMatcher.of(AnyIndex).findValues(array)).toEqual([1, 2]); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /packages/path/src/Projection.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { projection, Projection } from './Projection.js'; 3 | import moment from 'moment'; 4 | import { PathMatcher, UnionMatcher } from './PathMatcher.js'; 5 | import { AnyProperty, AnyIndex } from './matchers.js'; 6 | import { Path } from './Path.js'; 7 | 8 | describe('project', () => { 9 | describe('map', () => { 10 | const obj = { 11 | id: 'id', 12 | name: 'name', 13 | object: { 14 | name: 'nested', 15 | }, 16 | array: [ 17 | { 18 | name: 'a', 19 | value: 123, 20 | }, 21 | { 22 | name: 'b', 23 | value: 456, 24 | }, 25 | { 26 | name: 'c', 27 | value: 789, 28 | }, 29 | ], 30 | }; 31 | Object.freeze(obj); 32 | Object.freeze(obj.object); 33 | Object.freeze(obj.array); 34 | Object.freeze(obj.array[0]); 35 | Object.freeze(obj.array[1]); 36 | Object.freeze(obj.array[2]); 37 | 38 | test('Returns the a new object without includes and excludes', () => { 39 | const result = projection(undefined, [])(obj); 40 | expect(result).not.toBe(obj); 41 | expect(result).toEqual(obj); 42 | }); 43 | 44 | test('Returns a clone with include', () => expect(projection([PathMatcher.of(AnyProperty)], undefined)(obj)).not.toBe(obj)); 45 | 46 | test('Returns a clone with exclude', () => expect(projection(undefined, [PathMatcher.of('foo')])(obj)).not.toBe(obj)); 47 | 48 | test('exclude', () => { 49 | // Compare JSON rountrip to normalize undefined values 50 | expect(projection([], [PathMatcher.of('name'), PathMatcher.of('array', AnyIndex, 'value'), PathMatcher.of('object')])(obj)).toEqual({ 51 | id: 'id', 52 | array: [{ name: 'a' }, { name: 'b' }, { name: 'c' }], 53 | }); 54 | }); 55 | 56 | test('include', () => { 57 | expect(projection([PathMatcher.of('id'), PathMatcher.of('array', AnyIndex, 'name')])(obj)).toEqual({ 58 | id: 'id', 59 | array: [{ name: 'a' }, { name: 'b' }, { name: 'c' }], 60 | }); 61 | }); 62 | 63 | test("include index does't leave gaps", () => expect(projection([PathMatcher.of('array', 1, 'value')])(obj)).toEqual({ array: [{ value: 456 }] })); 64 | 65 | test("exclude index does't leave gaps", () => 66 | expect(projection([PathMatcher.of('array')], [PathMatcher.of('array', 1), PathMatcher.of('array', AnyIndex, 'name')])(obj)).toEqual({ 67 | array: [{ value: 123 }, { value: 789 }], 68 | })); 69 | 70 | test("always index does't leave gaps", () => 71 | expect(projection([], [PathMatcher.of(AnyProperty)], [PathMatcher.of('array', 1, 'name')])(obj)).toEqual({ 72 | array: [{ name: 'b' }], 73 | }) 74 | ); 75 | 76 | test("removing gaps retains nulls", () => { 77 | expect(projection([PathMatcher.of(UnionMatcher.of(1, 2))])([null, null, null, null])).toEqual([null, null]); 78 | }) 79 | 80 | test('include only array', () => { 81 | expect(projection([PathMatcher.of('array')])(obj)).toEqual({ 82 | array: [ 83 | { name: 'a', value: 123 }, 84 | { name: 'b', value: 456 }, 85 | { name: 'c', value: 789 }, 86 | ], 87 | }); 88 | }); 89 | 90 | test('cannot include fields from within a moment', () => { 91 | expect(projection([PathMatcher.of('moment', '_isUTC')])({ moment: moment() })).toEqual({}); 92 | }); 93 | 94 | test('excludes should not create an empty object', () => 95 | expect(projection([], [PathMatcher.of(AnyProperty), PathMatcher.of('object', 'name')])(obj)).toEqual({})); 96 | 97 | test('include properties', () => { 98 | expect(projection([PathMatcher.of('id'), PathMatcher.of('name')])(obj)).toEqual({ 99 | id: 'id', 100 | name: 'name', 101 | }); 102 | }); 103 | 104 | test('include union of indexes should', () => 105 | expect(projection([PathMatcher.of('array', new UnionMatcher([0, 2]))])(obj)).toEqual({ 106 | array: [ 107 | { 108 | name: 'a', 109 | value: 123, 110 | }, 111 | { 112 | name: 'c', 113 | value: 789, 114 | }, 115 | ], 116 | })); 117 | 118 | test('include union of properties', () => 119 | expect(projection([PathMatcher.of(new UnionMatcher(['id', 'name']))])(obj)).toEqual({ 120 | id: 'id', 121 | name: 'name', 122 | })); 123 | 124 | test('projection of PathMatcher instance', () => expect(projection([PathMatcher.of('foo')])).toBeDefined()); 125 | 126 | test('object is not valid PathMatcher', () => expect(() => projection([{} as any])).toThrow()); 127 | 128 | test('always included property', () => 129 | expect(projection([PathMatcher.of('non-existing-property')], [PathMatcher.of(AnyProperty)], [PathMatcher.of('id')])(obj)) 130 | .toEqual({ id: 'id' }) 131 | ); 132 | 133 | describe('projection map input should be non-null object', () => { 134 | test('toJSON => null', () => { 135 | expect(() => projection()({ toJSON() { return null; } })).toThrow(); 136 | }); 137 | 138 | test('replacer => string', () => { 139 | expect(() => projection(undefined, undefined, undefined, () => 'string')({})).toThrow(); 140 | }); 141 | }); 142 | }); 143 | 144 | describe('match', () => { 145 | test('everything matches if there are no includes or excludes', () => { 146 | const projection = Projection.of(); 147 | expect(projection.match(Path.of())).toBe(true); 148 | expect(projection.match(Path.of('property'))).toBe(true); 149 | expect(projection.match(Path.of(1))).toBe(true); 150 | }); 151 | 152 | test('includes partial match', () => { 153 | const projection = Projection.of([PathMatcher.of(1), PathMatcher.of('property')]); 154 | expect(projection.match(Path.of())).toBe(true); 155 | expect(projection.match(Path.of('property'))).toBe(true); 156 | expect(projection.match(Path.of('property', 'nested'))).toBe(true); 157 | expect(projection.match(Path.of(1))).toBe(true); 158 | expect(projection.match(Path.of(1, 2))).toBe(true); 159 | }); 160 | 161 | test("doesn't include sibling path", () => { 162 | const projection = Projection.of([PathMatcher.of('property', 'nested')]); 163 | expect(projection.match(Path.of('property', 'nested2'))).toBe(false); 164 | }); 165 | 166 | test('excludes by prefix', () => { 167 | const projection = Projection.of([], [PathMatcher.of('property2'), PathMatcher.of('property', 'nested')]); 168 | expect(projection.match(Path.of('property', 'nested'))).toBe(false); 169 | expect(projection.match(Path.of('property', 'nested', 0))).toBe(false); 170 | expect(projection.match(Path.of('property', 'nested2'))).toBe(true); 171 | expect(projection.match(Path.of('property'))).toBe(true); 172 | expect(projection.match(Path.of('property2'))).toBe(false); 173 | expect(projection.match(Path.of('property2', 0))).toBe(false); 174 | expect(projection.match(Path.of('anything other'))).toBe(true); 175 | }); 176 | 177 | test('include & exclude', () => { 178 | const projection = Projection.of([PathMatcher.of('property'), PathMatcher.of(1)], [PathMatcher.of(1, 0), PathMatcher.of('property', 'nested')]); 179 | expect(projection.match(Path.of())).toBe(true); 180 | expect(projection.match(Path.of('property', 'nested'))).toBe(false); 181 | expect(projection.match(Path.of('property', 'nested2'))).toBe(true); 182 | expect(projection.match(Path.of('property'))).toBe(true); 183 | expect(projection.match(Path.of('property2'))).toBe(false); 184 | 185 | expect(projection.match(Path.of(0))).toBe(false); 186 | expect(projection.match(Path.of(1))).toBe(true); 187 | expect(projection.match(Path.of(1, 0))).toBe(false); 188 | expect(projection.match(Path.of(1, 1))).toBe(true); 189 | }); 190 | 191 | test('always included property', () => { 192 | const projection = Projection.of([PathMatcher.of('non-existing-property')], [PathMatcher.of(AnyProperty)], [PathMatcher.of('id')]); 193 | expect(projection.match(Path.of('id'))).toBe(true); 194 | expect(projection.match(Path.of('name'))).toBe(false); 195 | }); 196 | }); 197 | }); 198 | -------------------------------------------------------------------------------- /packages/moment/src/Vmoment.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import moment, { Moment } from 'moment'; 3 | import { V, defaultViolations, Validator, ValidatorOptions, ValidationResult, Violation, TypeMismatch } from '@finnair/v-validation'; 4 | import { Path } from '@finnair/path'; 5 | import { Vmoment, dateUtcMoment, dateTimeUtcMoment, dateTimeMoment, timeMoment, dateMoment, dateTimeMillisUtcMoment, dateTimeMillisMoment } from './Vmoment.js'; 6 | 7 | async function expectViolations(value: In, validator: Validator, ...violations: Violation[]) { 8 | const result = await validator.validate(value); 9 | expect(result).toEqual(new ValidationResult(violations)); 10 | } 11 | 12 | async function expectValid(value: In, validator: Validator, convertedValue?: Out, ctx?: ValidatorOptions) { 13 | const result = await validator.validate(value, ctx); 14 | return verifyValid(result, value, convertedValue); 15 | } 16 | 17 | function verifyValid(result: ValidationResult, value: any, convertedValue?: Out) { 18 | expect(result.getViolations()).toEqual([]); 19 | if (convertedValue !== undefined) { 20 | expect(result.getValue()).toEqual(convertedValue); 21 | } else { 22 | expect(result.getValue()).toEqual(value); 23 | } 24 | return result; 25 | } 26 | 27 | describe('moment', () => { 28 | const toJSON = V.map((value: any) => value.toJSON()); 29 | const toDate = V.map((value: any) => value.toDate()); 30 | 31 | test('null is invalid', () => expectViolations(null, Vmoment.date(), defaultViolations.notNull())); 32 | 33 | test('undefined is invalid', () => expectViolations(null, Vmoment.date(), defaultViolations.notNull())); 34 | 35 | test('construct from Moment', () => { 36 | const m = moment(); 37 | expect(m.isSame(dateTimeMillisUtcMoment(m))).toBe(true); 38 | expect(m.isSame(dateTimeUtcMoment(m))).toBe(true); 39 | expect(dateTimeUtcMoment(m).toJSON()).toEqual(m.utc().format('YYYY-MM-DDTHH:mm:ss') + 'Z'); 40 | }); 41 | 42 | describe('local date', () => { 43 | test('valid local date converted', () => expectValid('2019-03-07', Vmoment.date().next(toJSON), '2019-03-07')); 44 | 45 | test('invalid', () => expectViolations('2019.03.07', Vmoment.date(), defaultViolations.date('2019.03.07'))); 46 | 47 | test('clone', () => expectCloneToMatch(dateMoment())); 48 | }); 49 | 50 | describe('local dateTime', () => { 51 | test('retain timezone', () => expectValid('2019-03-07T14:13:14+02:00', Vmoment.dateTime().next(toJSON), '2019-03-07T14:13:14+02:00')); 52 | 53 | test('valid local dateTime with +02:00 offset converted', () => 54 | expectValid('2019-03-07T14:13:14+02:00', Vmoment.dateTime().next(toJSON), '2019-03-07T14:13:14+02:00')); 55 | 56 | test('clone', () => expectCloneToMatch(dateTimeMoment())); 57 | }); 58 | 59 | describe('dateUtc', () => { 60 | test('valid utc date value', () => expectValid('2019-03-07', Vmoment.dateUtc().next(toDate), new Date('2019-03-07T00:00:00Z'))); 61 | 62 | test('valid utc date converted', () => expectValid('2019-03-07', Vmoment.dateUtc().next(toJSON), '2019-03-07')); 63 | 64 | test('array constructor', () => expect(dateUtcMoment([2019, 0, 1]).toJSON()).toEqual('2019-01-01')); 65 | 66 | test('clone', () => expectCloneToMatch(dateUtcMoment())); 67 | }); 68 | 69 | describe('dateTimeUtc', () => { 70 | test('valid utc dateTime converted', () => expectValid('2019-03-07T12:13:14Z', Vmoment.dateTimeUtc().next(toJSON), '2019-03-07T12:13:14Z')); 71 | 72 | test('valid utc dateTime with +00:00 offset converted', () => 73 | expectValid('2019-03-07T12:13:14+00:00', Vmoment.dateTimeUtc().next(toJSON), '2019-03-07T12:13:14Z')); 74 | 75 | test('valid utc dateTime with +02:00 offset converted', () => 76 | expectValid('2019-03-07T14:13:14+02:00', Vmoment.dateTimeUtc().next(toJSON), '2019-03-07T12:13:14Z')); 77 | 78 | test('array constructor', () => expect(dateTimeUtcMoment([2019, 0, 1, 1, 1, 1]).toJSON()).toEqual('2019-01-01T01:01:01Z')); 79 | 80 | test('convert dateTimeUtcMoment to local time', () => { 81 | const d: moment.Moment = dateTimeUtcMoment('2019-05-21T12:13:14Z'); 82 | expect(d.clone().utcOffset(3).toJSON()).toEqual('2019-05-21T15:13:14+03:00'); 83 | }); 84 | 85 | test('utcOffset of dateTimeUtcMoment', () => { 86 | const d: moment.Moment = dateTimeUtcMoment('2019-05-21T12:13:14+03:00'); 87 | expect(d.utcOffset()).toEqual(0); 88 | }); 89 | 90 | test('clone', () => expectCloneToMatch(dateTimeUtcMoment())); 91 | }); 92 | 93 | describe('dateTimeMillisUtc', () => { 94 | test('valid utc dateTime converted', () => expectValid('2019-03-07T12:13:14.123Z', Vmoment.dateTimeMillisUtc().next(toJSON), '2019-03-07T12:13:14.123Z')); 95 | 96 | test('valid utc dateTime with +00:00 offset converted', () => 97 | expectValid('2019-03-07T12:13:14.123+00:00', Vmoment.dateTimeMillisUtc().next(toJSON), '2019-03-07T12:13:14.123Z')); 98 | 99 | test('valid utc dateTime with +02:00 offset converted', () => 100 | expectValid('2019-03-07T14:13:14.123+02:00', Vmoment.dateTimeMillisUtc().next(toJSON), '2019-03-07T12:13:14.123Z')); 101 | 102 | test('array constructor', () => expect(dateTimeMillisUtcMoment([2019, 0, 1, 1, 1, 1, 123]).toJSON()).toEqual('2019-01-01T01:01:01.123Z')); 103 | 104 | test('millis required', () => 105 | expectViolations('2019-03-07T12:13:14Z', Vmoment.dateTimeMillis(), defaultViolations.date('2019-03-07T12:13:14Z', Path.ROOT, 'DateTimeMillis'))); 106 | 107 | test('two digit millis is not allowed', () => 108 | expectViolations('2019-03-07T12:13:14.1Z', Vmoment.dateTimeMillis(), defaultViolations.date('2019-03-07T12:13:14.1Z', Path.ROOT, 'DateTimeMillis'))); 109 | 110 | test('convert dateTimeMillisUtcMoment to local time', () => { 111 | const d: moment.Moment = dateTimeMillisUtcMoment('2019-05-21T12:13:14.123Z'); 112 | expect(d.clone().utcOffset(3).toJSON()).toEqual('2019-05-21T15:13:14.123+03:00'); 113 | }); 114 | 115 | test('utcOffset of dateTimeMillisUtcMoment', () => { 116 | const d: moment.Moment = dateTimeMillisUtcMoment('2019-05-21T12:13:14.123+03:00'); 117 | expect(d.utcOffset()).toEqual(0); 118 | }); 119 | 120 | test('clone', () => expectCloneToMatch(dateTimeMillisUtcMoment())); 121 | }); 122 | 123 | describe('local dateTimeMillis', () => { 124 | test('retain timezone', () => expectValid('2019-03-07T14:13:14.123+02:00', Vmoment.dateTimeMillis().next(toJSON), '2019-03-07T14:13:14.123+02:00')); 125 | 126 | test('valid local dateTime with +02:00 offset converted', () => 127 | expectValid('2019-03-07T14:13:14.123+02:00', Vmoment.dateTimeMillis().next(toJSON), '2019-03-07T14:13:14.123+02:00')); 128 | 129 | test('clone', () => expectCloneToMatch(dateTimeMillisMoment())); 130 | 131 | test('show 0 offset as Z', () => expect(dateTimeMillisMoment('2019-05-21T12:13:14.123Z').toJSON()).toEqual('2019-05-21T12:13:14.123Z')); 132 | }); 133 | 134 | describe('normalize Moment', () => { 135 | test('valid utc date', () => expectValid(moment('2019-03-07T12:13:14Z'), Vmoment.dateUtc().next(toJSON), '2019-03-07')); 136 | }); 137 | 138 | describe('duration', () => { 139 | test('null is invalid', () => expectViolations(null, Vmoment.duration(), defaultViolations.notNull())); 140 | 141 | test('undefined is invalid', () => expectViolations(null, Vmoment.duration(), defaultViolations.notNull())); 142 | 143 | test('ABC is invalid', () => expectViolations('ABC', Vmoment.duration(), new TypeMismatch(Path.ROOT, 'Duration', 'ABC'))); 144 | 145 | test('parse serialize roundtrip', () => expectValid('P23DT23H', Vmoment.duration().next(toJSON), 'P23DT23H')); 146 | }); 147 | 148 | describe('time', () => { 149 | test('retain original time', () => { 150 | expect(timeMoment('10:00:00').toJSON()).toEqual('10:00:00'); 151 | }); 152 | 153 | test('V.time()', () => expectValid('10:00:00', Vmoment.time(), timeMoment('10:00:00'))); 154 | 155 | test('clone', () => expectCloneToMatch(timeMoment())); 156 | }); 157 | 158 | describe('local time', () => { 159 | test('convert dateTimeMoment to local time', () => { 160 | const d: moment.Moment = dateTimeMoment('2019-05-21T12:13:14Z'); 161 | expect(d.clone().utcOffset(3).toJSON()).toEqual('2019-05-21T15:13:14+03:00'); 162 | }); 163 | 164 | test('show 0 offset as Z', () => { 165 | expect(dateTimeMoment('2019-05-21T12:13:14Z').toJSON()).toEqual('2019-05-21T12:13:14Z'); 166 | }); 167 | 168 | test('clone', () => expectCloneToMatch(dateTimeMoment())); 169 | }); 170 | }); 171 | 172 | function expectCloneToMatch(m: Moment) { 173 | const clone = m.clone(); 174 | expect(clone).not.toBe(m); 175 | expect(clone.isSame(m)).toBe(true); 176 | expect(clone.toJSON()).toEqual(m.toJSON()); 177 | } 178 | -------------------------------------------------------------------------------- /packages/diff/src/Diff.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import { describe, test, expect } from 'vitest'; 3 | import { Diff } from './Diff.js'; 4 | import { Node, Path } from '@finnair/path'; 5 | import { Change } from './DiffNode.js'; 6 | 7 | describe('Diff', () => { 8 | const defaultDiff = new Diff(); 9 | 10 | describe('helpers', () => { 11 | const object = { 12 | object: { string: "string"}, 13 | array: [0], 14 | 'undefined': undefined, 15 | 'null': null 16 | }; 17 | describe('allPaths', () => { 18 | test('with default filter (without undefined values)', () => { 19 | const paths = defaultDiff.allPaths(object); 20 | expect(paths).toEqual(new Set([ '$.object.string', '$.array[0]', '$.null'])); 21 | }); 22 | test('including undefined paths', () => { 23 | const paths = new Diff({ filter: () => true }).allPaths(object); 24 | expect(paths).toEqual(new Set([ '$.object.string', '$.array[0]', '$.undefined', '$.null'])); 25 | }); 26 | test('including objects', () => { 27 | const paths = new Diff({ includeObjects: true}).allPaths(object); 28 | expect(paths).toEqual(new Set([ '$.object', '$.object.string', '$.array', '$.array[0]', '$.null'])); 29 | }); 30 | test('array', () => { 31 | const paths = new Diff().allPaths([object]); 32 | expect(paths).toEqual(new Set([ '$[0].object.string', '$[0].array[0]', '$[0].null' ])); 33 | }); 34 | }); 35 | 36 | describe('pathsAndValues', () => { 37 | test('all pathsAndValues with default filter (without undefined values)', () => { 38 | const pathsAndValues = defaultDiff.pathsAndValues(object); 39 | expect(pathsAndValues).toEqual(new Map([ 40 | ['$.object.string', { path: Path.of('object', 'string'), value: 'string' }], 41 | ['$.array[0]', { path: Path.of('array', 0), value: 0 }], 42 | ['$.null', { path: Path.of('null'), value: null }], 43 | ])); 44 | }); 45 | test('all, including undefined pathsAndValues', () => { 46 | const pathsAndValues = new Diff({ filter: () => true }).pathsAndValues(object); 47 | expect(pathsAndValues).toEqual(new Map([ 48 | ['$.object.string', { path: Path.of('object', 'string'), value: 'string' }], 49 | ['$.array[0]', { path: Path.of('array', 0), value: 0 }], 50 | ['$.undefined', { path: Path.of('undefined'), value: undefined }], 51 | ['$.null', { path: Path.of('null'), value: null }], 52 | ])); 53 | }); 54 | test('including objects', () => { 55 | const pathsAndValues = new Diff({ includeObjects: true}).pathsAndValues(object); 56 | expect(pathsAndValues).toEqual(new Map([ 57 | ['$', { path: Path.ROOT, value: {} }], 58 | ['$.object', { path: Path.of('object'), value: {} }], 59 | ['$.object.string', { path: Path.of('object', 'string'), value: 'string' }], 60 | ['$.array', { path: Path.of('array'), value: [] }], 61 | ['$.array[0]', { path: Path.of('array', 0), value: 0 }], 62 | ['$.null', { path: Path.of('null'), value: null }], 63 | ])); 64 | }); 65 | }); 66 | }); 67 | 68 | test('handle null', async () => { 69 | const diff = defaultDiff.changedPaths(null, null); 70 | const expected = new Set([]); 71 | expect(diff).toEqual(expected); 72 | }); 73 | 74 | test('only primitives, arrays and plain objects are supported', () => { 75 | expect(() => defaultDiff.allPaths(new Set([1]))).toThrow('only primitives, arrays and plain objects are supported, got "Set"') 76 | }) 77 | 78 | describe('nested object', () => { 79 | const oldObject = { 80 | object: { 81 | name: 'Alexis', 82 | }, 83 | array: [ 84 | { name:'Foo' } 85 | ] 86 | }; 87 | const newObject = {}; 88 | 89 | describe('remove nested object', () => { 90 | test('with includeObjects: false', () => { 91 | const paths = defaultDiff.changedPaths(oldObject, newObject); 92 | const expected = new Set(['$.object.name', '$.array[0].name']); 93 | expect(paths).toEqual(expected); 94 | }); 95 | 96 | test('with includeObjects: true', () => { 97 | const paths = new Diff({ includeObjects: true }).changedPaths(oldObject, newObject); 98 | const expected = new Set(['$.object', '$.object.name', '$.array', '$.array[0]', '$.array[0].name']); 99 | expect(paths).toEqual(expected); 100 | }); 101 | }); 102 | 103 | describe('add nested object', () => { 104 | test('with includeObjects: false', () => { 105 | const paths = defaultDiff.changedPaths(newObject, oldObject); 106 | const expected = new Set(['$.object.name', '$.array[0].name']); 107 | expect(paths).toEqual(expected); 108 | }); 109 | 110 | test('with includeObjects: true', () => { 111 | const diff = new Diff({ includeObjects: true }).changedPaths(newObject, oldObject); 112 | const expected = new Set(['$.object', '$.object.name', '$.array', '$.array[0]', '$.array[0].name']); 113 | expect(diff).toEqual(expected); 114 | }); 115 | }); 116 | }); 117 | 118 | describe('CustomPrimitive', () => { 119 | const diff = new Diff({ isPrimitive: (value: any) => value instanceof CustomPrimitive, isEqual: (a: any, b: any) => { 120 | if (a instanceof CustomPrimitive && b instanceof CustomPrimitive) { 121 | return a.value === b.value; 122 | } 123 | return false; 124 | }}) 125 | test('no change', () => { 126 | expect(diff.changeset({ custom: new CustomPrimitive(1) }, { custom: new CustomPrimitive(1) })).toEqual(new Map()); 127 | }); 128 | test('change', () => { 129 | expect(diff.changeset({ custom: new CustomPrimitive(1) }, { custom: new CustomPrimitive(2) })).toEqual(new Map([ 130 | ['$.custom', { path: Path.of('custom'), oldValue: new CustomPrimitive(1), newValue: new CustomPrimitive(2)}] 131 | ])); 132 | }); 133 | }); 134 | 135 | describe('path/id based primitive', () => { 136 | const diff = new Diff({ isPrimitive: (_: any, path: Path) => path.componentAt(0) === 'nested', isEqual: (a: any, b: any, path: Path) => { 137 | if (path.componentAt(0) === 'nested') { 138 | return a.id === b.id; 139 | } 140 | return false; 141 | }}) 142 | test('objects with same id are equal', () => { 143 | expect(diff.changeset({ nested: { id: 1, value: 'foo' } }, { nested: { id: 1, value: 'bar' } })).toEqual(new Map([])); 144 | }); 145 | test('objects with different id are not equal', () => { 146 | expect(diff.changeset({ nested: { id: 1, value: 'foo' } }, { nested: { id: 2, value: 'foo' } })).toEqual(new Map([ 147 | ['$.nested', { path: Path.of('nested'), oldValue: { id: 1, value: 'foo' }, newValue: { id: 2, value: 'foo' } }] 148 | ])); 149 | }); 150 | }); 151 | 152 | test('property value is added, removed and changed', async () => { 153 | const right: any = { 154 | name: 'oldName', 155 | age: 20, 156 | }; 157 | const left: any = { 158 | name: 'changedName', 159 | lastName: 'Added lastName', 160 | }; 161 | const diff = defaultDiff.changeset(right, left); 162 | const expected = new Map([ 163 | ['$.name', { path: Path.of('name'), oldValue: 'oldName', newValue: 'changedName'}], 164 | ['$.lastName', { path: Path.of('lastName'), newValue: 'Added lastName' }], 165 | ['$.age', { path: Path.of('age'), oldValue: 20 }] 166 | ]); 167 | expect(diff).toEqual(expected); 168 | }); 169 | 170 | test('addItemToArray', async () => { 171 | const oldObject = { 172 | names: [], 173 | }; 174 | const newObject = { 175 | names: ['Alexis'], 176 | }; 177 | const diff = defaultDiff.changedPaths(oldObject, newObject); 178 | const expected = new Set(['$.names[0]']); 179 | expect(diff).toEqual(expected); 180 | }); 181 | 182 | test('addItemToArray with items', async () => { 183 | const oldObject = { 184 | persons: [], 185 | }; 186 | const newObject = { 187 | persons: [{ name: 'Alexis' }], 188 | }; 189 | const diff = defaultDiff.changedPaths(oldObject, newObject); 190 | const expected = new Set(['$.persons[0].name']); 191 | expect(diff).toEqual(expected); 192 | }); 193 | 194 | test('addItemToArray with multiple items', async () => { 195 | const oldObject = { 196 | persons: [{ firstName: 'Alexis' }], 197 | }; 198 | const newObject = { 199 | persons: [{ firstName: 'Alexis', lastName: 'Doe' }, { firstName: 'Riley' }], 200 | }; 201 | const diff = defaultDiff.changedPaths(oldObject, newObject); 202 | const expected = new Set(['$.persons[0].lastName', '$.persons[1].firstName']); 203 | expect(diff).toEqual(expected); 204 | }); 205 | 206 | test('nested arrays', () => { 207 | const oldObject = { 208 | array: [[[{ name: 'foo' }]]], 209 | }; 210 | const newObject = { 211 | array: [[[{ name: 'bar' }]]], 212 | }; 213 | const diff = defaultDiff.changedPaths(oldObject, newObject); 214 | const expected = new Set(['$.array[0][0][0].name']); 215 | expect(diff).toEqual(expected); 216 | }); 217 | }); 218 | 219 | class CustomPrimitive { 220 | constructor(public readonly value: number) {} 221 | } 222 | -------------------------------------------------------------------------------- /packages/luxon/src/Vluxon.ts: -------------------------------------------------------------------------------- 1 | import { ValidationContext, isNullOrUndefined, defaultViolations, isString, V, Validator, TypeMismatch } from '@finnair/v-validation'; 2 | import { Path } from '@finnair/path'; 3 | import { DateTime, DateTimeJSOptions, DateTimeOptions, Duration, FixedOffsetZone } from 'luxon'; 4 | import { 5 | LocalDateLuxon, 6 | DateTimeLuxon, 7 | DateTimeMillisLuxon, 8 | DateTimeMillisUtcLuxon, 9 | DateTimeUtcLuxon, 10 | LuxonDateTime, 11 | LocalTimeLuxon, 12 | LocalDateTimeLuxon, 13 | } from './luxon.js'; 14 | 15 | export type LuxonInput = string | DateTime | LuxonDateTime; 16 | 17 | export interface DateTimeParams { 18 | type: string; 19 | pattern: RegExp; 20 | parser: (value: string, match: RegExpExecArray) => DateTime; 21 | } 22 | 23 | export interface ValidateLuxonParams extends DateTimeParams { 24 | proto: new (...args:any[]) => Out; 25 | } 26 | 27 | export class DateTimeValidator extends Validator { 28 | constructor(public readonly params: DateTimeParams) { 29 | super(); 30 | Object.freeze(params); 31 | Object.freeze(this); 32 | } 33 | validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { 34 | const params = this.params; 35 | if (isNullOrUndefined(value)) { 36 | return Promise.reject(defaultViolations.notNull(path)); 37 | } 38 | if (DateTime.isDateTime(value)) { 39 | if (value.isValid) { 40 | return Promise.resolve(value as DateTime); 41 | } 42 | } else if (isString(value)) { 43 | const match = params.pattern.exec(value); 44 | if (match) { 45 | const dateTime = params.parser(value, match); 46 | if (dateTime.isValid) { 47 | return Promise.resolve(dateTime); 48 | } 49 | } 50 | } 51 | return Promise.reject(defaultViolations.date(value, path, params.type)); 52 | } 53 | } 54 | 55 | export class LuxonValidator extends Validator { 56 | private readonly dateTimeValidator: DateTimeValidator; 57 | constructor(public readonly params: ValidateLuxonParams) { 58 | super(); 59 | this.dateTimeValidator = new DateTimeValidator(params); 60 | Object.freeze(this); 61 | } 62 | 63 | validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { 64 | if (value instanceof this.params.proto) { 65 | return Promise.resolve(value); 66 | } 67 | if (DateTime.isDateTime(value?.dateTime)) { 68 | return Promise.resolve(new this.params.proto(value.dateTime)); 69 | } 70 | return this.dateTimeValidator.validatePath(value, path, ctx).then( 71 | (result: DateTime) => { 72 | return new this.params.proto(result); 73 | } 74 | ); 75 | } 76 | } 77 | 78 | const datePattern = /^\d{4}-\d{2}-\d{2}$/; 79 | 80 | function localDate(options: DateTimeOptions = { setZone: true }) { 81 | return new LuxonValidator({ 82 | type: 'Date', 83 | proto: LocalDateLuxon, 84 | pattern: datePattern, 85 | parser: (value: string) => DateTime.fromISO(value, { zone: FixedOffsetZone.utcInstance }), 86 | }); 87 | } 88 | 89 | const timePattern = /^\d{2}:\d{2}:\d{2}$/; 90 | 91 | function localTime(options: DateTimeOptions = { zone: FixedOffsetZone.utcInstance }) { 92 | return new LuxonValidator({ 93 | type: 'Time', 94 | proto: LocalTimeLuxon, 95 | pattern: timePattern, 96 | parser: (value: string) => DateTime.fromISO(value, options), 97 | }); 98 | } 99 | 100 | const localDateTimePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/; 101 | 102 | function localDateTime(options: DateTimeOptions = { zone: FixedOffsetZone.utcInstance }) { 103 | return new LuxonValidator({ 104 | type: 'DateTime', 105 | proto: LocalDateTimeLuxon, 106 | pattern: localDateTimePattern, 107 | parser: (value: string) => DateTime.fromISO(value, options), 108 | }); 109 | } 110 | 111 | const dateTimeTzPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}(?::?\d{2})?)$/; 112 | 113 | function dateTime(options: DateTimeOptions = { setZone: true }) { 114 | return new LuxonValidator({ 115 | type: 'DateTime', 116 | proto: DateTimeLuxon, 117 | pattern: dateTimeTzPattern, 118 | parser: (value: string) => DateTime.fromISO(value, options), 119 | }); 120 | } 121 | 122 | function dateTimeUtc(options: DateTimeOptions = { zone: FixedOffsetZone.utcInstance }) { 123 | return new LuxonValidator({ 124 | type: 'DateTime', 125 | proto: DateTimeUtcLuxon, 126 | pattern: dateTimeTzPattern, 127 | parser: (value: string) => DateTime.fromISO(value, options), 128 | }); 129 | } 130 | 131 | const dateTimeMillisPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}(?:Z|[+-]\d{2}(?::?\d{2})?)$/; 132 | 133 | function dateTimeMillis(options: DateTimeOptions = { setZone: true }) { 134 | return new LuxonValidator({ 135 | type: 'DateTimeMillis', 136 | proto: DateTimeMillisLuxon, 137 | pattern: dateTimeMillisPattern, 138 | parser: (value: string) => DateTime.fromISO(value, options), 139 | }); 140 | } 141 | 142 | function dateTimeMillisUtc(options: DateTimeOptions = { zone: FixedOffsetZone.utcInstance }) { 143 | return new LuxonValidator({ 144 | type: 'DateTimeMillis', 145 | proto: DateTimeMillisUtcLuxon, 146 | pattern: dateTimeMillisPattern, 147 | parser: (value: string) => DateTime.fromISO(value, options), 148 | }); 149 | } 150 | 151 | function dateTimeFromISO(options: DateTimeOptions = { setZone: true }) { 152 | return new DateTimeValidator({ 153 | type: 'ISODateTime', 154 | pattern: /./, 155 | parser: (value: string) => DateTime.fromISO(value, options), 156 | }); 157 | } 158 | 159 | function dateTimeFromRFC2822(options: DateTimeOptions = { setZone: true }) { 160 | return new DateTimeValidator({ 161 | type: 'RFC2822DateTime', 162 | pattern: /./, 163 | parser: (value: string) => DateTime.fromRFC2822(value, options), 164 | }); 165 | } 166 | 167 | function dateTimeFromHTTP(options: DateTimeOptions = { setZone: true }) { 168 | return new DateTimeValidator({ 169 | type: 'HTTPDateTime', 170 | pattern: /./, 171 | parser: (value: string) => DateTime.fromHTTP(value, options), 172 | }); 173 | } 174 | 175 | function dateTimeFromSQL(options: DateTimeOptions = { zone: FixedOffsetZone.utcInstance }) { 176 | return new DateTimeValidator({ 177 | type: 'SQLDateTime', 178 | pattern: /./, 179 | parser: (value: string) => DateTime.fromSQL(value, options), 180 | }); 181 | } 182 | 183 | export interface ValidateLuxonNumberParams { 184 | value: any; 185 | path: Path; 186 | ctx: ValidationContext; 187 | type: string; 188 | parser: (value: number) => DateTime; 189 | } 190 | 191 | export async function validateLuxonNumber({ value, path, ctx, type, parser }: ValidateLuxonNumberParams): Promise { 192 | if (isNullOrUndefined(value)) { 193 | return Promise.reject(defaultViolations.notNull(path)); 194 | } else if (DateTime.isDateTime(value)) { 195 | if (value.isValid) { 196 | return Promise.resolve(value); 197 | } 198 | } else if (typeof value === 'number' && !Number.isNaN(value)) { 199 | const dateTime = parser(value); 200 | if (dateTime.isValid) { 201 | return Promise.resolve(dateTime); 202 | } 203 | } 204 | return Promise.reject(defaultViolations.date(value, path, type)); 205 | } 206 | 207 | function dateTimeFromMillis(options: DateTimeJSOptions = { zone: FixedOffsetZone.utcInstance }) { 208 | return V.fn((value: any, path: Path, ctx: ValidationContext) => 209 | validateLuxonNumber({ 210 | value, 211 | path, 212 | ctx, 213 | type: 'MillisDateTime', 214 | parser: value => DateTime.fromMillis(value, options), 215 | }), 216 | ); 217 | } 218 | 219 | function dateTimeFromSeconds(options: DateTimeJSOptions = { zone: FixedOffsetZone.utcInstance }) { 220 | return V.fn((value: any, path: Path, ctx: ValidationContext) => 221 | validateLuxonNumber({ 222 | value, 223 | path, 224 | ctx, 225 | type: 'SecondsDateTime', 226 | parser: value => DateTime.fromSeconds(value, options), 227 | }), 228 | ); 229 | } 230 | 231 | const durationPattern = 232 | /^P(?!$)(\d+(?:\.\d+)?Y)?(\d+(?:\.\d+)?M)?(\d+(?:\.\d+)?W)?(\d+(?:\.\d+)?D)?(T(?=\d)(\d+(?:\.\d+)?H)?(\d+(?:\.\d+)?M)?(\d+(?:\.\d+)?S)?)?$/; 233 | 234 | export class DurationValidator extends Validator { 235 | async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { 236 | if (isNullOrUndefined(value)) { 237 | return Promise.reject(defaultViolations.notNull(path)); 238 | } else if (Duration.isDuration(value)) { 239 | return Promise.resolve(value); 240 | } else if (isString(value) && durationPattern.test(value)) { 241 | const duration = Duration.fromISO(value); 242 | if (duration.isValid) { 243 | return Promise.resolve(duration); 244 | } 245 | } 246 | return Promise.reject(new TypeMismatch(path, 'Duration', value)); 247 | } 248 | } 249 | 250 | export class TimeDurationValidator extends Validator { 251 | async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { 252 | if (isNullOrUndefined(value)) { 253 | return Promise.reject(defaultViolations.notNull(path)); 254 | } else if (Duration.isDuration(value)) { 255 | return Promise.resolve(value); 256 | } else if (isString(value)) { 257 | const duration = Duration.fromISOTime(value); 258 | if (duration.isValid) { 259 | return Promise.resolve(duration); 260 | } 261 | } 262 | return Promise.reject(new TypeMismatch(path, 'TimeDuration', value)); 263 | } 264 | } 265 | 266 | export const Vluxon = { 267 | // DateTime wrapper validators 268 | localDate, 269 | localTime, 270 | localDateTime, 271 | dateTime, 272 | dateTimeUtc, 273 | dateTimeMillis, 274 | dateTimeMillisUtc, 275 | // Plain DateTime validators 276 | dateTimeFromISO, 277 | dateTimeFromRFC2822, 278 | dateTimeFromHTTP, 279 | dateTimeFromSQL, 280 | dateTimeFromSeconds, 281 | dateTimeFromMillis, 282 | duration: () => new DurationValidator(), 283 | timeDuration: () => new TimeDurationValidator(), 284 | }; 285 | Object.freeze(Vluxon); 286 | -------------------------------------------------------------------------------- /packages/core/src/V.ts: -------------------------------------------------------------------------------- 1 | import { SchemaValidator, SchemaModel } from './schema.js'; 2 | import { Path } from '@finnair/path'; 3 | import { 4 | IgnoreValidator, 5 | ArrayNormalizer, 6 | AnyValidator, 7 | StringValidator, 8 | StringNormalizer, 9 | NotNullOrUndefinedValidator, 10 | IsNullOrUndefinedValidator, 11 | NotEmptyValidator, 12 | NotBlankValidator, 13 | ValueMapper, 14 | isNullOrUndefined, 15 | BooleanValidator, 16 | NumberValidator, 17 | NumberFormat, 18 | NumberNormalizer, 19 | DateValidator, 20 | ValidatorType, 21 | ValidatorFn, 22 | ValidatorFnWrapper, 23 | MappingFn, 24 | Validator, 25 | CheckValidator, 26 | OptionalValidator, 27 | AssertTrue, 28 | IfValidator, 29 | Conditional, 30 | GroupOrName, 31 | WhenGroupValidator, 32 | WhenGroup, 33 | AssertTrueValidator, 34 | PatternValidator, 35 | PatternNormalizer, 36 | BooleanNormalizer, 37 | MinValidator, 38 | MaxValidator, 39 | MapValidator, 40 | MapNormalizer, 41 | ArrayValidator, 42 | SizeValidator, 43 | AllOfValidator, 44 | AnyOfValidator, 45 | OneOfValidator, 46 | EnumValidator, 47 | HasValueValidator, 48 | JsonValidator, 49 | RequiredValidator, 50 | SetValidator, 51 | UuidValidator, 52 | VType, 53 | maybeCompositionOf, 54 | CompositionParameters, 55 | OptionalUndefinedValidator, 56 | NullableValidator, 57 | JsonBigIntValidator, 58 | UnknownValidator, 59 | } from './validators.js'; 60 | import {ObjectModel, ObjectValidator, ObjectNormalizer } from './objectValidator.js'; 61 | import { ObjectValidatorBuilder } from './objectValidatorBuilder.js'; 62 | 63 | interface AllOfParameters { 64 | (v1: Validator, v2: Validator): Validator; 65 | (v1: Validator, v2: Validator, v3: Validator): Validator; 66 | (v1: Validator, v2: Validator, v3: Validator, v4: Validator): Validator; 67 | (v1: Validator, v2: Validator, v3: Validator, v4: Validator, v5: Validator): Validator; 68 | } 69 | 70 | const AllOfConstructor: AllOfParameters = (...validators: [Validator, ...Validator[]]) => new AllOfValidator(validators); 71 | 72 | const ignoreValidator = new IgnoreValidator(), 73 | stringValidator = new StringValidator(), 74 | toStringValidator = new StringNormalizer(), 75 | nullOrUndefinedValidator = new IsNullOrUndefinedValidator(), 76 | notBlankValidator = new NotBlankValidator(), 77 | emptyToNullValidator = new ValueMapper((value: any) => (isNullOrUndefined(value) || value === '' ? null : value)), 78 | emptyToUndefinedValidator = new ValueMapper((value: any) => (isNullOrUndefined(value) || value === '' ? undefined : value)), 79 | undefinedToNullValidator = new ValueMapper((value: any) => (value === undefined ? null : value)), 80 | booleanValidator = new BooleanValidator(), 81 | numberValidator = new NumberValidator(NumberFormat.number), 82 | toNumberValidator = new NumberNormalizer(NumberFormat.number), 83 | integerValidator = new NumberValidator(NumberFormat.integer), 84 | jsonBigIntValidator = new JsonBigIntValidator(), 85 | toIntegerValidator = new NumberNormalizer(NumberFormat.integer), 86 | dateValidator = new DateValidator(ValidatorType.Date); 87 | 88 | export const V = { 89 | fn: (fn: ValidatorFn, type?: string) => new ValidatorFnWrapper(fn, type), 90 | 91 | map: (fn: MappingFn, error?: any) => new ValueMapper(fn, error), 92 | 93 | ignore: () => ignoreValidator, 94 | 95 | any: () => new AnyValidator(), 96 | 97 | unknown: () => new UnknownValidator(), 98 | 99 | check: (...validators: CompositionParameters) => 100 | new CheckValidator(maybeCompositionOf(...validators)), 101 | 102 | /** 103 | * Allows only undefined, null or valid value. 104 | */ 105 | optional: (...validators: CompositionParameters) => 106 | new OptionalValidator(maybeCompositionOf(...validators)), 107 | 108 | /** 109 | * Allows only undefined or valid value. 110 | */ 111 | optionalStrict: (...validators: CompositionParameters) => 112 | new OptionalUndefinedValidator(maybeCompositionOf(...validators)), 113 | 114 | /** 115 | * Allows only null or valid value. 116 | */ 117 | nullable: (...validators: CompositionParameters) => 118 | new NullableValidator(maybeCompositionOf(...validators)), 119 | 120 | required: (...validators: CompositionParameters) => 121 | new RequiredValidator(maybeCompositionOf(...validators)), 122 | 123 | if: (fn: AssertTrue, ...validators: CompositionParameters) => 124 | new IfValidator([new Conditional(fn, maybeCompositionOf(...validators))]), 125 | 126 | whenGroup: (group: GroupOrName, ...validators: CompositionParameters) => 127 | new WhenGroupValidator([new WhenGroup(group, maybeCompositionOf(...validators))]), 128 | 129 | string: () => stringValidator, 130 | 131 | toString: () => toStringValidator, 132 | 133 | notNull: () => new NotNullOrUndefinedValidator(), 134 | 135 | nullOrUndefined: () => nullOrUndefinedValidator, 136 | 137 | notEmpty: () => new NotEmptyValidator(), 138 | 139 | notBlank: () => notBlankValidator, 140 | 141 | emptyToNull: () => emptyToNullValidator, 142 | 143 | emptyToUndefined: () => emptyToUndefinedValidator, 144 | 145 | undefinedToNull: () => undefinedToNullValidator, 146 | 147 | emptyTo: (defaultValue: Default) => 148 | new ValueMapper((value: In | null | undefined) => (isNullOrUndefined(value) || value.length === 0 ? defaultValue : value)), 149 | 150 | uuid: (version?: number) => new UuidValidator(version), 151 | 152 | pattern: (pattern: string | RegExp, flags?: string) => new PatternValidator(pattern, flags), 153 | 154 | toPattern: (pattern: string | RegExp, flags?: string) => new PatternNormalizer(pattern, flags), 155 | 156 | boolean: () => booleanValidator, 157 | 158 | toBoolean: (truePattern: RegExp = /^true$/, falsePattern: RegExp = /^false$/) => new BooleanNormalizer(truePattern, falsePattern), 159 | 160 | number: () => numberValidator, 161 | 162 | toNumber: () => toNumberValidator, 163 | 164 | integer: () => integerValidator, 165 | 166 | toInteger: () => toIntegerValidator, 167 | 168 | min: (min: number, inclusive = true) => new MinValidator(min, inclusive), 169 | 170 | max: (max: number, inclusive = true) => new MaxValidator(max, inclusive), 171 | 172 | jsonBigInt: () => jsonBigIntValidator, 173 | 174 | object: (model: ObjectModel) => new ObjectValidator(model), 175 | 176 | objectType: () => new ObjectValidatorBuilder(), 177 | 178 | toObject: (property: string) => new ObjectNormalizer(property), 179 | 180 | schema: (fn: (schema: SchemaValidator) => SchemaModel) => new SchemaValidator(fn), 181 | 182 | /** WARN: Objects as Map keys use identity hash/equals, i.e. === */ 183 | mapType: (keys: Validator, values: Validator, jsonSafeMap: E) => new MapValidator(keys, values, jsonSafeMap), 184 | 185 | /** WARN: Objects as Map keys use identity hash/equals, i.e. === */ 186 | toMapType: (keys: Validator, values: Validator, jsonSafeMap: E) => new MapNormalizer(keys, values, jsonSafeMap), 187 | 188 | setType: (values: Validator, jsonSafeSet: E) => new SetValidator(values, jsonSafeSet), 189 | 190 | nullTo: (defaultValue: Out) => 191 | new ValueMapper((value: In) => (isNullOrUndefined(value) ? defaultValue : value)), 192 | 193 | nullToObject: () => new ValueMapper<{} | In, In>(value => (isNullOrUndefined(value) ? {} : value)), 194 | 195 | nullToArray: () => new ValueMapper<[] | In, In>(value => (isNullOrUndefined(value) ? [] : value)), 196 | 197 | array: (...items: CompositionParameters) => 198 | new ArrayValidator(maybeCompositionOf(...items)), 199 | 200 | toArray: (...items: CompositionParameters) => 201 | new ArrayNormalizer(maybeCompositionOf(...items)), 202 | 203 | size: (min: number, max: number) => new SizeValidator(min, max), 204 | 205 | properties: (keys: Validator, values: Validator) => 206 | new ObjectValidator>({ additionalProperties: { keys, values } }), 207 | 208 | optionalProperties: (keys: Validator, values: Validator) => 209 | new ObjectValidator>>({ additionalProperties: { keys, values } }), 210 | 211 | allOf: AllOfConstructor, 212 | 213 | anyOf: , ...Validator[]]>(...validators: V) => new AnyOfValidator>(validators), 214 | 215 | oneOf: , ...Validator[]]>(...validators: V) => new OneOfValidator>(validators), 216 | 217 | compositionOf: (...validators: CompositionParameters) => 218 | maybeCompositionOf(...validators), 219 | 220 | date: () => dateValidator, 221 | 222 | enum: >(enumType: Out, name: string) => new EnumValidator(enumType, name), 223 | 224 | assertTrue: (fn: AssertTrue, type: string = 'AssertTrue', path?: Path) => new AssertTrueValidator(fn, type, path), 225 | 226 | hasValue: (expectedValue: InOut) => new HasValueValidator(expectedValue), 227 | 228 | json: (...validators: CompositionParameters) => 229 | new JsonValidator(maybeCompositionOf(...validators)), 230 | }; 231 | Object.freeze(V); 232 | -------------------------------------------------------------------------------- /packages/path/src/PathMatcher.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { PathMatcher, AnyIndex, AnyProperty, Node, IndexMatcher, PropertyMatcher } from './PathMatcher.js'; 3 | import { Path } from './Path.js'; 4 | import { UnionMatcher } from './matchers.js'; 5 | 6 | describe('path', () => { 7 | const obj = { 8 | id: 'id', 9 | name: 'name', 10 | object: { 11 | 'undefined': undefined, 12 | id: 'nested id', 13 | name: 'nested name', 14 | }, 15 | array: [ 16 | { 17 | name: 'a', 18 | value: 123, 19 | }, 20 | { 21 | name: 'b', 22 | value: 456, 23 | }, 24 | { 25 | name: 'c', 26 | value: 789, 27 | }, 28 | undefined, 29 | ], 30 | }; 31 | 32 | describe('findAll', () => { 33 | test('root', () => expect(PathMatcher.of().findAll('root')).toEqual([{ path: Path.of(), value: 'root'}])); 34 | 35 | test('property of non-object', () => expect(PathMatcher.of('length').findAll('root')).toEqual([])); 36 | 37 | test('nested object name', () => expect(PathMatcher.of('object', 'name').findAll(obj)).toEqual([{ path: Path.of('object', 'name'), value: 'nested name'}])); 38 | 39 | test('nested array element values', () => 40 | expect(PathMatcher.of('array', AnyIndex, 'value').findAll(obj)).toEqual([ 41 | { path: Path.of('array', 0, 'value'), value: 123 }, 42 | { path: Path.of('array', 1, 'value'), value: 456 }, 43 | { path: Path.of('array', 2, 'value'), value: 789 }, 44 | ])); 45 | 46 | test('nested object wildcard', () => 47 | expect(PathMatcher.of('object', AnyProperty).findAll(obj)).toEqual([ 48 | { path: Path.of('object', 'id'), value: 'nested id' }, 49 | { path: Path.of('object', 'name'), value: 'nested name' }, 50 | ])); 51 | 52 | test('non-existing property', () => expect(PathMatcher.of('non-existing', 0).findAll(obj)).toEqual([])); 53 | 54 | test('non-existing index', () => expect(PathMatcher.of(999, 'property').findAll(obj)).toEqual([])); 55 | 56 | test("only object's properties are accessible", () => expect(PathMatcher.of('id', 'length').findAll(obj)).toEqual([])); 57 | 58 | test("only arrays's indexes are accessible", () => expect(PathMatcher.of('object', 0).findAll(obj)).toEqual([])); 59 | 60 | test('AnyIndex only works on an array', () => expect(PathMatcher.of(AnyIndex).findAll(obj)).toEqual([])); 61 | 62 | test('AnyProperty only works on an object', () => expect(PathMatcher.of('id', AnyProperty).findAll(obj)).toEqual([])); 63 | 64 | test('union of properties', () => 65 | expect(PathMatcher.of(new UnionMatcher(['id', 'name'])).findAll(obj)).toEqual([ 66 | { path: Path.of('id'), value: 'id' }, 67 | { path: Path.of('name'), value: 'name' } 68 | ])); 69 | 70 | test('union of indexes', () => 71 | expect(PathMatcher.of('array', new UnionMatcher([0, 2]), 'name').findAll(obj)).toEqual([ 72 | { path: Path.of('array', 0, 'name'), value: 'a' }, 73 | { path: Path.of('array', 2, 'name'), value: 'c' }, 74 | ])); 75 | 76 | test('union of properties on a string', () => expect(PathMatcher.of('name', new UnionMatcher(['length', 'prototype'])).findAll(obj)).toEqual([])); 77 | 78 | describe('acceptUndefined', () => { 79 | test('nested array element values', () => { 80 | const nodes = PathMatcher.of('array', AnyIndex).findAll(obj, true); 81 | expect(nodes.length).toBe(4); 82 | expect(nodes[3]).toEqual({ path: Path.of('array', 3), value: undefined }); 83 | }); 84 | 85 | test('nested object wildcard', () => { 86 | const nodes = PathMatcher.of('object', AnyProperty).findAll(obj, true); 87 | expect(nodes.length).toBe(3); 88 | expect(nodes[0]).toEqual({ path: Path.of('object', 'undefined'), value: undefined }); 89 | }); 90 | }); 91 | }); 92 | 93 | describe('match', () => { 94 | test('root', () => expect(PathMatcher.of().match(Path.of())).toBe(true)); 95 | 96 | test('property', () => expect(PathMatcher.of('name').match(Path.of('name'))).toBe(true)); 97 | 98 | test("property doesn't match", () => expect(PathMatcher.of('name').match(Path.of('Name'))).toBe(false)); 99 | 100 | test('index', () => expect(PathMatcher.of(0).match(Path.of(0))).toBe(true)); 101 | 102 | test("index doesn't match", () => expect(PathMatcher.of(0).match(Path.of(1))).toBe(false)); 103 | 104 | test('any property', () => expect(PathMatcher.of(AnyProperty).match(Path.of('name'))).toBe(true)); 105 | 106 | test('any property matches index', () => expect(PathMatcher.of(AnyProperty).match(Path.of(1))).toBe(true)); 107 | 108 | test('any index', () => expect(PathMatcher.of(AnyIndex).match(Path.of(123))).toBe(true)); 109 | 110 | test("any index doesn't match property", () => expect(PathMatcher.of(AnyIndex).match(Path.of('name'))).toBe(false)); 111 | 112 | test('too short path', () => expect(PathMatcher.of('array', 0).match(Path.of('array'))).toBe(false)); 113 | 114 | test('too long path', () => expect(PathMatcher.of('array').match(Path.of('array', 0))).toBe(false)); 115 | 116 | test('match union index', () => expect(PathMatcher.of(new UnionMatcher([0, 'foo'])).match(Path.of(0))).toBe(true)); 117 | 118 | test('match union property', () => expect(PathMatcher.of(UnionMatcher.of(0, 'foo')).match(Path.of('foo'))).toBe(true)); 119 | 120 | test('match union not found', () => expect(PathMatcher.of(UnionMatcher.of(0, 'foo')).match(Path.of(1))).toBe(false)); 121 | }); 122 | 123 | describe('prefixMatch', () => { 124 | test('root', () => expect(PathMatcher.of().prefixMatch(Path.of())).toBe(true)); 125 | 126 | test('match prefix of path', () => expect(PathMatcher.of('array').prefixMatch(Path.of('array', 0))).toBe(true)); 127 | 128 | test("property doesn't match", () => expect(PathMatcher.of('name').prefixMatch(Path.of('Name'))).toBe(false)); 129 | 130 | test('index', () => expect(PathMatcher.of(0).prefixMatch(Path.of(0))).toBe(true)); 131 | 132 | test("index doesn't match", () => expect(PathMatcher.of(0).prefixMatch(Path.of(1))).toBe(false)); 133 | 134 | test('any property', () => expect(PathMatcher.of(AnyProperty).prefixMatch(Path.of('name'))).toBe(true)); 135 | 136 | test('any property matches index', () => expect(PathMatcher.of(AnyProperty).prefixMatch(Path.of(1))).toBe(true)); 137 | 138 | test('any index', () => expect(PathMatcher.of(AnyIndex).prefixMatch(Path.of(123))).toBe(true)); 139 | 140 | test("any index doesn't match property", () => expect(PathMatcher.of(AnyIndex).prefixMatch(Path.of('name'))).toBe(false)); 141 | 142 | test('too short path', () => expect(PathMatcher.of('array', 0).prefixMatch(Path.of('array'))).toBe(false)); 143 | }); 144 | 145 | describe('findFirst', () => { 146 | test('first array element', () => expect(PathMatcher.of('array', AnyIndex, 'value').findFirst(obj)).toEqual({ path: Path.of('array', 0, 'value'), value: 123 })); 147 | 148 | test('last array element', () => expect(PathMatcher.of('array', 2, 'value').findFirst(obj)).toEqual({ path: Path.of('array', 2, 'value'), value: 789 })); 149 | 150 | test('non-existing array element', () => expect(PathMatcher.of('array', 3, 'value').findFirst(obj)).toBeUndefined()); 151 | 152 | test('first property', () => expect(PathMatcher.of(AnyProperty).findFirst(obj)).toEqual({ path: Path.of('id'), value: 'id' })); 153 | 154 | test('non-existing array element', () => expect(PathMatcher.of(AnyProperty, 'non-existing property').findFirst(obj)).toBeUndefined()); 155 | 156 | test('acceptUndefined=true', () => expect(PathMatcher.of('object', AnyProperty).findFirst(obj, true)).toEqual({ path: Path.of('object', 'undefined'), value: undefined })) 157 | 158 | test('acceptUndefined=false', () => expect(PathMatcher.of('object', AnyProperty).findFirst(obj, false)).toEqual({ path: Path.of('object', 'id'), value: 'nested id' })) 159 | }); 160 | 161 | describe('findValues', () => { 162 | test('root', () => expect(PathMatcher.of().findValues('root')).toEqual(['root'])); 163 | 164 | test('property of non-object', () => expect(PathMatcher.of('length').findValues('root')).toEqual([])); 165 | 166 | test('nested object name', () => expect(PathMatcher.of('object', 'name').findValues(obj)).toEqual(['nested name'])); 167 | 168 | test('nested array element values', () => expect(PathMatcher.of('array', AnyIndex, 'value').findValues(obj)).toEqual([123, 456, 789])); 169 | 170 | test('nested object wildcard', () => expect(PathMatcher.of('object', AnyProperty).findValues(obj)).toEqual(['nested id', 'nested name'])); 171 | 172 | test('acceptUndefined=true', () => expect(PathMatcher.of('object', AnyProperty).findValues(obj, true)).toEqual([undefined, 'nested id', 'nested name'])); 173 | 174 | test('acceptUndefined=false', () => expect(PathMatcher.of('object', AnyProperty).findValues(obj, false)).toEqual(['nested id', 'nested name'])); 175 | }); 176 | 177 | describe('findFirstValue', () => { 178 | test('first array element', () => expect(PathMatcher.of('array', AnyIndex, 'value').findFirstValue(obj)).toEqual(123)); 179 | 180 | test('last array element', () => expect(PathMatcher.of('array', 2, 'value').findFirstValue(obj)).toEqual(789)); 181 | 182 | test('non-existing array element', () => expect(PathMatcher.of('array', 3, 'value').findFirstValue(obj)).toBeUndefined()); 183 | 184 | test('first property', () => expect(PathMatcher.of(AnyProperty).findFirstValue(obj)).toEqual('id')); 185 | 186 | test('non-existing array element', () => expect(PathMatcher.of(AnyProperty, 'non-existing property').findFirstValue(obj)).toBeUndefined()); 187 | 188 | test('first value of an union', () => expect(PathMatcher.of('array', AnyIndex, new UnionMatcher(['name', 'value'])).findFirstValue(obj)).toEqual('a')); 189 | 190 | test('acceptUndefined=true', () => expect(PathMatcher.of('object', AnyProperty).findFirstValue(obj, true)).toBeUndefined()); 191 | 192 | test('acceptUndefined=false', () => expect(PathMatcher.of('object', AnyProperty).findFirstValue(obj, false)).toEqual('nested id')); 193 | }); 194 | 195 | describe('matchers', () => { 196 | test('UnionMatcher requires two components', () => expect(() => new UnionMatcher(['foo'])).toThrow()); 197 | 198 | test('IndexMatcher requires a number', () => expect(() => new IndexMatcher('foo' as any)).toThrow()); 199 | 200 | test('IndexMatcher requires a number >= 0', () => expect(() => new IndexMatcher(-1)).toThrow()); 201 | 202 | test('PropertyMatcher requires a string', () => expect(() => new PropertyMatcher(123 as any)).toThrow()); 203 | }); 204 | 205 | describe('toJSON', () => { 206 | test('all component types', () => 207 | expect(PathMatcher.of('property', AnyIndex, '"quoted"', AnyProperty, 0, new UnionMatcher(['"quoted"', 1])).toJSON()).toEqual( 208 | '$.property[*]["\\"quoted\\""].*[0]["\\"quoted\\"",1]', 209 | )); 210 | }); 211 | 212 | test('array is not a valid component', () => { 213 | const array: any = []; 214 | expect(() => PathMatcher.of(array)).toThrow(); 215 | }); 216 | }); 217 | -------------------------------------------------------------------------------- /packages/path-parser/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | # [10.2.0-alpha.0](https://github.com/finnair/v-validation/compare/v10.1.0...v10.2.0-alpha.0) (2025-11-28) 7 | 8 | **Note:** Version bump only for package @finnair/path-parser 9 | 10 | # [10.1.0](https://github.com/finnair/v-validation/compare/v10.0.0...v10.1.0) (2025-11-11) 11 | 12 | **Note:** Version bump only for package @finnair/path-parser 13 | 14 | # [10.0.0](https://github.com/finnair/v-validation/compare/v9.2.0...v10.0.0) (2025-09-15) 15 | 16 | **Note:** Version bump only for package @finnair/path-parser 17 | 18 | # [9.2.0](https://github.com/finnair/v-validation/compare/v9.1.1...v9.2.0) (2025-05-21) 19 | 20 | **Note:** Version bump only for package @finnair/path-parser 21 | 22 | ## [9.1.1](https://github.com/finnair/v-validation/compare/v9.1.0...v9.1.1) (2025-05-19) 23 | 24 | **Note:** Version bump only for package @finnair/path-parser 25 | 26 | # [9.1.0](https://github.com/finnair/v-validation/compare/v9.0.0...v9.1.0) (2025-05-19) 27 | 28 | **Note:** Version bump only for package @finnair/path-parser 29 | 30 | # [9.0.0](https://github.com/finnair/v-validation/compare/v8.0.0...v9.0.0) (2025-03-13) 31 | 32 | **Note:** Version bump only for package @finnair/path-parser 33 | 34 | # [8.0.0](https://github.com/finnair/v-validation/compare/v7.3.0...v8.0.0) (2025-02-03) 35 | 36 | **Note:** Version bump only for package @finnair/path-parser 37 | 38 | # [7.3.0](https://github.com/finnair/v-validation/compare/v7.2.0...v7.3.0) (2025-01-31) 39 | 40 | **Note:** Version bump only for package @finnair/path-parser 41 | 42 | # [7.2.0](https://github.com/finnair/v-validation/compare/v7.1.0...v7.2.0) (2025-01-29) 43 | 44 | **Note:** Version bump only for package @finnair/path-parser 45 | 46 | # [7.1.0](https://github.com/finnair/v-validation/compare/v7.0.0...v7.1.0) (2025-01-23) 47 | 48 | **Note:** Version bump only for package @finnair/path-parser 49 | 50 | # [7.0.0](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.9...v7.0.0) (2025-01-07) 51 | 52 | **Note:** Version bump only for package @finnair/path-parser 53 | 54 | # [7.0.0](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.9...v7.0.0) (2025-01-07) 55 | 56 | **Note:** Version bump only for package @finnair/path-parser 57 | 58 | # [7.0.0-alpha.9](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.8...v7.0.0-alpha.9) (2024-12-11) 59 | 60 | **Note:** Version bump only for package @finnair/path-parser 61 | 62 | # [7.0.0-alpha.8](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.7...v7.0.0-alpha.8) (2024-11-21) 63 | 64 | **Note:** Version bump only for package @finnair/path-parser 65 | 66 | # [7.0.0-alpha.7](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.6...v7.0.0-alpha.7) (2024-11-21) 67 | 68 | ### Bug Fixes 69 | 70 | - package.json exports ([7edf1ee](https://github.com/finnair/v-validation/commit/7edf1ee0b2295c7659802aab10963a0579869e5a)) 71 | 72 | # [7.0.0-alpha.6](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.5...v7.0.0-alpha.6) (2024-11-18) 73 | 74 | **Note:** Version bump only for package @finnair/path-parser 75 | 76 | # [7.0.0-alpha.5](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.4...v7.0.0-alpha.5) (2024-11-14) 77 | 78 | **Note:** Version bump only for package @finnair/path-parser 79 | 80 | # [7.0.0-alpha.4](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.3...v7.0.0-alpha.4) (2024-11-14) 81 | 82 | **Note:** Version bump only for package @finnair/path-parser 83 | 84 | # [7.0.0-alpha.3](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.2...v7.0.0-alpha.3) (2024-11-11) 85 | 86 | **Note:** Version bump only for package @finnair/path-parser 87 | 88 | # [7.0.0-alpha.2](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.1...v7.0.0-alpha.2) (2024-11-08) 89 | 90 | **Note:** Version bump only for package @finnair/path-parser 91 | 92 | # [7.0.0-alpha.1](https://github.com/finnair/v-validation/compare/v7.0.0-alpha.0...v7.0.0-alpha.1) (2024-11-08) 93 | 94 | **Note:** Version bump only for package @finnair/path-parser 95 | 96 | # [7.0.0-alpha.0](https://github.com/finnair/v-validation/compare/v6.1.0...v7.0.0-alpha.0) (2024-11-08) 97 | 98 | **Note:** Version bump only for package @finnair/path-parser 99 | 100 | # [6.1.0](https://github.com/finnair/v-validation/compare/v6.0.2...v6.1.0) (2024-10-21) 101 | 102 | **Note:** Version bump only for package @finnair/path-parser 103 | 104 | ## [6.0.2](https://github.com/finnair/v-validation/compare/v6.0.1...v6.0.2) (2024-09-17) 105 | 106 | **Note:** Version bump only for package @finnair/path-parser 107 | 108 | ## [6.0.1](https://github.com/finnair/v-validation/compare/v6.0.0...v6.0.1) (2024-09-16) 109 | 110 | **Note:** Version bump only for package @finnair/path-parser 111 | 112 | # [6.0.0](https://github.com/finnair/v-validation/compare/v5.4.0...v6.0.0) (2024-09-16) 113 | 114 | **Note:** Version bump only for package @finnair/path-parser 115 | 116 | # [5.4.0](https://github.com/finnair/v-validation/compare/v5.3.0...v5.4.0) (2024-05-08) 117 | 118 | **Note:** Version bump only for package @finnair/path-parser 119 | 120 | # [5.3.0](https://github.com/finnair/v-validation/compare/v5.2.0...v5.3.0) (2024-03-20) 121 | 122 | **Note:** Version bump only for package @finnair/path-parser 123 | 124 | # [5.2.0](https://github.com/finnair/v-validation/compare/v5.1.0...v5.2.0) (2024-02-14) 125 | 126 | ### Bug Fixes 127 | 128 | - use peerDependencies to other v-validation packages to avoid duplicate dependencies ([#101](https://github.com/finnair/v-validation/issues/101)) ([ae1def0](https://github.com/finnair/v-validation/commit/ae1def01e10d3491949424a76c986caa82e7a4d2)) 129 | 130 | # [5.1.0](https://github.com/finnair/v-validation/compare/v5.0.1...v5.1.0) (2023-11-16) 131 | 132 | ### Features 133 | 134 | - support both ESM and CommonJS ([#96](https://github.com/finnair/v-validation/issues/96)) ([c32a104](https://github.com/finnair/v-validation/commit/c32a1040cd2e0412005cf9e6ff869569ab194950)) 135 | 136 | ## [5.0.1](https://github.com/finnair/v-validation/compare/v5.0.0...v5.0.1) (2023-10-13) 137 | 138 | ### Bug Fixes 139 | 140 | - moo import ([a866737](https://github.com/finnair/v-validation/commit/a8667371515fd2fe92fa2e32b382ccc55612b704)) 141 | - npm ignore node_modules for published packages ([9690faf](https://github.com/finnair/v-validation/commit/9690fafb8289e6448dbbeb6274054a8f2b01907a)) 142 | - revert npm ignore node_modules for published packages ([53acd31](https://github.com/finnair/v-validation/commit/53acd312441e823d507152f371ecca0367412c18)) 143 | 144 | **Note:** Version bump only for package @finnair/path-parser 145 | 146 | ## [5.0.1](https://github.com/finnair/v-validation/compare/v5.0.0...v5.0.1) (2023-10-13) 147 | 148 | ### Bug Fixes 149 | 150 | - moo import ([a866737](https://github.com/finnair/v-validation/commit/a8667371515fd2fe92fa2e32b382ccc55612b704)) 151 | - npm ignore node_modules for published packages ([9690faf](https://github.com/finnair/v-validation/commit/9690fafb8289e6448dbbeb6274054a8f2b01907a)) 152 | - revert npm ignore node_modules for published packages ([53acd31](https://github.com/finnair/v-validation/commit/53acd312441e823d507152f371ecca0367412c18)) 153 | 154 | # [5.0.0](https://github.com/finnair/v-validation/compare/v4.3.0...v5.0.0) (2023-10-13) 155 | 156 | ### Features 157 | 158 | - use ECMAScript modules (ESM) instead of CommonJS ([#95](https://github.com/finnair/v-validation/issues/95)) ([92e9118](https://github.com/finnair/v-validation/commit/92e9118235957ec4bc2bcf2de73e195ea940378c)) 159 | 160 | # [5.0.0](https://github.com/finnair/v-validation/compare/v4.3.0...v5.0.0) (2023-10-13) 161 | 162 | ### Features 163 | 164 | - use ECMAScript modules (ESM) instead of CommonJS ([#95](https://github.com/finnair/v-validation/issues/95)) ([92e9118](https://github.com/finnair/v-validation/commit/92e9118235957ec4bc2bcf2de73e195ea940378c)) 165 | 166 | # [4.0.0](https://github.com/finnair/v-validation/compare/v3.2.0...v4.0.0) (2022-11-07) 167 | 168 | **Note:** Version bump only for package @finnair/path-parser 169 | 170 | # [3.1.0](https://github.com/finnair/v-validation/compare/v3.0.0...v3.1.0) (2022-10-24) 171 | 172 | **Note:** Version bump only for package @finnair/path-parser 173 | 174 | # [3.0.0](https://github.com/finnair/v-validation/compare/v2.0.0...v3.0.0) (2022-10-03) 175 | 176 | **Note:** Version bump only for package @finnair/path-parser 177 | 178 | # [2.0.0](https://github.com/finnair/v-validation/compare/v1.1.0...v2.0.0) (2022-09-08) 179 | 180 | - Upgrade All Dependencies (#80) ([fb6309c](https://github.com/finnair/v-validation/commit/fb6309cc1d9fd90f3e8c5ba79798fae1450b66a6)), closes [#80](https://github.com/finnair/v-validation/issues/80) 181 | 182 | ### BREAKING CHANGES 183 | 184 | - Drop Node 12 support 185 | - Add .nvmrc 186 | - Introduce CI build for Node 18 187 | - Upgrade All Dependencies 188 | 189 | # [1.1.0](https://github.com/finnair/v-validation/compare/v1.0.1...v1.1.0) (2022-08-29) 190 | 191 | ### Features 192 | 193 | - Support for Luxon ([94b3806](https://github.com/finnair/v-validation/commit/94b38060e07feeb0abb8c81659d8bda537a4d9aa)) 194 | 195 | # [1.1.0-alpha.6](https://github.com/finnair/v-validation/compare/v1.0.1...v1.1.0-alpha.6) (2022-08-17) 196 | 197 | **Note:** Version bump only for package @finnair/path-parser 198 | 199 | # [1.1.0-alpha.5](https://github.com/finnair/v-validation/compare/v1.0.1...v1.1.0-alpha.5) (2022-08-16) 200 | 201 | **Note:** Version bump only for package @finnair/path-parser 202 | 203 | # [1.1.0-alpha.0](https://github.com/finnair/v-validation/compare/v1.0.1...v1.1.0-alpha.0) (2022-08-12) 204 | 205 | **Note:** Version bump only for package @finnair/path-parser 206 | 207 | ## [1.0.1](https://github.com/finnair/v-validation/compare/v0.9.1...v1.0.1) (2022-01-20) 208 | 209 | ### Features 210 | 211 | - Add support for Node v14 and v16 212 | - Update Jest and Lerna 213 | - Update other vulnerable packages 214 | 215 | ### BREAKING CHANGES 216 | 217 | - Drop support for Node v10 218 | 219 | # [0.9.0](https://github.com/finnair/v-validation/compare/v0.8.0...v0.9.0) (2020-09-23) 220 | 221 | **Note:** Version bump only for package @finnair/path-parser 222 | 223 | # [0.8.0](https://github.com/finnair/v-validation/compare/v0.7.0...v0.8.0) (2020-09-09) 224 | 225 | **Note:** Version bump only for package @finnair/path-parser 226 | 227 | # [0.7.0](https://github.com/finnair/v-validation/compare/v0.6.2...v0.7.0) (2020-08-26) 228 | 229 | **Note:** Version bump only for package @finnair/path-parser 230 | 231 | ## [0.6.2](https://github.com/finnair/v-validation/compare/v0.6.1...v0.6.2) (2020-05-22) 232 | 233 | ### Bug Fixes 234 | 235 | - Update Moment.js and fix dependencies ([2e9a16d](https://github.com/finnair/v-validation/commit/2e9a16d297994a557133a853ed6556d16552c21a)) 236 | 237 | ## [0.6.1](https://github.com/finnair/v-validation/compare/v0.6.0...v0.6.1) (2020-05-22) 238 | 239 | ### Bug Fixes 240 | 241 | - NPM ignore node_modules ([9f1264f](https://github.com/finnair/v-validation/commit/9f1264f5086e406d30f94f5a47aa3fb6956d725a)) 242 | 243 | # [0.6.0](https://github.com/finnair/v-validation/compare/v0.5.0...v0.6.0) (2020-05-19) 244 | 245 | - Parsers for `Path` and `PathMatcher` (`@finnair/path`) 246 | 247 | # [0.5.0](https://github.com/finnair/v-validation/compare/v0.4.0...v0.5.0) (2020-05-19) 248 | 249 | Publish failed. 250 | --------------------------------------------------------------------------------