diff --git a/CHANGELOG.md b/CHANGELOG.md index f08a288aee..1597d24fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ should change the heading of the (upcoming) version to include a major version b --> +# 5.21.3 + +## @rjsf/utils + +- Added `experimental_customMergeAllOf` option to `retrieveSchema` to allow custom merging of `allOf` schemas + # 5.21.2 ## @rjsf/core diff --git a/packages/core/src/components/Form.tsx b/packages/core/src/components/Form.tsx index 014ebafe3d..d9ea45529d 100644 --- a/packages/core/src/components/Form.tsx +++ b/packages/core/src/components/Form.tsx @@ -33,6 +33,7 @@ import { validationDataMerge, ValidatorType, Experimental_DefaultFormStateBehavior, + Experimental_CustomMergeAllOf, } from '@rjsf/utils'; import _forEach from 'lodash/forEach'; import _get from 'lodash/get'; @@ -196,6 +197,9 @@ export interface FormProps; // Private /** * _internalFormWrapper is currently used by the semantic-ui theme to provide a custom wrapper around `
` @@ -390,12 +394,26 @@ export default class Form< 'experimental_defaultFormStateBehavior' in props ? props.experimental_defaultFormStateBehavior : this.props.experimental_defaultFormStateBehavior; + const experimental_customMergeAllOf = + 'experimental_customMergeAllOf' in props + ? props.experimental_customMergeAllOf + : this.props.experimental_customMergeAllOf; let schemaUtils: SchemaUtilsType = state.schemaUtils; if ( !schemaUtils || - schemaUtils.doesSchemaUtilsDiffer(props.validator, rootSchema, experimental_defaultFormStateBehavior) + schemaUtils.doesSchemaUtilsDiffer( + props.validator, + rootSchema, + experimental_defaultFormStateBehavior, + experimental_customMergeAllOf + ) ) { - schemaUtils = createSchemaUtils(props.validator, rootSchema, experimental_defaultFormStateBehavior); + schemaUtils = createSchemaUtils( + props.validator, + rootSchema, + experimental_defaultFormStateBehavior, + experimental_customMergeAllOf + ); } const formData: T = schemaUtils.getDefaultFormState(schema, inputFormData) as T; const _retrievedSchema = retrievedSchema ?? schemaUtils.retrieveSchema(schema, formData); diff --git a/packages/docs/docs/api-reference/form-props.md b/packages/docs/docs/api-reference/form-props.md index 2f431f406a..a94910d78e 100644 --- a/packages/docs/docs/api-reference/form-props.md +++ b/packages/docs/docs/api-reference/form-props.md @@ -91,8 +91,8 @@ The signature and documentation for this property is as follow: ##### computeSkipPopulate () A function that determines whether to skip populating the array with default values based on the provided validator, schema, and root schema. - If the function returns `true`, the array will not be populated with default values. - If the function returns `false`, the array will be populated with default values according to the `populate` option. +If the function returns `true`, the array will not be populated with default values. +If the function returns `false`, the array will be populated with default values according to the `populate` option. ###### Parameters @@ -104,7 +104,6 @@ A function that determines whether to skip populating the array with default val - boolean: A boolean indicating whether to skip populating the array with default values. - ##### Example ```tsx @@ -252,6 +251,30 @@ render( ); ``` +## experimental_customMergeAllOf + +The `experimental_customMergeAllOf` function allows you to provide a custom implementation for merging `allOf` schemas. This can be particularly useful in scenarios where the default [json-schema-merge-allof](https://github.com/mokkabonna/json-schema-merge-allof) library becomes a performance bottleneck, especially with large and complex schemas or doesn't satisfy your needs. + +By providing your own implementation, you can potentially achieve significant performance improvements. For instance, if your use case only requires a subset of JSON Schema features, you can implement a faster, more tailored merging strategy. + +If you're looking for alternative `allOf` merging implementations, you might consider [allof-merge](https://github.com/udamir/allof-merge). + +**Warning:** This is an experimental feature. Only use this if you fully understand the implications of custom `allOf` merging and are prepared to handle potential edge cases. Incorrect implementations may lead to unexpected behavior or validation errors. + +```tsx +import { Form } from '@rjsf/core'; +import validator from '@rjsf/validator-ajv8'; + +const customMergeAllOf = (schema: RJSFSchema): RJSFSchema => { + // Your custom implementation here +}; + +render( + , + document.getElementById('app') +); +``` + ## disabled It's possible to disable the whole form by setting the `disabled` prop. The `disabled` prop is then forwarded down to each field of the form. diff --git a/packages/utils/src/createSchemaUtils.ts b/packages/utils/src/createSchemaUtils.ts index 39e743d945..cfd6883e39 100644 --- a/packages/utils/src/createSchemaUtils.ts +++ b/packages/utils/src/createSchemaUtils.ts @@ -1,6 +1,7 @@ import deepEquals from './deepEquals'; import { ErrorSchema, + Experimental_CustomMergeAllOf, Experimental_DefaultFormStateBehavior, FormContextType, GlobalUISchemaOptions, @@ -30,9 +31,10 @@ import { } from './schema'; /** The `SchemaUtils` class provides a wrapper around the publicly exported APIs in the `utils/schema` directory such - * that one does not have to explicitly pass the `validator`, `rootSchema`, or `experimental_defaultFormStateBehavior` to each method. - * Since these generally do not change across a `Form`, this allows for providing a simplified set of APIs to the - * `@rjsf/core` components and the various themes as well. This class implements the `SchemaUtilsType` interface. + * that one does not have to explicitly pass the `validator`, `rootSchema`, `experimental_defaultFormStateBehavior` or + * `experimental_customMergeAllOf` to each method. Since these generally do not change across a `Form`, this allows for + * providing a simplified set of APIs to the `@rjsf/core` components and the various themes as well. This class + * implements the `SchemaUtilsType` interface. */ class SchemaUtils implements SchemaUtilsType @@ -40,21 +42,25 @@ class SchemaUtils; experimental_defaultFormStateBehavior: Experimental_DefaultFormStateBehavior; + experimental_customMergeAllOf?: Experimental_CustomMergeAllOf; /** Constructs the `SchemaUtils` instance with the given `validator` and `rootSchema` stored as instance variables * * @param validator - An implementation of the `ValidatorType` interface that will be forwarded to all the APIs * @param rootSchema - The root schema that will be forwarded to all the APIs * @param experimental_defaultFormStateBehavior - Configuration flags to allow users to override default form state behavior + * @param [experimental_customMergeAllOf] - Optional function that allows for custom merging of `allOf` schemas */ constructor( validator: ValidatorType, rootSchema: S, - experimental_defaultFormStateBehavior: Experimental_DefaultFormStateBehavior + experimental_defaultFormStateBehavior: Experimental_DefaultFormStateBehavior, + experimental_customMergeAllOf?: Experimental_CustomMergeAllOf ) { this.rootSchema = rootSchema; this.validator = validator; this.experimental_defaultFormStateBehavior = experimental_defaultFormStateBehavior; + this.experimental_customMergeAllOf = experimental_customMergeAllOf; } /** Returns the `ValidatorType` in the `SchemaUtilsType` @@ -72,12 +78,14 @@ class SchemaUtils, rootSchema: S, - experimental_defaultFormStateBehavior = {} + experimental_defaultFormStateBehavior = {}, + experimental_customMergeAllOf?: Experimental_CustomMergeAllOf ): boolean { if (!validator || !rootSchema) { return false; @@ -85,7 +93,8 @@ class SchemaUtils(this.validator, schema, this.rootSchema, rawFormData); + return retrieveSchema( + this.validator, + schema, + this.rootSchema, + rawFormData, + this.experimental_customMergeAllOf + ); } /** Sanitize the `data` associated with the `oldSchema` so it is considered appropriate for the `newSchema`. If the @@ -262,7 +278,16 @@ class SchemaUtils { - return toIdSchema(this.validator, schema, id, this.rootSchema, formData, idPrefix, idSeparator); + return toIdSchema( + this.validator, + schema, + id, + this.rootSchema, + formData, + idPrefix, + idSeparator, + this.experimental_customMergeAllOf + ); } /** Generates an `PathSchema` object for the `schema`, recursively @@ -283,6 +308,7 @@ class SchemaUtils( validator: ValidatorType, rootSchema: S, - experimental_defaultFormStateBehavior = {} + experimental_defaultFormStateBehavior = {}, + experimental_customMergeAllOf?: Experimental_CustomMergeAllOf ): SchemaUtilsType { - return new SchemaUtils(validator, rootSchema, experimental_defaultFormStateBehavior); + return new SchemaUtils( + validator, + rootSchema, + experimental_defaultFormStateBehavior, + experimental_customMergeAllOf + ); } diff --git a/packages/utils/src/schema/getDefaultFormState.ts b/packages/utils/src/schema/getDefaultFormState.ts index b1f5a096c8..2cbbde4b4a 100644 --- a/packages/utils/src/schema/getDefaultFormState.ts +++ b/packages/utils/src/schema/getDefaultFormState.ts @@ -20,6 +20,7 @@ import mergeDefaultsWithFormData from '../mergeDefaultsWithFormData'; import mergeObjects from '../mergeObjects'; import mergeSchemas from '../mergeSchemas'; import { + Experimental_CustomMergeAllOf, Experimental_DefaultFormStateBehavior, FormContextType, GenericObjectType, @@ -156,6 +157,8 @@ interface ComputeDefaultsProps _recurseList?: string[]; /** Optional configuration object, if provided, allows users to override default form state behavior */ experimental_defaultFormStateBehavior?: Experimental_DefaultFormStateBehavior; + /** Optional function that allows for custom merging of `allOf` schemas */ + experimental_customMergeAllOf?: Experimental_CustomMergeAllOf; /** Optional flag, if true, indicates this schema was required in the parent schema. */ required?: boolean; } @@ -180,6 +183,7 @@ export function computeDefaults(validator, schema, rootSchema, false, [], defaultFormData); + const resolvedSchema = resolveDependencies( + validator, + schema, + rootSchema, + false, + [], + defaultFormData, + experimental_customMergeAllOf + ); schemaToCompute = resolvedSchema[0]; // pick the first element from resolve dependencies } else if (isFixedItems(schema)) { defaults = (schema.items! as S[]).map((itemSchema: S, idx: number) => @@ -298,6 +310,7 @@ export function getObjectDefaults = {}, defaults?: T | T[] | undefined @@ -309,7 +322,7 @@ export function getObjectDefaults(validator, schema, rootSchema, formData) + ? retrieveSchema(validator, schema, rootSchema, formData, experimental_customMergeAllOf) : schema; const objectDefaults = Object.keys(retrievedSchema.properties || {}).reduce( (acc: GenericObjectType, key: string) => { @@ -319,6 +332,7 @@ export function getObjectDefaults ) { if (!isObject(theSchema)) { throw new Error('Invalid schema: ' + theSchema); } - const schema = retrieveSchema(validator, theSchema, rootSchema, formData); + const schema = retrieveSchema(validator, theSchema, rootSchema, formData, experimental_customMergeAllOf); const defaults = computeDefaults(validator, schema, { rootSchema, includeUndefinedValues, experimental_defaultFormStateBehavior, + experimental_customMergeAllOf, rawFormData: formData, }); if (formData === undefined || formData === null || (typeof formData === 'number' && isNaN(formData))) { diff --git a/packages/utils/src/schema/retrieveSchema.ts b/packages/utils/src/schema/retrieveSchema.ts index 2c9225f628..af837512ac 100644 --- a/packages/utils/src/schema/retrieveSchema.ts +++ b/packages/utils/src/schema/retrieveSchema.ts @@ -25,7 +25,14 @@ import getDiscriminatorFieldFromSchema from '../getDiscriminatorFieldFromSchema' import guessType from '../guessType'; import isObject from '../isObject'; import mergeSchemas from '../mergeSchemas'; -import { FormContextType, GenericObjectType, RJSFSchema, StrictRJSFSchema, ValidatorType } from '../types'; +import { + Experimental_CustomMergeAllOf, + FormContextType, + GenericObjectType, + RJSFSchema, + StrictRJSFSchema, + ValidatorType, +} from '../types'; import getFirstMatchingOption from './getFirstMatchingOption'; /** Retrieves an expanded schema that has had all of its conditions, additional properties, references and dependencies @@ -36,14 +43,29 @@ import getFirstMatchingOption from './getFirstMatchingOption'; * @param schema - The schema for which retrieving a schema is desired * @param [rootSchema={}] - The root schema that will be forwarded to all the APIs * @param [rawFormData] - The current formData, if any, to assist retrieving a schema + * @param [experimental_customMergeAllOf] - Optional function that allows for custom merging of `allOf` schemas * @returns - The schema having its conditions, additional properties, references and dependencies resolved */ export default function retrieveSchema< T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any ->(validator: ValidatorType, schema: S, rootSchema: S = {} as S, rawFormData?: T): S { - return retrieveSchemaInternal(validator, schema, rootSchema, rawFormData)[0]; +>( + validator: ValidatorType, + schema: S, + rootSchema: S = {} as S, + rawFormData?: T, + experimental_customMergeAllOf?: Experimental_CustomMergeAllOf +): S { + return retrieveSchemaInternal( + validator, + schema, + rootSchema, + rawFormData, + undefined, + undefined, + experimental_customMergeAllOf + )[0]; } /** Resolves a conditional block (if/else/then) by removing the condition and merging the appropriate conditional branch @@ -57,6 +79,7 @@ export default function retrieveSchema< * dependencies as a list of schemas * @param recurseList - The list of recursive references already processed * @param [formData] - The current formData to assist retrieving a schema + * @param [experimental_customMergeAllOf] - Optional function that allows for custom merging of `allOf` schemas * @returns - A list of schemas with the appropriate conditions resolved, possibly with all branches expanded */ export function resolveCondition( @@ -65,7 +88,8 @@ export function resolveCondition ): S[] { const { if: expression, then, else: otherwise, ...resolvedSchemaLessConditional } = schema; @@ -75,12 +99,28 @@ export function resolveCondition(validator, then as S, rootSchema, formData, expandAllBranches, recurseList) + retrieveSchemaInternal( + validator, + then as S, + rootSchema, + formData, + expandAllBranches, + recurseList, + experimental_customMergeAllOf + ) ); } if (otherwise && typeof otherwise !== 'boolean') { schemas = schemas.concat( - retrieveSchemaInternal(validator, otherwise as S, rootSchema, formData, expandAllBranches, recurseList) + retrieveSchemaInternal( + validator, + otherwise as S, + rootSchema, + formData, + expandAllBranches, + recurseList, + experimental_customMergeAllOf + ) ); } } else { @@ -93,7 +133,8 @@ export function resolveCondition mergeSchemas(resolvedSchemaLessConditional, s) as S); } return resolvedSchemas.flatMap((s) => - retrieveSchemaInternal(validator, s, rootSchema, formData, expandAllBranches, recurseList) + retrieveSchemaInternal( + validator, + s, + rootSchema, + formData, + expandAllBranches, + recurseList, + experimental_customMergeAllOf + ) ); } @@ -148,6 +197,7 @@ export function getAllPermutationsOfXxxOf( @@ -156,7 +206,8 @@ export function resolveSchema ): S[] { const updatedSchemas = resolveReference( validator, @@ -181,7 +232,15 @@ export function resolveSchema { - return retrieveSchemaInternal(validator, s, rootSchema, formData, expandAllBranches, recurseList); + return retrieveSchemaInternal( + validator, + s, + rootSchema, + formData, + expandAllBranches, + recurseList, + experimental_customMergeAllOf + ); }); } if (ALL_OF_KEY in schema && Array.isArray(schema.allOf)) { @@ -192,7 +251,8 @@ export function resolveSchema(allOfSchemaElements); @@ -213,6 +273,7 @@ export function resolveSchema( @@ -221,7 +282,8 @@ export function resolveReference ): S[] { const updatedSchema = resolveAllReferences(schema, rootSchema, recurseList); if (updatedSchema !== schema) { @@ -232,7 +294,8 @@ export function resolveReference ): S[] { if (!isObject(schema)) { return [{} as S]; @@ -402,7 +467,8 @@ export function retrieveSchemaInternal< rootSchema, expandAllBranches, recurseList, - rawFormData as T + rawFormData as T, + experimental_customMergeAllOf ); } if (ALL_OF_KEY in resolvedSchema) { @@ -424,9 +490,11 @@ export function retrieveSchemaInternal< if (withContainsSchemas.length) { resolvedSchema = { ...resolvedSchema, allOf: withoutContainsSchemas }; } - resolvedSchema = mergeAllOf(resolvedSchema, { - deep: false, - } as Options) as S; + resolvedSchema = experimental_customMergeAllOf + ? experimental_customMergeAllOf(resolvedSchema) + : (mergeAllOf(resolvedSchema, { + deep: false, + } as Options) as S); if (withContainsSchemas.length) { resolvedSchema.allOf = withContainsSchemas; } @@ -499,6 +567,7 @@ export function resolveAnyOrOneOfSchemas< * as a list of schemas * @param recurseList - The list of recursive references already processed * @param [formData] - The current formData, if any, to assist retrieving a schema + * @param [experimental_customMergeAllOf] - Optional function that allows for custom merging of `allOf` schemas * @returns - The list of schemas with their dependencies resolved */ export function resolveDependencies( @@ -507,7 +576,8 @@ export function resolveDependencies ): S[] { // Drop the dependencies from the source schema. const { dependencies, ...remainingSchema } = schema; @@ -526,7 +596,8 @@ export function resolveDependencies( @@ -551,7 +623,8 @@ export function processDependencies ): S[] { let schemas = [resolvedSchema]; // Process dependencies updating the local schema properties as appropriate. @@ -579,7 +652,8 @@ export function processDependencies @@ -590,7 +664,8 @@ export function processDependencies * as a list of schemas * @param recurseList - The list of recursive references already processed * @param [formData]- The current formData to assist retrieving a schema + * @param [experimental_customMergeAllOf] - Optional function that allows for custom merging of `allOf` schemas * @returns - The list of schemas with the dependent schema resolved into them */ export function withDependentSchema( @@ -638,7 +714,8 @@ export function withDependentSchema ): S[] { const dependentSchemas = retrieveSchemaInternal( validator, @@ -646,7 +723,8 @@ export function withDependentSchema { const { oneOf, ...dependentSchema } = dependent; @@ -672,7 +750,8 @@ export function withDependentSchema ): S[] { const validSubschemas = oneOf!.filter((subschema) => { if (typeof subschema === 'boolean' || !subschema || !subschema.properties) { @@ -738,7 +819,8 @@ export function withExactlyOneSubschema< rootSchema, formData, expandAllBranches, - recurseList + recurseList, + experimental_customMergeAllOf ); return schemas.map((s) => mergeSchemas(schema, s) as S); }); diff --git a/packages/utils/src/schema/toIdSchema.ts b/packages/utils/src/schema/toIdSchema.ts index 04fb79eaac..1265cd8af8 100644 --- a/packages/utils/src/schema/toIdSchema.ts +++ b/packages/utils/src/schema/toIdSchema.ts @@ -3,7 +3,15 @@ import isEqual from 'lodash/isEqual'; import { ALL_OF_KEY, DEPENDENCIES_KEY, ID_KEY, ITEMS_KEY, PROPERTIES_KEY, REF_KEY } from '../constants'; import isObject from '../isObject'; -import { FormContextType, GenericObjectType, IdSchema, RJSFSchema, StrictRJSFSchema, ValidatorType } from '../types'; +import { + Experimental_CustomMergeAllOf, + FormContextType, + GenericObjectType, + IdSchema, + RJSFSchema, + StrictRJSFSchema, + ValidatorType, +} from '../types'; import retrieveSchema from './retrieveSchema'; import getSchemaType from '../getSchemaType'; @@ -18,6 +26,7 @@ import getSchemaType from '../getSchemaType'; * @param [rootSchema] - The root schema, used to primarily to look up `$ref`s * @param [formData] - The current formData, if any, to assist retrieving a schema * @param [_recurseList=[]] - The list of retrieved schemas currently being recursed, used to prevent infinite recursion + * @param [experimental_customMergeAllOf] - Optional function that allows for custom merging of `allOf` schemas * @returns - The `IdSchema` object for the `schema` */ function toIdSchemaInternal( @@ -28,7 +37,8 @@ function toIdSchemaInternal ): IdSchema { if (REF_KEY in schema || DEPENDENCIES_KEY in schema || ALL_OF_KEY in schema) { const _schema = retrieveSchema(validator, schema, rootSchema, formData); @@ -42,7 +52,8 @@ function toIdSchemaInternal( @@ -99,7 +113,18 @@ export default function toIdSchema ): IdSchema { - return toIdSchemaInternal(validator, schema, idPrefix, idSeparator, id, rootSchema, formData); + return toIdSchemaInternal( + validator, + schema, + idPrefix, + idSeparator, + id, + rootSchema, + formData, + undefined, + experimental_customMergeAllOf + ); } diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index 6c0fbb0170..ed1ca7aa69 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -88,6 +88,12 @@ export type Experimental_DefaultFormStateBehavior = { allOf?: 'populateDefaults' | 'skipDefaults'; }; +/** Optional function that allows for custom merging of `allOf` schemas + * @param schema - Schema with `allOf` that needs to be merged + * @returns The merged schema + */ +export type Experimental_CustomMergeAllOf = (schema: S) => S; + /** The interface representing a Date object that contains an optional time */ export interface DateObject { /** The year of the Date */ @@ -1030,12 +1036,14 @@ export interface SchemaUtilsType, rootSchema: S, - experimental_defaultFormStateBehavior?: Experimental_DefaultFormStateBehavior + experimental_defaultFormStateBehavior?: Experimental_DefaultFormStateBehavior, + experimental_customMergeAllOf?: Experimental_CustomMergeAllOf ): boolean; /** Returns the superset of `formData` that includes the given set updated to include any missing fields that have * computed to have defaults provided in the `schema`. diff --git a/packages/utils/test/schema/retrieveSchemaTest.ts b/packages/utils/test/schema/retrieveSchemaTest.ts index 2e13c7b262..ca245d5e27 100644 --- a/packages/utils/test/schema/retrieveSchemaTest.ts +++ b/packages/utils/test/schema/retrieveSchemaTest.ts @@ -897,6 +897,37 @@ export default function retrieveSchemaTest(testValidator: TestValidatorType) { default: 'hi', }); }); + + it('should use experimental_customMergeAllOf when provided', () => { + const schema: RJSFSchema = { + allOf: [ + { + type: 'object', + properties: { + string: { type: 'string' }, + }, + }, + { + type: 'object', + properties: { + number: { type: 'number' }, + }, + }, + ], + }; + const rootSchema: RJSFSchema = { definitions: {} }; + const formData = {}; + const customMergeAllOf = jest.fn().mockReturnValue({ + type: 'object', + properties: { string: { type: 'string' }, number: { type: 'number' } }, + }); + + expect(retrieveSchema(testValidator, schema, rootSchema, formData, customMergeAllOf)).toEqual({ + type: 'object', + properties: { string: { type: 'string' }, number: { type: 'number' } }, + }); + expect(customMergeAllOf).toHaveBeenCalledWith(schema); + }); }); describe('Conditional schemas (If, Then, Else)', () => { it('should resolve if, then', () => {