Skip to content

core: Remove AJV usage from combinator mappers #2413

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,116 @@

## Migrating to JSON Forms 3.6

### Combinator (anyOf & oneOf) index selection now uses a heuristic instead of AJV

In this update, we have eliminated the direct usage of AJV to determine the selected subschema for combinator renderers.
To achieve this, the algorithm in `getCombinatorIndexOfFittingSchema` and with this `mapStateToCombinatorRendererProps` was changed.
Thus, custom renderers using either method might have behavior changes.
This rework is part of an ongoing effort to remove mandatory usage of AJV from JSON Forms.

Before this change, AJV was used to validate the current data against all schemas of the combinator.
This was replaced by a heuristic which tries to match the schema via an identification property against a `const` entry in the schema.

The identification property is determined as follows in descending order of priority:

1. The schema contains a new custom property `x-jsf-type-property` next to the combinator to define the identification property.
2. At least one of the combinator schemas has this property with a const declaration: `type`, `kind`. They are considered in the listed order.

If no combinator schema can be matched, fallback to the first one as before this update.

Note that this approach can not determine a subschema for non-object subschemas (e.g. ones only defining a primitive property).
Furthermore, subschemas can no longer automatically be selected based on validation results like
produced by different required properties between subschemas.

#### Example 1: Custom identification property

Use custom property `x-jsf-type-property` to define which property's content identifies the subschema to select.
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.
Meaning a data object with property `mytype: 'user'` results in the second subschema being selected.
The `default` keyword can be used to tell JSON Forms to automatically initialize the property.

```ts
const schema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
addressOrUser: {
'x-jsf-type-property': 'mytype',
anyOf: [
{
type: 'object',
properties: {
mytype: { const: 'address', default: 'address' },
street_address: { type: 'string' },
city: { type: 'string' },
state: { type: 'string' },
},
},
{
type: 'object',
properties: {
mytype: { const: 'user', default: 'user' },
name: { type: 'string' },
},
},
],
},
},
};

// Data that results in the second subschema being selected
const dataWithUser = {
addressOrUser: {
mytype: 'user',
name: 'Peter',
},
};
```

#### Example 2: Use a default identification property

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.
The `default` keyword can be used to tell JSON Forms to automatically initialize the property.

```ts
const schema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
addressOrUser: {
anyOf: [
{
type: 'object',
properties: {
kind: { const: 'address', default: 'address' },
street_address: { type: 'string' },
city: { type: 'string' },
state: { type: 'string' },
},
},
{
type: 'object',
properties: {
kind: { const: 'user', default: 'user' },
name: { type: 'string' },
},
},
],
},
},
};

// Data that results in the second subschema being selected
const dataWithUser = {
addressOrUser: {
kind: 'user',
name: 'Peter',
},
};
```

### UI schema type changes

The `UISchemaElement` type was renamed to `BaseUISchemaElement` and a new `UISchemaElement` type was introduced, which is a union of all available UI schema types.
Expand Down
87 changes: 87 additions & 0 deletions packages/core/src/mappers/combinators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export interface CombinatorSubSchemaRenderInfo {

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

/** Custom schema keyword to define the property identifying different combinator schemas. */
export const COMBINATOR_TYPE_PROPERTY = 'x-jsf-type-property';

/** Default properties that are used to identify combinator schemas. */
export const COMBINATOR_IDENTIFICATION_PROPERTIES = ['type', 'kind'];

export const createCombinatorRenderInfos = (
combinatorSubSchemas: JsonSchema[],
rootSchema: JsonSchema,
Expand Down Expand Up @@ -67,3 +73,84 @@ export const createCombinatorRenderInfos = (
`${keyword}-${subSchemaIndex}`,
};
});

/**
* Returns the index of the schema in the given combinator keyword that matches the identification property of the given data object.
* 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.
*
* The following heuristics are applied:
* If the schema defines a `x-jsf-type-property`, it is used as the identification property.
* Otherwise, the first of the following properties is used if it exists in at least one combinator schema and has a `const` entry:
* - `type`
* - `kind`
*
* If the index cannot be determined, `-1` is returned.
*
* @returns the index of the fitting schema or `-1` if no fitting schema was found
*/
export const getCombinatorIndexOfFittingSchema = (
data: any,
keyword: CombinatorKeyword,
schema: JsonSchema,
rootSchema: JsonSchema
): number => {
if (typeof data !== 'object' || data === null) {
return -1;
}

// Resolve all schemas in the combinator.
const resolvedCombinatorSchemas = [];
for (let i = 0; i < schema[keyword]?.length; i++) {
let resolvedSchema = schema[keyword][i];
if (resolvedSchema.$ref) {
resolvedSchema = Resolve.schema(
rootSchema,
resolvedSchema.$ref,
rootSchema
);
}
resolvedCombinatorSchemas.push(resolvedSchema);
}

// Determine the identification property
let idProperty: string | undefined;
if (
COMBINATOR_TYPE_PROPERTY in schema &&
typeof schema[COMBINATOR_TYPE_PROPERTY] === 'string'
) {
idProperty = schema[COMBINATOR_TYPE_PROPERTY];
} else {
// Use the first default identification property that has a const entry in at least one of the schemas
for (const potentialIdProp of COMBINATOR_IDENTIFICATION_PROPERTIES) {
for (const resolvedSchema of resolvedCombinatorSchemas) {
if (resolvedSchema.properties?.[potentialIdProp]?.const !== undefined) {
idProperty = potentialIdProp;
break;
}
}
}
}

let indexOfFittingSchema = -1;
if (idProperty === undefined) {
return indexOfFittingSchema;
}

// Check if the data matches the identification property of one of the resolved schemas
for (let i = 0; i < resolvedCombinatorSchemas.length; i++) {
const resolvedSchema = resolvedCombinatorSchemas[i];

// Match the identification property against a constant value in resolvedSchema
const maybeConstIdValue = resolvedSchema.properties?.[idProperty]?.const;

if (
maybeConstIdValue !== undefined &&
data[idProperty] === maybeConstIdValue
) {
indexOfFittingSchema = i;
break;
}
}

return indexOfFittingSchema;
};
79 changes: 38 additions & 41 deletions packages/core/src/mappers/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ import {
getUiSchema,
} from '../store';
import { isInherentlyEnabled } from './util';
import { CombinatorKeyword } from './combinators';
import {
CombinatorKeyword,
getCombinatorIndexOfFittingSchema,
} from './combinators';
import isEqual from 'lodash/isEqual';

const move = (array: any[], index: number, delta: number) => {
Expand Down Expand Up @@ -1120,6 +1123,11 @@ export interface StatePropsOfCombinator extends StatePropsOfControl {
data: any;
}

export type StatePropsOfAllOfRenderer = Omit<
StatePropsOfCombinator,
'indexOfFittingSchema'
>;

export const mapStateToCombinatorRendererProps = (
state: JsonFormsState,
ownProps: OwnPropsOfControl,
Expand All @@ -1128,43 +1136,12 @@ export const mapStateToCombinatorRendererProps = (
const { data, schema, rootSchema, i18nKeyPrefix, label, ...props } =
mapStateToControlProps(state, ownProps);

const ajv = state.jsonforms.core.ajv;
const structuralKeywords = [
'required',
'additionalProperties',
'type',
'enum',
'const',
];
const dataIsValid = (errors: ErrorObject[]): boolean => {
return (
!errors ||
errors.length === 0 ||
!errors.find((e) => structuralKeywords.indexOf(e.keyword) !== -1)
);
};
let indexOfFittingSchema: number;
// TODO instead of compiling the combinator subschemas we can compile the original schema
// without the combinator alternatives and then revalidate and check the errors for the
// element
for (let i = 0; i < schema[keyword]?.length; i++) {
try {
let _schema = schema[keyword][i];
if (_schema.$ref) {
_schema = Resolve.schema(rootSchema, _schema.$ref, rootSchema);
}
const valFn = ajv.compile(_schema);
valFn(data);
if (dataIsValid(valFn.errors)) {
indexOfFittingSchema = i;
break;
}
} catch (error) {
console.debug(
"Combinator subschema is not self contained, can't hand it over to AJV"
);
}
}
const indexOfFittingSchema = getCombinatorIndexOfFittingSchema(
data,
keyword,
schema,
rootSchema
);

return {
data,
Expand All @@ -1173,14 +1150,22 @@ export const mapStateToCombinatorRendererProps = (
...props,
i18nKeyPrefix,
label,
indexOfFittingSchema,
// Fall back to the first schema if none fits
indexOfFittingSchema:
indexOfFittingSchema !== -1 ? indexOfFittingSchema : 0,
uischemas: getUISchemas(state),
};
};

export interface CombinatorRendererProps
extends StatePropsOfCombinator,
DispatchPropsOfControl {}

export type AllOfRendererProps = Omit<
CombinatorRendererProps,
'indexOfFittingSchema'
>;

/**
* Map state to all of renderer props.
* @param state the store's state
Expand All @@ -1190,8 +1175,20 @@ export interface CombinatorRendererProps
export const mapStateToAllOfProps = (
state: JsonFormsState,
ownProps: OwnPropsOfControl
): StatePropsOfCombinator =>
mapStateToCombinatorRendererProps(state, ownProps, 'allOf');
): StatePropsOfAllOfRenderer => {
const { data, schema, rootSchema, i18nKeyPrefix, label, ...props } =
mapStateToControlProps(state, ownProps);

return {
data,
schema,
rootSchema,
...props,
i18nKeyPrefix,
label,
uischemas: getUISchemas(state),
};
};

export const mapStateToAnyOfProps = (
state: JsonFormsState,
Expand Down
Loading