diff --git a/src/caosadvancedtools/json_schema_exporter.py b/src/caosadvancedtools/json_schema_exporter.py index c836d56cc517ecb19f87644598027a17893584d3..5c9362fd3551ade773fa452d9f6543c36221c88b 100644 --- a/src/caosadvancedtools/json_schema_exporter.py +++ b/src/caosadvancedtools/json_schema_exporter.py @@ -25,7 +25,7 @@ The scope of this json schema is the automatic generation of user interfaces. """ from collections import OrderedDict -from typing import Any, Dict, Iterable, List, Optional, Union +from typing import Any, Dict, Iterable, List, Optional, Tuple, Union import linkahead as db from linkahead.common.datatype import get_list_datatype, is_list_datatype @@ -43,6 +43,7 @@ class JsonSchemaExporter: do_not_create: List[str] = None, do_not_retrieve: List[str] = None, no_remote: bool = False, + multiple_choice: List[str] = None, ): """Set up a JsonSchemaExporter, which can then be applied on RecordTypes. @@ -75,6 +76,10 @@ class JsonSchemaExporter: parameter, the behavior is undefined. no_remote : bool If True, do not attempt to connect to a LinkAhead server at all. Default is False. + multiple_choice : list[str], optional + A list of reference Property names which shall be denoted as multiple choice properties. + This means that each option in this property may be selected at most once. This is not + implemented yet if the Property is not in ``do_not_create`` as well. """ if not additional_options_for_text_props: additional_options_for_text_props = {} @@ -82,6 +87,8 @@ class JsonSchemaExporter: do_not_create = [] if not do_not_retrieve: do_not_retrieve = [] + if not multiple_choice: + multiple_choice = [] self._additional_properties = additional_properties self._name_property_for_new_records = name_property_for_new_records @@ -91,6 +98,8 @@ class JsonSchemaExporter: self._do_not_create = do_not_create self._do_not_retrieve = do_not_retrieve self._no_remote = no_remote + self._multiple_choice = multiple_choice + self._rjsf_uischema: dict = {} @staticmethod def _make_required_list(rt: db.RecordType): @@ -106,15 +115,15 @@ class JsonSchemaExporter: return required_list - def _make_segment_from_prop(self, prop: db.Property): - """Return the JSON Schema segment for the given property + def _make_segment_from_prop(self, prop: db.Property) -> Tuple[OrderedDict, dict]: + """Return the JSON Schema and ui schema segments for the given property. Parameters ---------- prop : db.Property The property to be transformed. """ - + ui_schema: dict = {} if prop.datatype == db.TEXT or prop.datatype == db.DATETIME: text_format = None text_pattern = None @@ -131,7 +140,7 @@ class JsonSchemaExporter: # options list. text_format = ["date", "date-time"] - return self._make_text_property(prop.description, text_format, text_pattern) + return self._make_text_property(prop.description, text_format, text_pattern), ui_schema json_prop = OrderedDict() if prop.description: @@ -154,13 +163,21 @@ class JsonSchemaExporter: json_prop["type"] = "array" list_element_prop = db.Property( name=prop.name, datatype=get_list_datatype(prop.datatype, strict=True)) - json_prop["items"] = self._make_segment_from_prop(list_element_prop) + json_prop["items"], inner_ui_schema = self._make_segment_from_prop(list_element_prop) + if prop.name in self._multiple_choice and prop.name in self._do_not_create: + json_prop["uniqueItems"] = True + ui_schema["ui:widget"] = "checkboxes" + ui_schema["ui:options"] = {"inline": True} + if inner_ui_schema: + ui_schema["items"] = inner_ui_schema elif prop.is_reference(): if prop.datatype == db.REFERENCE: # No Record creation since no RT is specified and we don't know what # schema to use, so only enum of all Records and all Files. values = self._retrieve_enum_values("RECORD") + self._retrieve_enum_values("FILE") json_prop["enum"] = values + if prop.name in self._multiple_choice: + json_prop["uniqueItems"] = True elif prop.datatype == db.FILE: json_prop["type"] = "string" json_prop["format"] = "data-url" @@ -181,7 +198,9 @@ class JsonSchemaExporter: else: rt = db.execute_query(f"FIND RECORDTYPE WITH name='{prop_name}'", unique=True) - subschema = self._make_segment_from_recordtype(rt) + subschema, ui_schema = self._make_segment_from_recordtype(rt) + # if inner_ui_schema: + # ui_schema = inner_ui_schema if values: subschema["title"] = "Create new" json_prop["oneOf"] = [ @@ -198,7 +217,7 @@ class JsonSchemaExporter: raise ValueError( f"Unknown or no property datatype. Property {prop.name} with type {prop.datatype}") - return json_prop + return json_prop, ui_schema @staticmethod def _make_text_property(description="", text_format=None, text_pattern=None): @@ -245,16 +264,20 @@ class JsonSchemaExporter: return vals - def _make_segment_from_recordtype(self, rt: db.RecordType): - """Return a Json schema segment for the given RecordType. + def _make_segment_from_recordtype(self, rt: db.RecordType) -> Tuple[OrderedDict, dict]: + """Return Json schema and uischema segments for the given RecordType. """ - schema: dict[str, Any] = { + schema: OrderedDict[str, Any] = OrderedDict({ "type": "object" - } + }) + ui_schema = {} schema["required"] = self._make_required_list(rt) schema["additionalProperties"] = self._additional_properties + if rt.name: + schema["title"] = rt.name + props = OrderedDict() if self._name_property_for_new_records: props["name"] = self._make_text_property("The name of the Record to be created") @@ -269,13 +292,15 @@ class JsonSchemaExporter: "Creating a schema for multi-properties is not specified. " f"Property {prop.name} occurs more than once." ) - props[prop.name] = self._make_segment_from_prop(prop) + props[prop.name], inner_ui_schema = self._make_segment_from_prop(prop) + if inner_ui_schema: + ui_schema[prop.name] = inner_ui_schema schema["properties"] = props - return schema + return schema, ui_schema - def recordtype_to_json_schema(self, rt: db.RecordType): + def recordtype_to_json_schema(self, rt: db.RecordType, rjsf_uischema: Optional[dict] = None): """Create a jsonschema from a given RecordType that can be used, e.g., to validate a json specifying a record of the given type. @@ -283,6 +308,9 @@ class JsonSchemaExporter: ---------- rt : RecordType The RecordType from which a json schema will be created. + rjsf_uischema : dict, optional + If given, uiSchema definitions for react-jsonschema-forms will be appended to this dict + at ``[rt.name]``. Returns ------- @@ -292,10 +320,13 @@ class JsonSchemaExporter: if rt is None: raise ValueError( "recordtype_to_json_schema(...) cannot be called with a `None` RecordType.") - schema = self._make_segment_from_recordtype(rt) - schema["$schema"] = "https://json-schema.org/draft/2019-09/schema" - if rt.name: - schema["title"] = rt.name + if rjsf_uischema is None: + rjsf_uischema = {} + + schema, inner_uischema = self._make_segment_from_recordtype(rt) + if inner_uischema: + rjsf_uischema[rt.name] = inner_uischema + schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" if rt.description: schema["description"] = rt.description @@ -310,6 +341,8 @@ def recordtype_to_json_schema(rt: db.RecordType, additional_properties: bool = T do_not_create: List[str] = None, do_not_retrieve: List[str] = None, no_remote: bool = False, + multiple_choice: List[str] = None, + rjsf_uischema: Optional[dict] = None, ): """Create a jsonschema from a given RecordType that can be used, e.g., to validate a json specifying a record of the given type. @@ -338,16 +371,22 @@ def recordtype_to_json_schema(rt: db.RecordType, additional_properties: bool = T description of the corresponding schema entry. If set to false, an additional `unit` key is added to the schema itself which is purely annotational and ignored, e.g., in validation. Default is True. - do_not_create : list[str] + do_not_create : list[str], optional A list of RedcordType names, for which there should be no option to create them. Instead, only the choice of existing elements should be given. - do_not_retrieve : list[str] + do_not_retrieve : list[str], optional A list of RedcordType names, for which no Records shall be retrieved. Instead, only an object description should be given. If this list overlaps with the `do_not_create` parameter, the behavior is undefined. - no_remote : bool + no_remote : bool, optional If True, do not attempt to connect to a LinkAhead server at all. Default is False. + multiple_choice : list[str], optional + A list of reference Property names which shall be denoted as multiple choice properties. + This means that each option in this property may be selected at most once. This is not + implemented yet if the Property is not in ``do_not_create`` as well. + rjsf_uischema : dict, optional + If given, uiSchema definitions for react-jsonschema-forms will be appended to this dict. Returns ------- @@ -365,8 +404,9 @@ def recordtype_to_json_schema(rt: db.RecordType, additional_properties: bool = T do_not_create=do_not_create, do_not_retrieve=do_not_retrieve, no_remote=no_remote, + multiple_choice=multiple_choice, ) - return exporter.recordtype_to_json_schema(rt) + return exporter.recordtype_to_json_schema(rt, rjsf_uischema=rjsf_uischema) def make_array(schema: dict) -> dict: @@ -397,7 +437,7 @@ out : dict result = { "type": "array", "items": schema, - "$schema": "https://json-schema.org/draft/2019-09/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", } return result diff --git a/unittests/test_json_schema_exporter.py b/unittests/test_json_schema_exporter.py index e2772f55ecd84e7a79ccae149b9e12cd249ba619..8fe43bdaa4c4a478310ba3eb682f6254f27b269f 100644 --- a/unittests/test_json_schema_exporter.py +++ b/unittests/test_json_schema_exporter.py @@ -81,6 +81,19 @@ def _mock_execute_query(query_string, unique=False, **kwargs): return referencing_type_records # wrong types, but who cares for the test? elif query_string == "FIND RECORDTYPE WITH name='RT1'" and unique is True: return RT1 + elif query_string == "FIND RECORDTYPE WITH name='RT21'" and unique is True: + model_str = """ +RT1: +RT21: + obligatory_properties: + RT1: + datatype: LIST<RT1> +RT3: + obligatory_properties: + RT21: + """ + model = parse_model_from_string(model_str) + return model.get_deep("RT21") elif query_string == "SELECT name, id FROM RECORD": return all_records elif query_string == "SELECT name, id FROM FILE": @@ -602,6 +615,7 @@ RT2: "some_date" ], "additionalProperties": true, + "title": "RT1", "properties": { "some_date": { "description": "Just some date", @@ -617,8 +631,7 @@ RT2: ] } }, - "$schema": "https://json-schema.org/draft/2019-09/schema", - "title": "RT1", + "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "some description" }""" # Second test: with reference @@ -630,6 +643,7 @@ RT2: "RT1" ], "additionalProperties": true, + "title": "RT2", "properties": { "RT1": { "description": "some description", @@ -647,6 +661,7 @@ RT2: "some_date" ], "additionalProperties": true, + "title": "Create new", "properties": { "some_date": { "description": "Just some date", @@ -661,14 +676,12 @@ RT2: } ] } - }, - "title": "Create new" + } } ] } }, - "$schema": "https://json-schema.org/draft/2019-09/schema", - "title": "RT2" + "$schema": "https://json-schema.org/draft/2020-12/schema" }""" # Third test: Reference prop shall be only existing references, no option to create new ones. @@ -679,6 +692,7 @@ RT2: "RT1" ], "additionalProperties": true, + "title": "RT2", "properties": { "RT1": { "description": "some description", @@ -688,8 +702,7 @@ RT2: ] } }, - "$schema": "https://json-schema.org/draft/2019-09/schema", - "title": "RT2" + "$schema": "https://json-schema.org/draft/2020-12/schema" }""" @@ -820,6 +833,53 @@ RT3: assert (schema_noexist_noremote["properties"]["NoRecords"].get("properties") == OrderedDict([('some_text', {'type': 'string'})])) - schema_noexist_noretrieve = rtjs(model.get_deep("RT2"), do_not_retrieve=["RT1"]) + uischema = {} + schema_noexist_noretrieve = rtjs(model.get_deep("RT2"), do_not_retrieve=["RT1"], + rjsf_uischema=uischema) assert schema_noexist_noretrieve["properties"]["RT1"].get("type") == "object" assert "some_date" in schema_noexist_noretrieve["properties"]["RT1"].get("properties") + assert not uischema + + +@patch("linkahead.execute_query", new=Mock(side_effect=_mock_execute_query)) +def test_multiple_choice(): + """Multiple choice is mostyly a matter of UI.""" + model_str = """ +RT1: +RT21: + obligatory_properties: + RT1: + datatype: LIST<RT1> +RT3: + obligatory_properties: + RT21: +RT4: + obligatory_properties: + RT21: + datatype: LIST<RT21> + """ + model = parse_model_from_string(model_str) + # generate a multiple choice, in first level + uischema = {} + schema = rtjs(model.get_deep("RT21"), additional_properties=False, do_not_create=["RT1"], + multiple_choice=["RT1"], rjsf_uischema=uischema) + assert schema["properties"]["RT1"]["uniqueItems"] is True + assert (str(uischema["RT21"]) == + "{'RT1': {'ui:widget': 'checkboxes', 'ui:options': {'inline': True}}}") + + # second level + uischema = {} + schema = rtjs(model.get_deep("RT3"), additional_properties=False, do_not_create=["RT1"], + multiple_choice=["RT1"], rjsf_uischema=uischema) + assert schema["properties"]["RT21"]["properties"]["RT1"]["uniqueItems"] is True + assert (str(uischema["RT3"]) == + "{'RT21': {'RT1': {'ui:widget': 'checkboxes', 'ui:options': {'inline': True}}}}") + + # second level with lists + uischema = {} + schema = rtjs(model.get_deep("RT4"), additional_properties=False, do_not_create=["RT1"], + multiple_choice=["RT1"], rjsf_uischema=uischema) + assert schema["properties"]["RT21"]["items"]["properties"]["RT1"]["uniqueItems"] is True + assert (str(uischema["RT4"]) == + "{'RT21': {'items': {'RT1': {'ui:widget': 'checkboxes', " + "'ui:options': {'inline': True}}}}}")