Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion packages/linkml/src/linkml/generators/jsonschemagen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down Expand Up @@ -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"""
Expand Down
47 changes: 47 additions & 0 deletions tests/linkml/test_scripts/test_gen_json_schema.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import re

import pytest
Expand Down Expand Up @@ -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"
Loading