Skip to content
Merged
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
120 changes: 73 additions & 47 deletions linkml/generators/jsonschemagen.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,20 +83,42 @@ def add_lax_def(self, names: Union[str, List[str]], identifier_name: str) -> Non
lax_cls["required"].remove(identifier_name)
self["$defs"][canonical_name + self.OPTIONAL_IDENTIFIER_SUFFIX] = lax_cls

def add_property(self, name: str, subschema: "JsonSchema", required: bool = False) -> None:
def add_property(
self, name: str, subschema: "JsonSchema", *, value_required: bool = False, value_disallowed: bool = False
) -> None:
canonical_name = underscore(name)

if "properties" not in self:
self["properties"] = {}

self["properties"][canonical_name] = subschema

if required:
if value_required:
if "required" not in self:
self["required"] = []

self["required"].append(canonical_name)

# JSON Schema does not have a very natural way to express that a property cannot be present.
# The apparent best way to do it is to use:
# {
# properties: {
# foo: ...
# },
# not: {
# required: ['foo']
# }
# }
# The {required: [foo]} subschema evaluates to true if the foo property is present with any
# value. Wrapping that in a `not` keyword inverts that condition.
if value_disallowed:
if "not" not in self:
self["not"] = {}
if "required" not in self["not"]:
self["not"]["required"] = []

self["not"]["required"].append(canonical_name)

def add_keyword(self, keyword: str, value: Any):
if value is None:
return
Expand Down Expand Up @@ -220,42 +242,44 @@ def handle_class(self, cls: ClassDefinition) -> None:
self.handle_class_slot(subschema=class_subschema, cls=cls, slot=slot_definition)

rule_subschemas = []
for rule in cls.rules:
subschema = JsonSchema()
for ancestor_class_name in self.schemaview.class_ancestors(cls.name):
ancestor_class = self.schemaview.get_class(ancestor_class_name)
for rule in ancestor_class.rules:
subschema = JsonSchema()

open_world = rule.open_world
if open_world is None:
open_world = False
open_world = rule.open_world
if open_world is None:
open_world = False

if_subschema = self.get_subschema_for_anonymous_class(rule.preconditions, properties_required=True)
if if_subschema:
subschema["if"] = if_subschema
if_subschema = self.get_subschema_for_anonymous_class(rule.preconditions, properties_required=True)
if if_subschema:
subschema["if"] = if_subschema

then_subschema = self.get_subschema_for_anonymous_class(
rule.postconditions, properties_required=not open_world
)
if then_subschema:
subschema["then"] = then_subschema
then_subschema = self.get_subschema_for_anonymous_class(
rule.postconditions, properties_required=not open_world
)
if then_subschema:
subschema["then"] = then_subschema

# same as required requirements as postconditions?
else_subschema = self.get_subschema_for_anonymous_class(
rule.elseconditions, properties_required=not open_world
)
if else_subschema:
subschema["else"] = else_subschema
# same as required requirements as postconditions?
else_subschema = self.get_subschema_for_anonymous_class(
rule.elseconditions, properties_required=not open_world
)
if else_subschema:
subschema["else"] = else_subschema

rule_subschemas.append(subschema)
rule_subschemas.append(subschema)

if rule.bidirectional:
inverse_subschema = JsonSchema()
if rule.bidirectional:
inverse_subschema = JsonSchema()

if then_subschema:
inverse_subschema["if"] = then_subschema
if then_subschema:
inverse_subschema["if"] = then_subschema

if if_subschema:
inverse_subschema["then"] = if_subschema
if if_subschema:
inverse_subschema["then"] = if_subschema

rule_subschemas.append(inverse_subschema)
rule_subschemas.append(inverse_subschema)

if len(rule_subschemas) == 1:
class_subschema.update(rule_subschemas[0])
Expand Down Expand Up @@ -285,20 +309,20 @@ def get_subschema_for_anonymous_class(
subschema = JsonSchema()
for slot in cls.slot_conditions.values():
prop = self.get_subschema_for_slot(slot, omit_type=True)
value_required = False
value_disallowed = False
if slot.value_presence:
if slot.value_presence == PresenceEnum(PresenceEnum.PRESENT):
this_properties_required = True
value_required = True
elif slot.value_presence == PresenceEnum(PresenceEnum.ABSENT):
this_properties_required = False
# make the slot unsatisfiable
prop["enum"] = []
else:
this_properties_required = False
value_disallowed = True
elif slot.required is not None:
this_properties_required = slot.required
value_required = slot.required
else:
this_properties_required = properties_required
subschema.add_property(self.aliased_slot_name(slot), prop, this_properties_required)
value_required = properties_required
subschema.add_property(
self.aliased_slot_name(slot), prop, value_required=value_required, value_disallowed=value_disallowed
)

if cls.any_of is not None and len(cls.any_of) > 0:
subschema["anyOf"] = [self.get_subschema_for_anonymous_class(c, properties_required) for c in cls.any_of]
Expand Down Expand Up @@ -343,7 +367,9 @@ def extract_permissible_text(pv):
enum_schema["enum"] = permissible_values_texts
self.top_level_schema.add_def(enum.name, enum_schema)

def get_type_info_for_slot_subschema(self, slot: AnonymousSlotExpression) -> Tuple[str, str, Union[str, List[str]]]:
def get_type_info_for_slot_subschema(
self, slot: Union[SlotDefinition, AnonymousSlotExpression]
) -> Tuple[str, str, Union[str, List[str]]]:
# JSON Schema type (https://json-schema.org/understanding-json-schema/reference/type.html)
typ = None
# Reference to a JSON schema entity (https://json-schema.org/understanding-json-schema/structuring.html#ref)
Expand Down Expand Up @@ -379,7 +405,7 @@ def get_type_info_for_slot_subschema(self, slot: AnonymousSlotExpression) -> Tup

return (typ, fmt, reference)

def get_value_constraints_for_slot(self, slot: Union[AnonymousSlotExpression, None]) -> JsonSchema:
def get_value_constraints_for_slot(self, slot: Union[SlotDefinition, AnonymousSlotExpression, None]) -> JsonSchema:
if slot is None:
return JsonSchema()

Expand All @@ -397,11 +423,6 @@ def get_value_constraints_for_slot(self, slot: Union[AnonymousSlotExpression, No
constraints.add_keyword("maximum", slot.maximum_value)
constraints.add_keyword("const", slot.equals_string)
constraints.add_keyword("const", slot.equals_number)
if slot.value_presence:
if slot.value_presence == PresenceEnum(PresenceEnum.PRESENT):
constraints.add_keyword("required", True)
elif slot.value_presence == PresenceEnum(PresenceEnum.ABSENT):
constraints.add_keyword("enum", [])
return constraints

def get_subschema_for_slot(self, slot: SlotDefinition, omit_type: bool = False) -> JsonSchema:
Expand Down Expand Up @@ -503,11 +524,16 @@ def get_subschema_for_slot(self, slot: SlotDefinition, omit_type: bool = False)

def handle_class_slot(self, subschema: JsonSchema, cls: ClassDefinition, slot: SlotDefinition) -> None:
class_id_slot = self.schemaview.get_identifier_slot(cls.name, use_key=True)
slot_is_required = slot.required or slot == class_id_slot
value_required = (
slot.required or slot == class_id_slot or slot.value_presence == PresenceEnum(PresenceEnum.PRESENT)
)
value_disallowed = slot.value_presence == PresenceEnum(PresenceEnum.ABSENT)

aliased_slot_name = self.aliased_slot_name(slot)
prop = self.get_subschema_for_slot(slot)
subschema.add_property(aliased_slot_name, prop, slot_is_required)
subschema.add_property(
aliased_slot_name, prop, value_required=value_required, value_disallowed=value_disallowed
)

if slot.designates_type:
type_value = get_type_designator_value(self.schemaview, slot, cls)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,22 @@ schema:
slots:
drivers_license_number:
role:
gas_money:
range: float
required: true

classes:
CarOccupant:
tree_root: true
slots:
- role
- drivers_license_number
- gas_money
rules:
- title: drivers need licenses
description: >-
this rule verifies that a slot can be flipped from non-required to required in a
postcondition and that asserting required: false in the elsecondition is a no-op.
preconditions:
slot_conditions:
role:
Expand All @@ -32,16 +39,44 @@ schema:
slot_conditions:
drivers_license_number:
required: false
- title: driver doesn't have to pay for gas
description: >-
this rule verifies that a slot can be flipped from required to non-required in a
postcondition and that asserting required: true in the elsecondition is a no-op.
preconditions:
slot_conditions:
role:
equals_string: driver
postconditions:
slot_conditions:
gas_money:
required: false
elseconditions:
slot_conditions:
gas_money:
required: true

data_cases:
- data:
role: driver
drivers_license_number: ABC123
gas_money: 10.0
- data:
role: driver
error_message: "required"
gas_money: 10.0
error_message: "drivers_license_number"
# TODO: Making a slot NOT required in a rule doesn't currently work
# https://github.com/linkml/linkml/issues/1803
# - data:
# role: driver
# drivers_license_number: ABC123
- data:
role: passenger
drivers_license_number: ABC123
gas_money: 10.0
- data:
role: passenger
gas_money: 10.0
- data:
role: passenger
error_message: "gas_money"
43 changes: 43 additions & 0 deletions tests/test_generators/input/jsonschema_rule_inheritance.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
schema:
id: https://example.org/test
name: test

prefixes:
linkml: https://w3id.org/linkml/
imports:
- linkml:types

default_range: integer

classes:
MyClass:
attributes:
a:
b:
rules:
- description: if a is big then b is small
preconditions:
slot_conditions:
a:
minimum_value: 100
postconditions:
slot_conditions:
b:
maximum_value: 10
MySubClass:
is_a: MyClass
tree_root: true
attributes:
c:

data_cases:
- data:
a: 1000
b: 1
- data:
a: 1
b: 1000
- data:
a: 1000
b: 2000
error_message: "2000 is greater than the maximum"
6 changes: 6 additions & 0 deletions tests/test_generators/test_jsonschemagen.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,12 @@ def test_missing_top_class(input_path, caplog):
assert "No class in schema named NotARealClass" in caplog.text


def test_rule_inheritance(subtests, input_path):
"""Tests that rules are inherited from superclasses"""

external_file_test(subtests, input_path("jsonschema_rule_inheritance.yaml"))


# **********************************************************
#
# Utility functions
Expand Down