118

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.

Seanny123
  • 8,776
  • 13
  • 68
  • 124
user3486184
  • 2,147
  • 3
  • 26
  • 28
  • I had to use the oneOf tag outside as parent tag and properties inside, which fulfilled the requirement for me. https://medium.com/@dheerajkumar_95579/json-schema-oneof-with-either-or-required-ab633daa29bb?sk=87db98fcab4a7bc6d7dfa46eb5146dae – Dheeraj Kumar Sep 20 '20 at 11:56

4 Answers4

174

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"
                    ]
                }
            ]
        }
    ]
}
S.A.
  • 1,819
  • 1
  • 24
  • 39
jruizaranguren
  • 12,679
  • 7
  • 55
  • 73
  • 1
    How does "not required" not mean inclusion forbidden? IMO if you have exactly one "not required" element, then if that element is present, the "required" succeeds, and the "not" therefore fails. Note that in the OP's example, there is several "not" keys in one object, which would probably result in some funky behavior. Also if you have multiple elements in the "not required" clause, it just means that validation fails only if **all** of them are present. That can, however, be circumvented by using "allOf". Please correct me if I am wrong. – Tomeamis Jun 25 '19 at 14:35
  • 4
    @Tomeamis "not required" means "may or may not be present", while "inclusion forbidden" means "must not be present", that's not the same – Mark May 04 '20 at 18:37
  • 4
    @Mark In plain English, you're correct. In JSON schema, a `required` element (with a single string inside of its array) inside of a `not` element actually means that the argument of the `required` is forbidden. Try to validate this JSON: `{"forbidden":"asd"}` against this schema: `{"$schema":"http://json-schema.org/draft-07/schema#","not":{"required":["forbidden"]}}`, and you will see that the validation fails. – Tomeamis May 05 '20 at 19:27
3

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'
Steve
  • 4,372
  • 26
  • 37
2

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

Miguel
  • 71
  • 5
1

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!

Hunter H.
  • 51
  • 5