Skip to content

Commit

Permalink
WIP 2371 combinator schema index heuristic
Browse files Browse the repository at this point in the history
- 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 an example to migration guide
- Adapt some of the anyOf and oneOf examples to custom and new identifaction properties

TODO:
- explore allOf usage of AJV
- consider initializing const values on tab switch
  • Loading branch information
lucas-koehler committed Jan 28, 2025
1 parent 6af8825 commit a611efa
Show file tree
Hide file tree
Showing 9 changed files with 495 additions and 114 deletions.
110 changes: 110 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,115 @@
# 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.

```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: { type: 'string', const: 'address' },
street_address: { type: 'string' },
city: { type: 'string' },
state: { type: 'string' },
},
},
{
type: 'object',
properties: {
mytype: { type: 'string', const: '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.

```ts
const schema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
addressOrUser: {
anyOf: [
{
type: 'object',
properties: {
kind: { type: 'string', const: 'address' },
street_address: { type: 'string' },
city: { type: 'string' },
state: { type: 'string' },
},
},
{
type: 'object',
properties: {
kind: { type: 'string', const: '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;
};
52 changes: 13 additions & 39 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 @@ -1128,43 +1131,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,7 +1145,9 @@ export const mapStateToCombinatorRendererProps = (
...props,
i18nKeyPrefix,
label,
indexOfFittingSchema,
// Fall back to the first schema if none fits
indexOfFittingSchema:
indexOfFittingSchema !== -1 ? indexOfFittingSchema : 0,
uischemas: getUISchemas(state),
};
};
Expand Down
Loading

0 comments on commit a611efa

Please sign in to comment.