From 747a2029588e3072bda2486d63271fd527be93c2 Mon Sep 17 00:00:00 2001 From: "Edgar R. M" Date: Tue, 6 Dec 2022 20:32:24 -0600 Subject: [PATCH] feat: Support boolean `additional_properties` in JSON schema helper objects (#1188) * feat: Support boolean `additional_properties` in JSON schema helper objects * Add more test cases for `ObjectType` * Remove re-implementation of to_json * Re-add test case Co-authored-by: Cody J. Hanson --- singer_sdk/typing.py | 107 +++++++++++++++--- tests/core/test_jsonschema_helpers.py | 88 ++++++++++++++ .../duplicates_no_additional_properties.json | 30 +++++ .../jsonschema/no_additional_properties.json | 30 +++++ ...d_duplicates_no_additional_properties.json | 33 ++++++ .../required_no_additional_properties.json | 32 ++++++ 6 files changed, 304 insertions(+), 16 deletions(-) create mode 100644 tests/snapshots/jsonschema/duplicates_no_additional_properties.json create mode 100644 tests/snapshots/jsonschema/no_additional_properties.json create mode 100644 tests/snapshots/jsonschema/required_duplicates_no_additional_properties.json create mode 100644 tests/snapshots/jsonschema/required_no_additional_properties.json diff --git a/singer_sdk/typing.py b/singer_sdk/typing.py index 5d38f0abe..1a689b8fc 100644 --- a/singer_sdk/typing.py +++ b/singer_sdk/typing.py @@ -153,6 +153,17 @@ def to_dict(self) -> dict: """ return cast(dict, self.type_dict) + def to_json(self, **kwargs: Any) -> str: + """Convert to JSON. + + Args: + kwargs: Additional keyword arguments to pass to json.dumps(). + + Returns: + A JSON string describing the object. + """ + return json.dumps(self.to_dict(), **kwargs) + class StringType(JSONTypeHelper): """String type.""" @@ -449,7 +460,7 @@ class ObjectType(JSONTypeHelper): def __init__( self, *properties: Property, - additional_properties: W | type[W] | None = None, + additional_properties: W | type[W] | bool | None = None, pattern_properties: Mapping[str, W | type[W]] | None = None, ) -> None: """Initialize ObjectType from its list of properties. @@ -457,9 +468,81 @@ def __init__( Args: properties: Zero or more attributes for this JSON object. additional_properties: A schema to match against unnamed properties in - this object. + this object, or a boolean indicating if extra properties are allowed. pattern_properties: A dictionary of regex patterns to match against property names, and the schema to match against the values. + + Examples: + >>> t = ObjectType( + ... Property("name", StringType, required=True), + ... Property("age", IntegerType), + ... Property("height", NumberType), + ... additional_properties=False, + ... ) + >>> print(t.to_json(indent=2)) + { + "type": "object", + "properties": { + "name": { + "type": [ + "string" + ] + }, + "age": { + "type": [ + "integer", + "null" + ] + }, + "height": { + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } + >>> t = ObjectType( + ... Property("name", StringType, required=True), + ... Property("age", IntegerType), + ... Property("height", NumberType), + ... additional_properties=StringType, + ... ) + >>> print(t.to_json(indent=2)) + { + "type": "object", + "properties": { + "name": { + "type": [ + "string" + ] + }, + "age": { + "type": [ + "integer", + "null" + ] + }, + "height": { + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "name" + ], + "additionalProperties": { + "type": [ + "string" + ] + } + } """ self.wrapped: list[Property] = list(properties) self.additional_properties = additional_properties @@ -478,13 +561,16 @@ def type_dict(self) -> dict: # type: ignore # OK: @classproperty vs @property merged_props.update(w.to_dict()) if not w.optional: required.append(w.name) - result = {"type": "object", "properties": merged_props} + result: dict = {"type": "object", "properties": merged_props} if required: result["required"] = required - if self.additional_properties: - result["additionalProperties"] = self.additional_properties.type_dict + if self.additional_properties is not None: + if isinstance(self.additional_properties, bool): + result["additionalProperties"] = self.additional_properties + else: + result["additionalProperties"] = self.additional_properties.type_dict if self.pattern_properties: result["patternProperties"] = { @@ -493,17 +579,6 @@ def type_dict(self) -> dict: # type: ignore # OK: @classproperty vs @property return result - def to_json(self, **kwargs: Any) -> str: - """Return a JSON string representation of the object. - - Args: - **kwargs: Additional keyword arguments to pass to `json.dumps`. - - Returns: - A JSON string. - """ - return json.dumps(self.type_dict, **kwargs) - class CustomType(JSONTypeHelper): """Accepts an arbitrary JSON Schema dictionary.""" diff --git a/tests/core/test_jsonschema_helpers.py b/tests/core/test_jsonschema_helpers.py index 98a685da3..9914ed04b 100644 --- a/tests/core/test_jsonschema_helpers.py +++ b/tests/core/test_jsonschema_helpers.py @@ -4,6 +4,7 @@ import re from pathlib import Path +from textwrap import dedent from typing import Callable import pytest @@ -79,6 +80,48 @@ def discover_streams(self) -> list[Stream]: return [] +def test_to_json(): + schema = PropertiesList( + Property( + "test_property", + StringType, + description="A test property", + required=True, + ), + Property( + "test_property_2", + StringType, + description="A test property", + ), + additional_properties=False, + ) + assert schema.to_json(indent=4) == dedent( + """\ + { + "type": "object", + "properties": { + "test_property": { + "type": [ + "string" + ], + "description": "A test property" + }, + "test_property_2": { + "type": [ + "string", + "null" + ], + "description": "A test property" + } + }, + "required": [ + "test_property" + ], + "additionalProperties": false + }""" + ) + + def test_nested_complex_objects(): test1a = Property( "Datasets", @@ -467,6 +510,16 @@ def test_array_type(): "additional_properties.json", id="no required, no duplicates, additional properties", ), + pytest.param( + ObjectType( + Property("id", StringType), + Property("email", StringType), + Property("username", StringType), + Property("phone_number", StringType), + additional_properties=False, + ), + "no_additional_properties.json", + ), pytest.param( ObjectType( Property("id", StringType), @@ -490,6 +543,18 @@ def test_array_type(): "duplicates_additional_properties.json", id="no required, duplicates, additional properties", ), + pytest.param( + ObjectType( + Property("id", StringType), + Property("id", StringType), + Property("email", StringType), + Property("username", StringType), + Property("phone_number", StringType), + additional_properties=False, + ), + "duplicates_no_additional_properties.json", + id="no required, duplicates, no additional properties allowed", + ), pytest.param( ObjectType( Property("id", StringType), @@ -511,6 +576,17 @@ def test_array_type(): "required_additional_properties.json", id="required, no duplicates, additional properties", ), + pytest.param( + ObjectType( + Property("id", StringType), + Property("email", StringType, required=True), + Property("username", StringType, required=True), + Property("phone_number", StringType), + additional_properties=False, + ), + "required_no_additional_properties.json", + id="required, no duplicates, no additional properties allowed", + ), pytest.param( ObjectType( Property("id", StringType), @@ -534,6 +610,18 @@ def test_array_type(): "required_duplicates_additional_properties.json", id="required, duplicates, additional properties", ), + pytest.param( + ObjectType( + Property("id", StringType), + Property("email", StringType, True), + Property("email", StringType, True), + Property("username", StringType, True), + Property("phone_number", StringType), + additional_properties=False, + ), + "required_duplicates_no_additional_properties.json", + id="required, duplicates, no additional properties allowed", + ), pytest.param( ObjectType( Property("id", StringType), diff --git a/tests/snapshots/jsonschema/duplicates_no_additional_properties.json b/tests/snapshots/jsonschema/duplicates_no_additional_properties.json new file mode 100644 index 000000000..cc04f2a37 --- /dev/null +++ b/tests/snapshots/jsonschema/duplicates_no_additional_properties.json @@ -0,0 +1,30 @@ +{ + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "email": { + "type": [ + "string", + "null" + ] + }, + "username": { + "type": [ + "string", + "null" + ] + }, + "phone_number": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/tests/snapshots/jsonschema/no_additional_properties.json b/tests/snapshots/jsonschema/no_additional_properties.json new file mode 100644 index 000000000..cc04f2a37 --- /dev/null +++ b/tests/snapshots/jsonschema/no_additional_properties.json @@ -0,0 +1,30 @@ +{ + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "email": { + "type": [ + "string", + "null" + ] + }, + "username": { + "type": [ + "string", + "null" + ] + }, + "phone_number": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/tests/snapshots/jsonschema/required_duplicates_no_additional_properties.json b/tests/snapshots/jsonschema/required_duplicates_no_additional_properties.json new file mode 100644 index 000000000..fd65061f8 --- /dev/null +++ b/tests/snapshots/jsonschema/required_duplicates_no_additional_properties.json @@ -0,0 +1,33 @@ +{ + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "email": { + "type": [ + "string" + ] + }, + "username": { + "type": [ + "string" + ] + }, + "phone_number": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "email", + "email", + "username" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/tests/snapshots/jsonschema/required_no_additional_properties.json b/tests/snapshots/jsonschema/required_no_additional_properties.json new file mode 100644 index 000000000..dcd44ca07 --- /dev/null +++ b/tests/snapshots/jsonschema/required_no_additional_properties.json @@ -0,0 +1,32 @@ +{ + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "email": { + "type": [ + "string" + ] + }, + "username": { + "type": [ + "string" + ] + }, + "phone_number": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "email", + "username" + ], + "additionalProperties": false +} \ No newline at end of file