diff --git a/packages/linkml/src/linkml/generators/jsonschemagen.py b/packages/linkml/src/linkml/generators/jsonschemagen.py index 35967eaf93..7ba4e28385 100644 --- a/packages/linkml/src/linkml/generators/jsonschemagen.py +++ b/packages/linkml/src/linkml/generators/jsonschemagen.py @@ -404,7 +404,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).""" @@ -1031,6 +1042,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"