Skip to content

Commit 5640e2d

Browse files
committed
introduce: $ref-reducer
1 parent 162d50e commit 5640e2d

14 files changed

+303
-116
lines changed

src/SchemaNode.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -338,9 +338,8 @@ export const SchemaNodeMethods = {
338338
data: unknown,
339339
options: { key?: string | number; pointer?: string; path?: ValidationPath } = {}
340340
): OptionalNodeAndError {
341+
const node = this as SchemaNode;
341342
const { key, pointer, path } = options;
342-
const resolvedNode = { ...this.resolveRef({ pointer, path }) } as SchemaNode;
343-
const node = mergeNode(this, resolvedNode, "$ref");
344343

345344
// @ts-expect-error bool schema
346345
if (node.schema === false) {
@@ -353,10 +352,12 @@ export const SchemaNodeMethods = {
353352
}
354353

355354
let schema;
356-
let workingNode = node;
355+
// we need to copy node to prevent modification of source
356+
// @todo does mergeNode break immutability?
357+
let workingNode = node.compileSchema(node.schema, node.spointer, node.schemaId);
357358
const reducers = node.reducers;
358359
for (let i = 0; i < reducers.length; i += 1) {
359-
const result = reducers[i]({ data, key, node, pointer });
360+
const result = reducers[i]({ data, key, node, pointer, path });
360361
if (isJsonError(result)) {
361362
return { node: undefined, error: result };
362363
}
@@ -374,7 +375,6 @@ export const SchemaNodeMethods = {
374375
}
375376

376377
if (schema === false) {
377-
console.log("return boolean schema `false`");
378378
// @ts-expect-error bool schema
379379
return { node: { ...node, schema: false, reducers: [] } as SchemaNode, error: undefined };
380380
}

src/compileSchema.getChild.test.ts

Lines changed: 7 additions & 54 deletions
Large diffs are not rendered by default.

src/compileSchema.reduceSchema.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ describe("compileSchema : reduceSchema", () => {
156156
}
157157
}
158158
}).reduceSchema({ one: "" });
159+
159160
assert.deepEqual(node.schema, {
160161
type: "object",
161162
required: ["one", "two", "three"],
@@ -677,6 +678,49 @@ describe("compileSchema : reduceSchema", () => {
677678
});
678679
});
679680

681+
describe("$ref", () => {
682+
it("should resolve to $ref referenced schema", () => {
683+
const { node } = compileSchema({
684+
$ref: "/$defs/one",
685+
$defs: {
686+
one: { type: "boolean", title: "one" }
687+
}
688+
}).reduceSchema(3);
689+
690+
assert.deepEqual(node.schema, { type: "boolean", title: "one" });
691+
});
692+
693+
it("should resolve to multiple $ref referenced schema", () => {
694+
const { node } = compileSchema({
695+
$ref: "/$defs/one",
696+
$defs: {
697+
one: { $ref: "/$defs/two" },
698+
two: { type: "boolean", title: "two" }
699+
}
700+
}).reduceSchema(3);
701+
702+
assert.deepEqual(node.schema, { type: "boolean", title: "two" });
703+
});
704+
705+
it("should merge nested sub-schema", () => {
706+
const { node } = compileSchema({
707+
$ref: "/$defs/one",
708+
description: "from root",
709+
$defs: {
710+
one: { default: "from one", $ref: "/$defs/two" },
711+
two: { type: "boolean", title: "from two" }
712+
}
713+
}).reduceSchema(3);
714+
715+
assert.deepEqual(node.schema, {
716+
description: "from root",
717+
default: "from one",
718+
title: "from two",
719+
type: "boolean"
720+
});
721+
});
722+
});
723+
680724
describe("dynamicId", () => {
681725
it("should add dynamicId based on merge anyOf schema", () => {
682726
const { node } = compileSchema({

src/compileSchema.validate.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,62 @@ describe("compileSchema.validate", () => {
748748
});
749749
});
750750

751+
describe("unavaluatedProperties", () => {
752+
let node: SchemaNode;
753+
beforeEach(
754+
() =>
755+
(node = compileSchema({
756+
$schema: "https://json-schema.org/draft/2020-12/schema",
757+
$defs: {
758+
one: {
759+
oneOf: [
760+
{ $ref: "#/$defs/two" },
761+
{ required: ["b"], properties: { b: true } },
762+
{
763+
required: ["xx"],
764+
patternProperties: {
765+
x: true
766+
}
767+
},
768+
{
769+
required: ["all"],
770+
unevaluatedProperties: true
771+
}
772+
]
773+
},
774+
two: {
775+
oneOf: [
776+
{
777+
required: ["c"],
778+
properties: {
779+
c: true
780+
}
781+
},
782+
{
783+
required: ["d"],
784+
properties: {
785+
d: true
786+
}
787+
}
788+
]
789+
}
790+
},
791+
oneOf: [{ $ref: "#/$defs/one" }, { required: ["a"], properties: { a: true } }],
792+
unevaluatedProperties: false
793+
}))
794+
);
795+
796+
it("`all` is valid", () => {
797+
const { errors } = node.validate({ all: 1 });
798+
assert(errors.length === 0);
799+
});
800+
801+
it("`all` and `foo` is valid", () => {
802+
const { errors } = node.validate({ all: 1, foo: 1 });
803+
assert(errors.length === 0);
804+
});
805+
});
806+
751807
describe("recursiveRef (spec)", () => {
752808
describe("$recursiveRef without using nesting", () => {
753809
it("integer does not match as a property value", () => {

src/draft2019-09/keywords/$ref.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ import { isObject } from "../../utils/isObject";
66
import { validateNode } from "../../validateNode";
77
import { SchemaNode } from "../../types";
88
import { get } from "@sagold/json-pointer";
9+
import { reduceRef } from "../../keywords/$ref";
910

1011
export const $refKeyword: Keyword = {
1112
id: "$ref",
1213
keyword: "$ref",
1314
parse: parseRef,
1415
addValidate: ({ schema }) => schema.$ref != null || schema.$recursiveRef != null,
15-
validate: validateRef
16+
validate: validateRef,
17+
addReduce: ({ schema }) => schema.$ref != null || schema.$recursiveRef != null,
18+
reduce: reduceRef
1619
};
1720

1821
function register(node: SchemaNode, path: string) {
@@ -54,10 +57,23 @@ export function parseRef(node: SchemaNode) {
5457
}
5558
}
5659

60+
// export function reduceRef({ node, data, key, pointer, path }: JsonSchemaReducerParams) {
61+
// const resolvedNode = node.resolveRef({ pointer, path });
62+
// if (resolvedNode.schemaId === node.schemaId) {
63+
// return resolvedNode;
64+
// }
65+
// const result = resolvedNode.reduceSchema(data, { key, pointer, path });
66+
// return result.node ?? result.error;
67+
// // const merged = mergeNode({ ...node, $ref: undefined, schema: { ...node.schema, $ref: undefined } }, resolvedNode);
68+
// // const { node: reducedNode, error } = merged.reduceSchema(data, { key, pointer, path });
69+
// // return reducedNode ?? error;
70+
// }
71+
5772
export function resolveRef({ pointer, path }: { pointer?: string; path?: ValidationPath } = {}) {
5873
const node = this as SchemaNode;
5974
if (node.schema.$recursiveRef) {
6075
const nextNode = resolveRecursiveRef(node, path);
76+
console.log("recursive ref:", nextNode.schema);
6177
path?.push({ pointer, node: nextNode });
6278
return nextNode;
6379
}

src/isItemEvaluated.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ export function isItemEvaluated({ node, data, key, pointer, path }: Options) {
4848
}
4949
}
5050
}
51+
52+
if (node.oneOf) {
53+
for (let i = 0; i < node.oneOf.length; i += 1) {
54+
if (isItemEvaluated({ node: node.oneOf[i], data, key, pointer, path })) {
55+
return true;
56+
}
57+
}
58+
}
59+
5160
if (node.if) {
5261
if (isItemEvaluated({ node: node.if, data, key, pointer, path })) {
5362
return true;

src/isPropertyEvaluated.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { ValidationPath } from "./Keyword";
2+
import { SchemaNode } from "./types";
3+
import { hasProperty } from "./utils/hasProperty";
4+
// import { getValue } from "./utils/getValue";
5+
import { validateNode } from "./validateNode";
6+
7+
type Options = {
8+
/** array node */
9+
node: SchemaNode;
10+
/** array data */
11+
data: Record<string, unknown>;
12+
/** array index to evaluate */
13+
key: string;
14+
/** pointer to array */
15+
pointer: string;
16+
17+
path: ValidationPath;
18+
};
19+
20+
/**
21+
* Returns true if an item is evaluated
22+
*
23+
* - Note that this check is partial, the remainder is done in unevaluatedItems
24+
* - This function currently checks for schema that are not visible by simple validation
25+
* - We could introduce this method as a new keyword-layer
26+
*/
27+
export function isPropertyEvaluated({ node, data, key, pointer, path }: Options) {
28+
if (Array.isArray(node.schema.required) && !node.schema.required.find((prop) => hasProperty(data, prop))) {
29+
return false;
30+
}
31+
32+
if (node.schema.unevaluatedProperties === true || node.schema.additionalProperties === true) {
33+
return true;
34+
}
35+
36+
if (node.properties?.[key] && node.properties[key].validate(data[key], pointer, path).valid) {
37+
return true;
38+
}
39+
40+
if (node.patternProperties && node.patternProperties.find((p) => p.pattern.test(key))) {
41+
return true;
42+
}
43+
44+
if (node.allOf) {
45+
for (let i = 0; i < node.allOf.length; i += 1) {
46+
if (isPropertyEvaluated({ node: node.allOf[i], data, key, pointer, path })) {
47+
return true;
48+
}
49+
}
50+
}
51+
52+
if (node.anyOf) {
53+
for (let i = 0; i < node.anyOf.length; i += 1) {
54+
if (isPropertyEvaluated({ node: node.anyOf[i], data, key, pointer, path })) {
55+
return true;
56+
}
57+
}
58+
}
59+
60+
if (node.oneOf) {
61+
for (let i = 0; i < node.oneOf.length; i += 1) {
62+
if (isPropertyEvaluated({ node: node.oneOf[i], data, key, pointer, path })) {
63+
return true;
64+
}
65+
}
66+
}
67+
68+
if (node.if) {
69+
if (isPropertyEvaluated({ node: node.if, data, key, pointer, path })) {
70+
return true;
71+
}
72+
73+
const validIf = validateNode(node.if, data, pointer, path).length === 0;
74+
if (validIf && node.then) {
75+
if (isPropertyEvaluated({ node: node.then, data, key, pointer, path })) {
76+
return true;
77+
}
78+
} else if (!validIf && node.else) {
79+
if (isPropertyEvaluated({ node: node.else, data, key, pointer, path })) {
80+
return true;
81+
}
82+
}
83+
}
84+
85+
const resolved = node.resolveRef({ pointer, path });
86+
if (resolved !== node) {
87+
if (isPropertyEvaluated({ node: resolved, data, key, pointer, path })) {
88+
return true;
89+
}
90+
}
91+
92+
return false;
93+
}

src/keywords/$ref.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import { SchemaNode } from "../types";
2-
import { Keyword, JsonSchemaValidatorParams, ValidationPath } from "../Keyword";
2+
import { Keyword, JsonSchemaValidatorParams, ValidationPath, JsonSchemaReducerParams } from "../Keyword";
33
import { joinId } from "../utils/joinId";
44
import splitRef from "../utils/splitRef";
55
import { omit } from "../utils/omit";
66
import { isObject } from "../utils/isObject";
77
import { validateNode } from "../validateNode";
88
import { get, split } from "@sagold/json-pointer";
9+
import { mergeNode } from "../mergeNode";
910

1011
export const $refKeyword: Keyword = {
1112
id: "$ref",
1213
keyword: "$ref",
14+
order: 10,
1315
parse: parseRef,
16+
addReduce: (node) => node.$ref != null || node.schema.$dynamicRef != null,
17+
reduce: reduceRef,
1418
addValidate: ({ schema }) => schema.$ref != null || schema.$dynamicRef != null,
1519
validate: validateRef
1620
};
@@ -69,11 +73,21 @@ export function parseRef(node: SchemaNode) {
6973
}
7074
}
7175

76+
export function reduceRef({ node, data, key, pointer, path }: JsonSchemaReducerParams) {
77+
const resolvedNode = node.resolveRef({ pointer, path });
78+
if (resolvedNode.schemaId === node.schemaId) {
79+
return resolvedNode;
80+
}
81+
const merged = mergeNode(node, resolvedNode);
82+
const { node: reducedNode, error } = merged.reduceSchema(data, { key, pointer, path });
83+
return reducedNode ?? error;
84+
}
85+
7286
export function resolveRef({ pointer, path }: { pointer?: string; path?: ValidationPath } = {}) {
7387
const node = this as SchemaNode;
88+
7489
if (node.schema.$dynamicRef) {
7590
const nextNode = resolveRecursiveRef(node, path);
76-
// console.log("resolved node", node.schema.$dynamicRef, "=>", nextNode != null);
7791
path?.push({ pointer, node: nextNode });
7892
return nextNode;
7993
}
@@ -85,8 +99,6 @@ export function resolveRef({ pointer, path }: { pointer?: string; path?: Validat
8599
const resolvedNode = getRef(node);
86100
if (resolvedNode != null) {
87101
path?.push({ pointer, node: resolvedNode });
88-
} else {
89-
// console.log("failed resolving", node.$ref, "from", Object.keys(node.context.refs));
90102
}
91103
return resolvedNode;
92104
}
@@ -103,9 +115,6 @@ function validateRef({ node, data, pointer = "#", path }: JsonSchemaValidatorPar
103115
function resolveRecursiveRef(node: SchemaNode, path: ValidationPath): SchemaNode {
104116
const history = path;
105117
const refInCurrentScope = joinId(node.$id, node.schema.$dynamicRef);
106-
// console.log("resolve $dynamicRef:", node.schema.$dynamicRef);
107-
// console.log(" -> scope:", joinId(node.$id, node.schema.$dynamicRef));
108-
// console.log("dynamicAnchors", Object.keys(node.context.dynamicAnchors));
109118

110119
// A $dynamicRef with a non-matching $dynamicAnchor in the same schema resource behaves like a normal $ref to $anchor
111120
const nonMatchingDynamicAnchor = node.context.dynamicAnchors[refInCurrentScope] == null;
@@ -131,7 +140,7 @@ function resolveRecursiveRef(node: SchemaNode, path: ValidationPath): SchemaNode
131140
}
132141

133142
// A $dynamicRef without a matching $dynamicAnchor in the same schema resource behaves like a normal $ref to $anchor
134-
// console.log(" -> resolve as ref");
143+
135144
const nextNode = getRef(node, refInCurrentScope);
136145
return nextNode;
137146
}

src/keywords/allOf.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ export function parseAllOf(node: SchemaNode) {
2222
}
2323
}
2424

25-
function reduceAllOf({ node, data }: JsonSchemaReducerParams) {
25+
function reduceAllOf({ node, data, key, pointer, path }: JsonSchemaReducerParams) {
2626
// note: parts of schemas could be merged, e.g. if they do not include
2727
// dynamic schema parts
2828
let mergedSchema = {};
2929
let dynamicId = "";
3030
for (let i = 0; i < node.allOf.length; i += 1) {
31-
const { node: schemaNode } = node.allOf[i].reduceSchema(data);
31+
const { node: schemaNode } = node.allOf[i].reduceSchema(data, { key, pointer, path });
3232
if (schemaNode) {
3333
const nestedDynamicId = schemaNode.dynamicId?.replace(node.dynamicId, "") ?? "";
3434
const localDynamicId = nestedDynamicId === "" ? `allOf/${i}` : nestedDynamicId;

0 commit comments

Comments
 (0)