Skip to content
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

core: Remove AJV usage from combinator mappers #2413

Open
wants to merge 2 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
112 changes: 112 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,117 @@
# Migration guide

## 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 using 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. The data has any of these properties: `type`, `kind`, `id`. They are considered in the listed order.
3. The data has any string or number property. The first encountered one is used.

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',
},
};
```

## Migrating to JSON Forms 3.5

### Angular support now targets Angular 18 and Angular 19
Expand Down
104 changes: 104 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', 'id'];

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

/**
* Returns the identification property of the given data object.
* 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 data properties is used:
* - `type`
* - `kind`
* - `id`
*
* If none of the above properties are present, the first string or number property of the data object is used.
*/
export const getCombinatorIdentificationProp = (
data: any,
schema: JsonSchema
): string | undefined => {
if (typeof data !== 'object' || data === null) {
return undefined;
}

// 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 is present in the data object
for (const prop of COMBINATOR_IDENTIFICATION_PROPERTIES) {
if (Object.prototype.hasOwnProperty.call(data, prop)) {
idProperty = prop;
break;
}
}
}

// If no identification property was found, use the first string or number property
// of the data object
if (idProperty === undefined) {
for (const key of Object.keys(data)) {
if (typeof data[key] === 'string' || typeof data[key] === 'number') {
idProperty = key;
break;
}
}
}

return idProperty;
};

/**
* 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.
*
* 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 => {
let indexOfFittingSchema = -1;
const idProperty = getCombinatorIdentificationProp(data, schema);
if (idProperty === undefined) {
return indexOfFittingSchema;
}

for (let i = 0; i < schema[keyword]?.length; i++) {
let resolvedSchema = schema[keyword][i];
if (resolvedSchema.$ref) {
resolvedSchema = Resolve.schema(
rootSchema,
resolvedSchema.$ref,
rootSchema
);
}

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

if (
maybeConstIdValue !== undefined &&
data[idProperty] === maybeConstIdValue
) {
indexOfFittingSchema = i;
console.debug(
`Data matches the resolved schema for property ${idProperty}`
);
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