Skip to content

Commit 138d69d

Browse files
committed
core: Remove AJV usage from combinator mappers
- Adapt algorithm to determine the fitting schema index for combinators to no longer use AJV - New heuristic uses identifying properties that should match a const value in the schema - Adapt MaterialOneOfRenderer.test.tsx to fit new heuristic - Describe changes and add examples to migration guide - Adapt some of the anyOf and oneOf examples to custom and new identification properties #2371
1 parent daa1836 commit 138d69d

File tree

9 files changed

+497
-114
lines changed

9 files changed

+497
-114
lines changed

MIGRATION.md

+112
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,117 @@
11
# Migration guide
22

3+
## Migrating to JSON Forms 3.6
4+
5+
### Combinator (anyOf & oneOf) index selection now uses a heuristic instead of AJV
6+
7+
In this update, we have eliminated the direct usage of AJV to determine the selected subschema for combinator renderers.
8+
To achieve this, the algorithm in `getCombinatorIndexOfFittingSchema` and with this `mapStateToCombinatorRendererProps` was changed.
9+
Thus, custom renderers using either method might have behavior changes.
10+
This rework is part of an ongoing effort to remove mandatory usage of AJV from JSON Forms.
11+
12+
Before this change, AJV was used to validate the current data against all schemas of the combinator.
13+
This was replaced by using a heuristic which tries to match the schema via an identification property
14+
against a `const` entry in the schema.
15+
16+
The identification property is determined as follows in descending order of priority:
17+
18+
1. The schema contains a new custom property `x-jsf-type-property` next to the combinator to define the identification property.
19+
2. The data has any of these properties: `type`, `kind`, `id`. They are considered in the listed order.
20+
3. The data has any string or number property. The first encountered one is used.
21+
22+
If no combinator schema can be matched, fallback to the first one as before this update.
23+
24+
Note that this approach can not determine a subschema for non-object subschemas (e.g. ones only defining a primitive property).
25+
Furthermore, subschemas can no longer automatically be selected based on validation results like
26+
produced by different required properties between subschemas.
27+
28+
#### Example 1: Custom identification property
29+
30+
Use custom property `x-jsf-type-property` to define which property's content identifies the subschema to select.
31+
In this case, `mytype` is defined as the property to use. The two subschemas in the `anyOf` each define a `const` value for this property.
32+
Meaning a data object with property `mytype: 'user'` results in the second subschema being selected.
33+
The `default` keyword can be used to tell JSON Forms to automatically initialize the property.
34+
35+
```ts
36+
const schema = {
37+
$schema: 'http://json-schema.org/draft-07/schema#',
38+
type: 'object',
39+
properties: {
40+
addressOrUser: {
41+
'x-jsf-type-property': 'mytype',
42+
anyOf: [
43+
{
44+
type: 'object',
45+
properties: {
46+
mytype: { const: 'address', default: 'address' },
47+
street_address: { type: 'string' },
48+
city: { type: 'string' },
49+
state: { type: 'string' },
50+
},
51+
},
52+
{
53+
type: 'object',
54+
properties: {
55+
mytype: { const: 'user', default: 'user' },
56+
name: { type: 'string' },
57+
},
58+
},
59+
],
60+
},
61+
},
62+
};
63+
64+
// Data that results in the second subschema being selected
65+
const dataWithUser = {
66+
addressOrUser: {
67+
mytype: 'user',
68+
name: 'Peter',
69+
},
70+
};
71+
```
72+
73+
#### Example 2: Use a default identification property
74+
75+
In this example we use the `kind` property as the identification property. Like in the custom property case, subschemas are matched via a `const` definition in the identification property's schema. However, we do not need to explicitly specify `kind` being used.
76+
The `default` keyword can be used to tell JSON Forms to automatically initialize the property.
77+
78+
```ts
79+
const schema = {
80+
$schema: 'http://json-schema.org/draft-07/schema#',
81+
type: 'object',
82+
properties: {
83+
addressOrUser: {
84+
anyOf: [
85+
{
86+
type: 'object',
87+
properties: {
88+
kind: { const: 'address', default: 'address' },
89+
street_address: { type: 'string' },
90+
city: { type: 'string' },
91+
state: { type: 'string' },
92+
},
93+
},
94+
{
95+
type: 'object',
96+
properties: {
97+
kind: { const: 'user', default: 'user' },
98+
name: { type: 'string' },
99+
},
100+
},
101+
],
102+
},
103+
},
104+
};
105+
106+
// Data that results in the second subschema being selected
107+
const dataWithUser = {
108+
addressOrUser: {
109+
kind: 'user',
110+
name: 'Peter',
111+
},
112+
};
113+
```
114+
3115
## Migrating to JSON Forms 3.5
4116

5117
### Angular support now targets Angular 18 and Angular 19

packages/core/src/mappers/combinators.ts

+104
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ export interface CombinatorSubSchemaRenderInfo {
3636

3737
export type CombinatorKeyword = 'anyOf' | 'oneOf' | 'allOf';
3838

39+
/** Custom schema keyword to define the property identifying different combinator schemas. */
40+
export const COMBINATOR_TYPE_PROPERTY = 'x-jsf-type-property';
41+
42+
/** Default properties that are used to identify combinator schemas. */
43+
export const COMBINATOR_IDENTIFICATION_PROPERTIES = ['type', 'kind', 'id'];
44+
3945
export const createCombinatorRenderInfos = (
4046
combinatorSubSchemas: JsonSchema[],
4147
rootSchema: JsonSchema,
@@ -67,3 +73,101 @@ export const createCombinatorRenderInfos = (
6773
`${keyword}-${subSchemaIndex}`,
6874
};
6975
});
76+
77+
/**
78+
* Returns the identification property of the given data object.
79+
* The following heuristics are applied:
80+
* If the schema defines a `x-jsf-type-property`, it is used as the identification property.
81+
* Otherwise, the first of the following data properties is used:
82+
* - `type`
83+
* - `kind`
84+
* - `id`
85+
*
86+
* If none of the above properties are present, the first string or number property of the data object is used.
87+
*/
88+
export const getCombinatorIdentificationProp = (
89+
data: any,
90+
schema: JsonSchema
91+
): string | undefined => {
92+
if (typeof data !== 'object' || data === null) {
93+
return undefined;
94+
}
95+
96+
// Determine the identification property
97+
let idProperty: string | undefined;
98+
if (
99+
COMBINATOR_TYPE_PROPERTY in schema &&
100+
typeof schema[COMBINATOR_TYPE_PROPERTY] === 'string'
101+
) {
102+
idProperty = schema[COMBINATOR_TYPE_PROPERTY];
103+
} else {
104+
// Use the first default identification property that is present in the data object
105+
for (const prop of COMBINATOR_IDENTIFICATION_PROPERTIES) {
106+
if (Object.prototype.hasOwnProperty.call(data, prop)) {
107+
idProperty = prop;
108+
break;
109+
}
110+
}
111+
}
112+
113+
// If no identification property was found, use the first string or number property
114+
// of the data object
115+
if (idProperty === undefined) {
116+
for (const key of Object.keys(data)) {
117+
if (typeof data[key] === 'string' || typeof data[key] === 'number') {
118+
idProperty = key;
119+
break;
120+
}
121+
}
122+
}
123+
124+
return idProperty;
125+
};
126+
127+
/**
128+
* Returns the index of the schema in the given combinator keyword that matches the identification property of the given data object.
129+
* The heuristic only works for data objects with a corresponding schema. If the data is a primitive value or an array, the heuristic does not work.
130+
*
131+
* If the index cannot be determined, `-1` is returned.
132+
*
133+
* @returns the index of the fitting schema or `-1` if no fitting schema was found
134+
*/
135+
export const getCombinatorIndexOfFittingSchema = (
136+
data: any,
137+
keyword: CombinatorKeyword,
138+
schema: JsonSchema,
139+
rootSchema: JsonSchema
140+
): number => {
141+
let indexOfFittingSchema = -1;
142+
const idProperty = getCombinatorIdentificationProp(data, schema);
143+
if (idProperty === undefined) {
144+
return indexOfFittingSchema;
145+
}
146+
147+
for (let i = 0; i < schema[keyword]?.length; i++) {
148+
let resolvedSchema = schema[keyword][i];
149+
if (resolvedSchema.$ref) {
150+
resolvedSchema = Resolve.schema(
151+
rootSchema,
152+
resolvedSchema.$ref,
153+
rootSchema
154+
);
155+
}
156+
157+
// Match the identification property against a constant value in resolvedSchema
158+
const maybeConstIdValue = resolvedSchema.properties?.[idProperty]?.const;
159+
160+
if (
161+
maybeConstIdValue !== undefined &&
162+
data[idProperty] === maybeConstIdValue
163+
) {
164+
indexOfFittingSchema = i;
165+
console.debug(
166+
`Data matches the resolved schema for property ${idProperty}`
167+
);
168+
break;
169+
}
170+
}
171+
172+
return indexOfFittingSchema;
173+
};

packages/core/src/mappers/renderer.ts

+13-39
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ import {
8484
getUiSchema,
8585
} from '../store';
8686
import { isInherentlyEnabled } from './util';
87-
import { CombinatorKeyword } from './combinators';
87+
import {
88+
CombinatorKeyword,
89+
getCombinatorIndexOfFittingSchema,
90+
} from './combinators';
8891
import isEqual from 'lodash/isEqual';
8992

9093
const move = (array: any[], index: number, delta: number) => {
@@ -1128,43 +1131,12 @@ export const mapStateToCombinatorRendererProps = (
11281131
const { data, schema, rootSchema, i18nKeyPrefix, label, ...props } =
11291132
mapStateToControlProps(state, ownProps);
11301133

1131-
const ajv = state.jsonforms.core.ajv;
1132-
const structuralKeywords = [
1133-
'required',
1134-
'additionalProperties',
1135-
'type',
1136-
'enum',
1137-
'const',
1138-
];
1139-
const dataIsValid = (errors: ErrorObject[]): boolean => {
1140-
return (
1141-
!errors ||
1142-
errors.length === 0 ||
1143-
!errors.find((e) => structuralKeywords.indexOf(e.keyword) !== -1)
1144-
);
1145-
};
1146-
let indexOfFittingSchema: number;
1147-
// TODO instead of compiling the combinator subschemas we can compile the original schema
1148-
// without the combinator alternatives and then revalidate and check the errors for the
1149-
// element
1150-
for (let i = 0; i < schema[keyword]?.length; i++) {
1151-
try {
1152-
let _schema = schema[keyword][i];
1153-
if (_schema.$ref) {
1154-
_schema = Resolve.schema(rootSchema, _schema.$ref, rootSchema);
1155-
}
1156-
const valFn = ajv.compile(_schema);
1157-
valFn(data);
1158-
if (dataIsValid(valFn.errors)) {
1159-
indexOfFittingSchema = i;
1160-
break;
1161-
}
1162-
} catch (error) {
1163-
console.debug(
1164-
"Combinator subschema is not self contained, can't hand it over to AJV"
1165-
);
1166-
}
1167-
}
1134+
const indexOfFittingSchema = getCombinatorIndexOfFittingSchema(
1135+
data,
1136+
keyword,
1137+
schema,
1138+
rootSchema
1139+
);
11681140

11691141
return {
11701142
data,
@@ -1173,7 +1145,9 @@ export const mapStateToCombinatorRendererProps = (
11731145
...props,
11741146
i18nKeyPrefix,
11751147
label,
1176-
indexOfFittingSchema,
1148+
// Fall back to the first schema if none fits
1149+
indexOfFittingSchema:
1150+
indexOfFittingSchema !== -1 ? indexOfFittingSchema : 0,
11771151
uischemas: getUISchemas(state),
11781152
};
11791153
};

0 commit comments

Comments
 (0)