diff --git a/docs/generators/json-schema.rst b/docs/generators/json-schema.rst index ea2ca9cad9..af18817c45 100644 --- a/docs/generators/json-schema.rst +++ b/docs/generators/json-schema.rst @@ -322,6 +322,27 @@ This is what the underlying JSON-Schema looks like: +Optional slots and null +^^^^^^^^^^^^^^^^^^^^^^^ + +By default, optional (non-required) slots accept an explicit JSON ``null`` in +addition to their base type — the generator emits ``"type": ["string", "null"]`` +for an optional string slot. This is convenient for producers that serialise +missing values as ``null``. + +Some target schemas forbid explicit ``null`` on optional properties (a property +must either be present with a typed value or absent). Use +``--no-include-null`` to restrict optional slots to their base type: + +.. code:: bash + + gen-json-schema --no-include-null personinfo.yaml + +With ``--no-include-null`` the optional string slot above is emitted as +``"type": "string"``, so instance documents carrying ``"slot": null`` fail +validation. The default (``--include-null``) preserves the previous behaviour. + + Patterns ^^^^^^^^ diff --git a/packages/linkml/src/linkml/generators/jsonschemagen.py b/packages/linkml/src/linkml/generators/jsonschemagen.py index b9cfa6b883..0f23d6de08 100644 --- a/packages/linkml/src/linkml/generators/jsonschemagen.py +++ b/packages/linkml/src/linkml/generators/jsonschemagen.py @@ -423,7 +423,18 @@ class JsonSchemaGenerator(Generator, LifecycleMixin): top_level_schema: JsonSchema = None include_null: bool = True - """Whether to include a "null" type in optional slots""" + """Whether optional (non-required) slots also accept an explicit JSON ``null``. + + When ``True`` (default) an optional slot is rendered with ``null`` added to its + type (e.g. ``["string", "null"]``), so an explicit ``null`` value validates. When + ``False`` the slot keeps its bare type and optionality is expressed solely by + absence from ``required``. + + JSON Schema treats presence (``required``, Validation 6.5.3) as separate from the + value type (``type``, Validation 6.1.1, where ``null`` is one of the value types), + and JSON ``null`` is a distinct value, not an absent member (RFC 8259 sec. 3). Set + this ``False`` for strict parity with reference schemas that declare a bare type + and forbid ``null``.""" preserve_names: bool = False """If true, preserve LinkML element names in JSON Schema output (e.g., for $defs, properties, $ref targets).""" @@ -1064,6 +1075,13 @@ def serialize(self, **kwargs) -> str: show_default=True, help="If set, expand subproperty_of constraints to enum constraints.", ) +@click.option( + "--include-null/--no-include-null", + default=True, + show_default=True, + help="If set (default), optional slots also accept an explicit JSON null. " + "Use --no-include-null to forbid explicit null on optional slots.", +) @click.version_option(__version__, "-V", "--version") def cli(yamlfile, **kwargs): """Generate JSON Schema representation of a LinkML model""" diff --git a/tests/linkml/test_scripts/test_gen_json_schema.py b/tests/linkml/test_scripts/test_gen_json_schema.py index 7c172e0085..471265518b 100644 --- a/tests/linkml/test_scripts/test_gen_json_schema.py +++ b/tests/linkml/test_scripts/test_gen_json_schema.py @@ -1,3 +1,4 @@ +import json import re import pytest @@ -70,3 +71,49 @@ def test_include_option(input_path): extra_import_path = str(input_path("deprecation.yaml")) result = runner.invoke(cli, ["--include", extra_import_path, schema]) assert "C4" in result.output + + +_INCLUDE_NULL_SCHEMA = """ +id: https://example.org/test-include-null-cli +name: test-include-null-cli +prefixes: + linkml: https://w3id.org/linkml/ +default_range: string +imports: + - linkml:types +classes: + C: + attributes: + opt: + range: string + opt_multi: + range: string + multivalued: true + req: + range: string + required: true +""" + + +@pytest.mark.parametrize( + "flag,expected_opt,expected_opt_multi", + [ + ([], ["string", "null"], ["array", "null"]), + (["--include-null"], ["string", "null"], ["array", "null"]), + (["--no-include-null"], "string", "array"), + ], +) +def test_include_null_cli_option(flag, expected_opt, expected_opt_multi, tmp_path): + """The --include-null/--no-include-null CLI option must control whether optional + slots accept an explicit JSON null, across scalar and multivalued ranges, while + leaving required slots unaffected.""" + schema = tmp_path / "schema.yaml" + schema.write_text(_INCLUDE_NULL_SCHEMA) + runner = CliRunner() + result = runner.invoke(cli, flag + [str(schema)]) + assert result.exit_code == 0, result.output + props = json.loads(result.output)["$defs"]["C"]["properties"] + assert props["opt"]["type"] == expected_opt + assert props["opt_multi"]["type"] == expected_opt_multi + # required scalar slot is unaffected by the flag + assert props["req"]["type"] == "string"