How do I require one field or another or (one of two others) but not all of them?

JsonValidationJsonschema

Json Problem Overview


I am having trouble coming up with a JSON schema that will validate if the JSON contains either:

  • one field only
  • another field only
  • (one of two other fields) only

but not to match when multiples of those are present.

In my case specifically, I want one of

  • copyAll
  • fileNames
  • matchesFiles and/or doesntMatchFiles

to validate but I don't want to accept when more than that is there.

Here's what I've got so far:

{
    "$schema": "http://json-schema.org/draft-04/schema#",
    "type": "object",
    "required": [ "unrelatedA" ],
    "properties": {
	"unrelatedA": {
	    "type": "string"
	},
	"fileNames": {
	    "type": "array"
	},
	"copyAll": {
	    "type": "boolean"
	},
	"matchesFiles": {
	    "type": "array"
	},
	"doesntMatchFiles": {
	    "type": "array"
        }
    },
    "oneOf": [
         {"required": ["copyAll"], "not":{"required":["matchesFiles"]}, "not":{"required":["doesntMatchFiles"]}, "not":{"required":["fileNames"]}},
         {"required": ["fileNames"], "not":{"required":["matchesFiles"]}, "not":{"required":["doesntMatchFiles"]}, "not":{"required":["copyAll"]}},
         {"anyOf": [
               {"required": ["matchesFiles"], "not":{"required":["copyAll"]}, "not":{"required":["fileNames"]}},
               {"required": ["doesntMatchFiles"], "not":{"required":["copyAll"]}, "not":{"required":["fileNames"]}}]}
    ]
} ;

This matches more than I want to. I want this to match all of the following:

{"copyAll": true, "unrelatedA":"xxx"}
{"fileNames": ["aab", "cab"], "unrelatedA":"xxx"}
{"matchesFiles": ["a*"], "unrelatedA":"xxx"}
{"doesntMatchFiles": ["a*"], "unrelatedA":"xxx"}
{"matchesFiles": ["a*"], "doesntMatchFiles": ["*b"], "unrelatedA":"xxx"}

but not to match:

{"copyAll": true, "matchesFiles":["a*"], "unrelatedA":"xxx"}
{"fileNames": ["a"], "matchesFiles":["a*"], "unrelatedA":"xxx"}
{"copyAll": true, "doesntMatchFiles": ["*b"], "matchesFiles":["a*"], "unrelatedA":"xxx"}
{"fileNames": ["a"], "matchesFiles":["a*"], "unrelatedA":"xxx"}
{"unrelatedA":"xxx"}

I'm guessing there's something obvious I'm missing - I'd like to know what it is.

Json Solutions


Solution 1 - Json

The problem is the "not" semantics. "not required" does not mean "inclusion forbidden". It just means that you don't have to add it in order to validate that schema.

However, you can use "oneOf" to satisfy your specification in a simpler way. Remember that it means that "just one of these schemas can validate". The following schema achieves the property switching you are attempting to solve:

{
    "$schema": "http://json-schema.org/draft-04/schema#",
    "type": "object",
    "required": [
        "unrelatedA"
    ],
    "properties": {
        "unrelatedA": {
            "type": "string"
        },
        "fileNames": {
            "type": "array"
        },
        "copyAll": {
            "type": "boolean"
        },
        "matchesFiles": {
            "type": "array"
        },
        "doesntMatchFiles": {
            "type": "array"
        }
    },
    "oneOf": [
        {
            "required": [
                "copyAll"
            ]
        },
        {
            "required": [
                "fileNames"
            ]
        },
        {
            "anyOf": [
                {
                    "required": [
                        "matchesFiles"
                    ]
                },
                {
                    "required": [
                        "doesntMatchFiles"
                    ]
                }
            ]
        }
    ]
}

Solution 2 - Json

If the property having a value of null is as good as it not being there, then something like this might be suitable. commonProp must be provided, and only one of x or y can be provided.

You might get a couple of similar error messages though.

{
	$schema: 'http://json-schema.org/draft-07/schema#',
	type: 'object',
	required: ['commonProp'],

	oneOf: [
		{
			properties: {
				x: { type: 'number' },
				commonProp: { type: 'number' },
				y: {
					type: 'null',
					errorMessage: "should ONLY include either ('x') or ('y') keys. Not a mix.",
				},
			},
			additionalProperties: { not: true, errorMessage: 'remove additional property ${0#}' },
		},
		{
			properties: {
				y: { type: 'number' },
				commonProp: { type: 'number' },
				x: {
					type: 'null',
					errorMessage: "should ONLY include either ('x') or ('y') keys. Not a mix.",
				},
			},
			additionalProperties: { not: true, errorMessage: 'remove additional property ${0#}' },
		},
	],
}
const model = { x: 0, y: 0, commonProp: 0 };

// ⛔️ ⛔️ ⛔️ ⛔️ ⛔️ ⛔️
// Model>y should ONLY include either ('x') or ('y') keys. Not a mix.
// Model>x should ONLY include either ('x') or ('y') keys. Not a mix.
const model = { x: 0, y: null, commonProp: 0 };

//      
const model = { x: 0 };

// ⛔️ ⛔️ ⛔️ ⛔️ ⛔️ ⛔️
// Model must have required property 'commonProp'

Solution 3 - Json

As pointed out by @Tomeamis in the comments, the not-required combination means "forbidden" in json schema. However, you should not duplicate the "not" keyword (I do not really know why). Instead you should

{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"required": [ "unrelatedA" ],
"properties": {
    "unrelatedA": {
        "type": "string"
    },
    "fileNames": {
        "type": "array"
    },
    "copyAll": {
        "type": "boolean"
    },
    "matchesFiles": {
        "type": "array"
    },
    "doesntMatchFiles": {
        "type": "array"
    }
},
"oneOf": [
     {
         "required": [
             "copyAll"
         ],
         "not": {
             "anyOf": [
                 {"required":["matchesFiles"]},
                 {"required":["doesntMatchFiles"]},
                 {"required":["fileNames"]}
             ]
        }
     },
     {
         "required": [
             "fileNames"
         ],
         "not": {
             "anyOf": [
                 {"required":["matchesFiles"]},
                 {"required":["doesntMatchFiles"]},
                 {"required":["copyAll"]}
             ]
        }
     },
     {
         "anyOf": [
           {
               "required": ["matchesFiles"],
               "not": {
                   "anyOf": [
                       {"required":["fileNames"]},
                       {"required":["copyAll"]}
                   ]
               }
           },
           {
               "required": ["doesntMatchFiles"],
               "not": {
                   "anyOf": [
                       {"required":["fileNames"]},
                       {"required":["copyAll"]}
                   ]
               }
           }]
     }
]
}

More details here

To forbid the presence of a property it is also possible to do

{
    "properties": {
        "x": false
    }
}

as mentioned in the answers here

Solution 4 - Json

Little late to the party here but I implemented a solution today for this that works in my schema and is reusable.

For context, I had several fields that were required by name but their value could be empty or required to be present based on another condition.


Here is the reusable TypeScript method:

// SchemaLogic.ts

import { Schema } from "jsonschema";

/**
 * A required string property with a minimum length of 0.
 */
export const StringValue = { type: "string", required: true, minLength: 0 };
/**
 * A required string property with a minimum length of 1.
 */
export const NonEmptyStringValue = { type: "string", required: true, minLength: 1 };

/**
 * Provides the option to submit a value for one of the two
 * property names provided. If one of the properties is
 * submitted with a truthy string value, then the other will
 * not be required to have a value. If neither are submitted
 * with a truthy value, then both will return an error
 * message saying that the minimum length requirement has
 * not been met.
 *
 * **NOTE:**
 *  1. this only works with string properties that are
 *     not restricted to a certain set of values or a
 *     regex-validated format
 *  1. this must be used inside an `allOf` array
 *
 * @param propertyNames the names of the properties
 * @returns a {@link Schema} that creates a conditional
 *  requirement condition between the two fields
 */
export const eitherOr = (propertyNames: [string, string]): Schema => {
    return {
        if: { properties: { [propertyNames[0]]: NonEmptyStringValue } },
        then: { properties: { [propertyNames[1]]: StringValue } },
        else: {
            if: { properties: { [propertyNames[1]]: NonEmptyStringValue } },
            then: { properties: { [propertyNames[0]]: StringValue } },
            else: {
                properties: {
                    [propertyNames[0]]: NonEmptyStringValue,
                    [propertyNames[1]]: NonEmptyStringValue,
                },
            },
        },
    };
};

And here is the most basic example of how to use it. This will require the following:

  • xCode and xDescription must be present but only one needs to have a truthy value
  • yCode and yDescription must be present but only one needs to have a truthy value
import { eitherOr } from "./SchemaLogic";

const schema: Schema = {
    allOf: [eitherOr(["xCode", "xDescription"]), eitherOr(["yCode", "yDescription"])],
};

If you want to get more complex and require these fields conditionally, you can use something like the following:

const schema: Schema = {
    properties: {
        type: {
            type: ["string"],
            enum: ["one", "two", "three"],
            required: true,
        },
    },
    if: {
        // if the 'type' property is either "one" or "two"...
        properties: { type: { oneOf: [{ const: "one" }, { const: "two" }] } },
    },
    then: {
        // ...require values
        allOf: [eitherOr(["xCode", "xDescription"]), eitherOr(["yCode", "yDescription"])],
    },
};

> Note: > > If your schema uses additionalProperties: false, you will need to add the properties to the 'properties' section of your schema so they are defined. Otherwise, you will have a requirement for the field to be present and, at the same time, not allowed because it's an additional field.

Hope this is helpful!

Solution 5 - Json

This answer is not related to JSON schema, so it's a bit off the track, though it can bring another perspective on solving this problem, and json validation in general.

The point is to express declaratively exactly what you need as a result: a single field which is the only present. Consider the following json schema:

JsonElement json =
    new Gson().toJsonTree(
        Map.of(
            "first_field", "vasya",
            "second_field", false,
            "third_field", 777,
            "unrelated", "Rinse"
        )
    );

Let's say you need either one of the first_field, second_field, and third_field. The fourth field doesn't matter. Here is how the corresponding validation object looks like:

Result<SomeTestStructure> result =
    new UnnamedBlocOfNameds<SomeTestStructure>(
        List.of(
            new OneOf(
                "global",
                new ErrorStub("Only one of the fields must be present"),
                new AsString(
                    new Required(
                        new IndexedValue("first_field", json)
                    )
                ),
                new AsBoolean(
                    new Required(
                        new IndexedValue("second_field", json)
                    )
                ),
                new AsInteger(
                    new Required(
                        new IndexedValue("third_field", json)
                    )
                )
            ),
            new AsString(
                new IndexedValue("unrelated", json)
            )
        ),
        SomeTestStructure.class
    )
        .result();

First, you declare an unnamed block consisting of named ones; then you say that you need a single successful validatable element out of the three ones. And finally, you declare what success means. In this case, to be successful is to be simply present. If json is valid, an object of SomeTestStructure class is created:

assertTrue(result.isSuccessful());
assertEquals(
    new SomeTestStructure(777, "Rinse").thirdField(),
    result.value().raw().thirdField()
);

For more info about this approach and a library implementing it, check out a quick start entry.

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
Questionuser3486184View Question on Stackoverflow
Solution 1 - JsonjruizarangurenView Answer on Stackoverflow
Solution 2 - JsonSteveView Answer on Stackoverflow
Solution 3 - JsonMiguelView Answer on Stackoverflow
Solution 4 - JsonHunter H.View Answer on Stackoverflow
Solution 5 - JsonVadim SamokhinView Answer on Stackoverflow