diff --git a/poetry.lock b/poetry.lock index a4ca3e2..f14998d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -623,6 +623,20 @@ files = [ attrs = ">=22.2.0" rpds-py = ">=0.7.0" +[[package]] +name = "result" +version = "0.17.0" +description = "A Rust-like result type for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "result-0.17.0-py3-none-any.whl", hash = "sha256:49fd668b4951ad15800b8ccefd98b6b94effc789607e19c65064b775570933e8"}, + {file = "result-0.17.0.tar.gz", hash = "sha256:b73da420c0cb1a3bf741dbd41ff96dedafaad6a1b3ef437a9e33e380bb0d91cf"}, +] + +[package.dependencies] +typing-extensions = {version = "*", markers = "python_version < \"3.10\""} + [[package]] name = "rich" version = "13.3.5" @@ -991,4 +1005,4 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "0e99495157ecb1b44d8faea4dd1c3a0243e64900329ea526e2a860e5d9d53a08" +content-hash = "37545d46f8bfda3b0c34dcb8d990f8940cf81664dfabf0eeebda02e6bcbe4f0e" diff --git a/pyproject.toml b/pyproject.toml index ac5bfe5..7813963 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ pyyaml = ">=5.4" loguru = ">=0.7" typing-extensions = ">=4.6" greenlet = ">=3" +result = "^0.17.0" [tool.poetry.group.dev.dependencies] mypy = "^1.11" diff --git a/sqlalchemy_to_json_schema/command/driver.py b/sqlalchemy_to_json_schema/command/driver.py index 396edea..c82575b 100644 --- a/sqlalchemy_to_json_schema/command/driver.py +++ b/sqlalchemy_to_json_schema/command/driver.py @@ -7,6 +7,7 @@ from typing import Any, Callable, Optional, Union, cast import yaml +from result import Err, Ok, Result from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy_to_json_schema.command.transformer import ( @@ -57,7 +58,9 @@ def __init__(self, walker: Walker, decision: Decision, layout: Layout, /): def build_transformer( self, walker: Walker, decision: Decision, layout: Layout, / - ) -> Callable[[Iterable[Union[ModuleType, DeclarativeMeta]], Optional[int]], Schema]: + ) -> Callable[ + [Iterable[Union[ModuleType, DeclarativeMeta]], Optional[int]], Result[Schema, str] + ]: walker_factory = WALKER_MAP[walker] relation_decision = DECISION_MAP[decision]() schema_factory = SchemaFactory(walker_factory, relation_decision=relation_decision) @@ -73,7 +76,7 @@ def run( filename: Optional[Path] = None, format: Optional[Format] = None, depth: Optional[int] = None, - ) -> None: + ) -> Result[None, str]: modules_and_types = (load_module_or_symbol(target) for target in targets) modules_and_models = cast( Iterator[Union[ModuleType, DeclarativeMeta]], @@ -84,8 +87,14 @@ def run( ), ) - result = self.transformer(modules_and_models, depth) - self.dump(result, filename=filename, format=format) + schema = self.transformer(modules_and_models, depth) + + if schema.is_err(): + return Err(schema.unwrap_err()) + + self.dump(schema.unwrap(), filename=filename, format=format) + + return Ok(None) def dump( self, diff --git a/sqlalchemy_to_json_schema/command/transformer.py b/sqlalchemy_to_json_schema/command/transformer.py index 8157c72..a408fb4 100644 --- a/sqlalchemy_to_json_schema/command/transformer.py +++ b/sqlalchemy_to_json_schema/command/transformer.py @@ -5,6 +5,7 @@ from typing import Optional, Union from loguru import logger +from result import Err, Ok, Result from sqlalchemy.ext.declarative import DeclarativeMeta from typing_extensions import TypeGuard @@ -18,13 +19,13 @@ def __init__(self, schema_factory: SchemaFactory, /): @abstractmethod def transform( self, rawtargets: Iterable[Union[ModuleType, DeclarativeMeta]], depth: Optional[int], / - ) -> Schema: ... + ) -> Result[Schema, str]: ... class JSONSchemaTransformer(AbstractTransformer): def transform( self, rawtargets: Iterable[Union[ModuleType, DeclarativeMeta]], depth: Optional[int], / - ) -> Schema: + ) -> Result[Schema, str]: definitions = {} for item in rawtargets: @@ -33,33 +34,46 @@ def transform( elif inspect.ismodule(item): partial_definitions = self.transform_by_module(item, depth) else: - TypeError(f"Expected a class or module, got {item}") + return Err(f"Expected a class or module, got {item}") - definitions.update(partial_definitions) + if partial_definitions.is_err(): + return partial_definitions - return definitions + definitions.update(partial_definitions.unwrap()) - def transform_by_model(self, model: DeclarativeMeta, depth: Optional[int], /) -> Schema: + return Ok(definitions) + + def transform_by_model( + self, model: DeclarativeMeta, depth: Optional[int], / + ) -> Result[Schema, str]: return self.schema_factory(model, depth=depth) - def transform_by_module(self, module: ModuleType, depth: Optional[int], /) -> Schema: + def transform_by_module( + self, module: ModuleType, depth: Optional[int], / + ) -> Result[Schema, str]: subdefinitions = {} definitions = {} for basemodel in collect_models(module): - schema = self.schema_factory(basemodel, depth=depth) + schema_result = self.schema_factory(basemodel, depth=depth) + + if schema_result.is_err(): + return schema_result + + schema = schema_result.unwrap() + if "definitions" in schema: subdefinitions.update(schema.pop("definitions")) definitions[schema["title"]] = schema d = {} d.update(subdefinitions) d.update(definitions) - return {"definitions": definitions} + return Ok({"definitions": definitions}) class OpenAPI2Transformer(AbstractTransformer): def transform( self, rawtargets: Iterable[Union[ModuleType, DeclarativeMeta]], depth: Optional[int], / - ) -> Schema: + ) -> Result[Schema, str]: definitions = {} for target in rawtargets: @@ -68,29 +82,46 @@ def transform( elif inspect.ismodule(target): partial_definitions = self.transform_by_module(target, depth) else: - raise TypeError(f"Expected a class or module, got {target}") + return Err(f"Expected a class or module, got {target}") + + if partial_definitions.is_err(): + return partial_definitions - definitions.update(partial_definitions) + definitions.update(partial_definitions.unwrap()) - return {"definitions": definitions} + return Ok({"definitions": definitions}) - def transform_by_model(self, model: DeclarativeMeta, depth: Optional[int], /) -> Schema: + def transform_by_model( + self, model: DeclarativeMeta, depth: Optional[int], / + ) -> Result[Schema, str]: definitions = {} - schema = self.schema_factory(model, depth=depth) + schema_result = self.schema_factory(model, depth=depth) + + if schema_result.is_err(): + return schema_result + + schema = schema_result.unwrap() if "definitions" in schema: definitions.update(schema.pop("definitions")) definitions[schema["title"]] = schema - return definitions + return Ok(definitions) - def transform_by_module(self, module: ModuleType, depth: Optional[int], /) -> Schema: + def transform_by_module( + self, module: ModuleType, depth: Optional[int], / + ) -> Result[Schema, str]: subdefinitions = {} definitions = {} for basemodel in collect_models(module): - schema = self.schema_factory(basemodel, depth=depth) + schema_result = self.schema_factory(basemodel, depth=depth) + + if schema_result.is_err(): + return schema_result + + schema = schema_result.unwrap() if "definitions" in schema: subdefinitions.update(schema.pop("definitions")) @@ -101,7 +132,7 @@ def transform_by_module(self, module: ModuleType, depth: Optional[int], /) -> Sc d.update(subdefinitions) d.update(definitions) - return definitions + return Ok(definitions) class OpenAPI3Transformer(OpenAPI2Transformer): @@ -118,8 +149,13 @@ def replace_ref(self, d: Union[dict, list], old_prefix: str, new_prefix: str, /) def transform( self, rawtargets: Iterable[Union[ModuleType, DeclarativeMeta]], depth: Optional[int], / - ) -> Schema: - definitions = super().transform(rawtargets, depth) + ) -> Result[Schema, str]: + definitions_result = super().transform(rawtargets, depth) + + if definitions_result.is_err(): + return Err(definitions_result.unwrap_err()) + + definitions = definitions_result.unwrap() self.replace_ref(definitions, "#/definitions/", "#/components/schemas/") @@ -128,7 +164,8 @@ def transform( if "schemas" not in definitions["components"]: definitions["components"]["schemas"] = {} definitions["components"]["schemas"] = definitions.pop("definitions", {}) - return definitions + + return Ok(definitions) def collect_models(module: ModuleType, /) -> Iterator[DeclarativeMeta]: diff --git a/sqlalchemy_to_json_schema/decisions.py b/sqlalchemy_to_json_schema/decisions.py index 3a21b30..bc1c021 100644 --- a/sqlalchemy_to_json_schema/decisions.py +++ b/sqlalchemy_to_json_schema/decisions.py @@ -2,6 +2,7 @@ from collections.abc import Iterator from typing import Any, Union +from result import Err, Ok, Result from sqlalchemy.orm import MapperProperty from sqlalchemy.orm.base import MANYTOMANY, MANYTOONE from sqlalchemy.orm.properties import ColumnProperty @@ -24,7 +25,7 @@ def decision( /, *, toplevel: bool = False, - ) -> Iterator[DecisionResult]: + ) -> Iterator[Result[DecisionResult, MapperProperty]]: pass @@ -36,13 +37,13 @@ def decision( /, *, toplevel: bool = False, - ) -> Iterator[DecisionResult]: + ) -> Iterator[Result[DecisionResult, MapperProperty]]: if hasattr(prop, "mapper"): - yield ColumnPropertyType.RELATIONSHIP, prop, {} + yield Ok((ColumnPropertyType.RELATIONSHIP, prop, {})) elif hasattr(prop, "columns"): - yield ColumnPropertyType.FOREIGNKEY, prop, {} + yield Ok((ColumnPropertyType.FOREIGNKEY, prop, {})) else: - raise NotImplementedError(prop) + yield Err(prop) class UseForeignKeyIfPossibleDecision(AbstractDecision): @@ -53,32 +54,42 @@ def decision( /, *, toplevel: bool = False, - ) -> Iterator[DecisionResult]: + ) -> Iterator[Result[DecisionResult, MapperProperty]]: if hasattr(prop, "mapper"): if prop.direction == MANYTOONE: if toplevel: for c in prop.local_columns: - yield ColumnPropertyType.FOREIGNKEY, walker.mapper._props[c.name], { - "relation": prop.key - } + yield Ok( + ( + ColumnPropertyType.FOREIGNKEY, + walker.mapper._props[c.name], + {"relation": prop.key}, + ) + ) else: rp = walker.history[0] if prop.local_columns != rp.remote_side: for c in prop.local_columns: - yield ColumnPropertyType.FOREIGNKEY, walker.mapper._props[c.name], { - "relation": prop.key - } + yield Ok( + ( + ColumnPropertyType.FOREIGNKEY, + walker.mapper._props[c.name], + {"relation": prop.key}, + ) + ) elif prop.direction == MANYTOMANY: # logger.warning("skip mapper=%s, prop=%s is many to many.", walker.mapper, prop) # fixme: this must return a ColumnPropertyType member - yield ( - {"type": "array", "items": {"type": "string"}}, # type: ignore[misc] - prop, - {}, + yield Ok( + ( # type: ignore[arg-type] + {"type": "array", "items": {"type": "string"}}, + prop, + {}, + ) ) else: - yield ColumnPropertyType.RELATIONSHIP, prop, {} + yield Ok((ColumnPropertyType.RELATIONSHIP, prop, {})) elif hasattr(prop, "columns"): - yield ColumnPropertyType.FOREIGNKEY, prop, {} + yield Ok((ColumnPropertyType.FOREIGNKEY, prop, {})) else: - raise NotImplementedError(prop) + yield Err(prop) diff --git a/sqlalchemy_to_json_schema/schema_factory.py b/sqlalchemy_to_json_schema/schema_factory.py index 9c0b48f..6209405 100644 --- a/sqlalchemy_to_json_schema/schema_factory.py +++ b/sqlalchemy_to_json_schema/schema_factory.py @@ -26,6 +26,7 @@ from typing import Any, Callable import sqlalchemy.types as t +from result import Err, Ok, Result from sqlalchemy import Enum from sqlalchemy.dialects import postgresql as postgresql_types from sqlalchemy.ext.declarative import DeclarativeMeta @@ -36,7 +37,6 @@ from sqlalchemy.sql.visitors import Visitable from sqlalchemy_to_json_schema.decisions import AbstractDecision, RelationDecision -from sqlalchemy_to_json_schema.exceptions import InvalidStatus from sqlalchemy_to_json_schema.types import ColumnPropertyType from sqlalchemy_to_json_schema.walkers import AbstractWalker @@ -121,7 +121,7 @@ def __init__( self.see_mro = see_mro self.see_impl = see_impl - def __getitem__(self, k: TypeEngine, /) -> tuple[type[TypeEngine], str]: + def __getitem__(self, k: TypeEngine, /) -> tuple[type[TypeEngine], TypeFormatFn]: cls = k.__class__ _, mapped = get_class_mapping( @@ -132,9 +132,9 @@ def __getitem__(self, k: TypeEngine, /) -> tuple[type[TypeEngine], str]: ) if mapped is None: - raise InvalidStatus(f"notfound: {k}. (cls={cls})") + raise KeyError(f"notfound: {k}. (cls={cls})") - return cls, mapped # type: ignore[return-value] + return (cls, mapped) def get_class_mapping( @@ -269,8 +269,8 @@ def child_schema( *, depth: int | None = None, history: Any | None = None, - ) -> dict[str, Any]: - subschema = schema_factory._build_properties( + ) -> Result[dict[str, Any], str]: + subschema_result = schema_factory._build_properties( walker, root_schema, overrides, @@ -278,10 +278,16 @@ def child_schema( history=history, toplevel=False, ) + + if subschema_result.is_err(): + return subschema_result + + subschema = subschema_result.unwrap() + if prop.direction == ONETOMANY: - return {"type": "array", "items": subschema} + return Ok({"type": "array", "items": subschema}) else: - return {"type": "object", "properties": subschema} + return Ok({"type": "object", "properties": subschema}) class SchemaFactory: @@ -313,17 +319,20 @@ def __call__( overrides: dict | None = None, depth: int | None = None, adjust_required: Callable[[MapperProperty, bool], bool] | None = None, - ) -> Schema: + ) -> Result[Schema, str]: walker = self.walker(model, includes=includes, excludes=excludes) overrides_manager = CollectionForOverrides(overrides or {}) schema: dict[str, Any] = {"title": model.__name__, "type": "object"} - schema["properties"] = self._build_properties( - walker, schema, overrides_manager, depth=depth - ) + properties = self._build_properties(walker, schema, overrides_manager, depth=depth) + + if properties.is_err(): + return Err(properties.unwrap_err()) + + schema["properties"] = properties.unwrap() if overrides_manager.not_used_keys: - raise InvalidStatus(f"invalid overrides: {overrides_manager.not_used_keys}") + return Err(f"invalid overrides: {overrides_manager.not_used_keys}") if model.__doc__: schema["description"] = model.__doc__ @@ -332,7 +341,8 @@ def __call__( if required: schema["required"] = required - return schema + + return Ok(schema) def _add_items_if_array( self, data: dict[str, Any], column: NamedColumn, itype: type[TypeEngine], / @@ -398,19 +408,26 @@ def _build_properties( depth: int | None = None, history: list[MapperProperty] | None = None, toplevel: bool = True, - ) -> dict[str, Any]: + ) -> Result[dict[str, Any], str]: definitions: dict[str, Any] = {} if depth is not None and depth <= 0: - return definitions + return Ok(definitions) if history is None: history = [] for walked_prop in walker.walk(): - for action, prop, opts in self.relation_decision.decision( - walker, walked_prop, toplevel=toplevel - ): + decision_iter = self.relation_decision.decision(walker, walked_prop, toplevel=toplevel) + + for decision in decision_iter: + if decision.is_err(): + return Err( + f"decision error: unsupported mapped property {decision.unwrap_err()}" + ) + + action, prop, opts = decision.unwrap() + if action == ColumnPropertyType.RELATIONSHIP: # RelationshipProperty history.append(prop) subwalker = self.child_factory.child_walker(prop, walker, history=history) @@ -424,8 +441,12 @@ def _build_properties( depth=depth, history=history, ) + + if value.is_err(): + return value + self._add_property_with_reference( - walker, root_schema, definitions, prop, value + walker, root_schema, definitions, prop, value.unwrap() ) history.pop() elif action == ColumnPropertyType.FOREIGNKEY: # ColumnProperty @@ -440,9 +461,6 @@ def _build_properties( if column.doc: sub["description"] = column.doc - if overrides is None: - raise RuntimeError("overrides is None") - if column.name in overrides: overrides.overrides(sub) if opts: @@ -454,10 +472,10 @@ def _build_properties( definitions[column_name] = sub else: - raise NotImplementedError + return Err(f"unsupported column type: {type(column.type)}") else: # immediate definitions[prop.key] = action - return definitions + return Ok(definitions) def _detect_required( self, diff --git a/tests/command/test_transformer.py b/tests/command/test_transformer.py index 945bfb5..6595941 100644 --- a/tests/command/test_transformer.py +++ b/tests/command/test_transformer.py @@ -4,6 +4,7 @@ import pytest from pytest_unordered import unordered +from result import Ok from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy_to_json_schema.command.transformer import ( @@ -33,19 +34,21 @@ def test_transform_model(self, schema_factory: SchemaFactory) -> None: actual = transformer.transform([User], None) # Assert - assert actual == { - "definitions": {"Address": ANY, "Group": ANY}, - "properties": { - "address": {"$ref": "#/definitions/Address"}, - "created_at": {"format": "date-time", "type": "string"}, - "group": {"$ref": "#/definitions/Group"}, - "name": {"maxLength": 255, "type": "string"}, - "pk": {"description": "primary key", "type": "integer"}, - }, - "required": ["name", "pk"], - "title": "User", - "type": "object", - } + assert actual == Ok( + { + "definitions": {"Address": ANY, "Group": ANY}, + "properties": { + "address": {"$ref": "#/definitions/Address"}, + "created_at": {"format": "date-time", "type": "string"}, + "group": {"$ref": "#/definitions/Group"}, + "name": {"maxLength": 255, "type": "string"}, + "pk": {"description": "primary key", "type": "integer"}, + }, + "required": ["name", "pk"], + "title": "User", + "type": "object", + } + ) def test_transform_module(self, schema_factory: SchemaFactory) -> None: # Arrange @@ -55,28 +58,30 @@ def test_transform_module(self, schema_factory: SchemaFactory) -> None: actual = transformer.transform([models], None) # Assert - assert actual == { - "definitions": { - "Address": { - "properties": ANY, - "required": ["pk", "street", "town"], - "title": "Address", - "type": "object", - }, - "Group": { - "properties": ANY, - "required": ["name", "pk"], - "title": "Group", - "type": "object", - }, - "User": { - "properties": ANY, - "required": ["name", "pk"], - "title": "User", - "type": "object", + assert actual == Ok( + { + "definitions": { + "Address": { + "properties": ANY, + "required": ["pk", "street", "town"], + "title": "Address", + "type": "object", + }, + "Group": { + "properties": ANY, + "required": ["name", "pk"], + "title": "Group", + "type": "object", + }, + "User": { + "properties": ANY, + "required": ["name", "pk"], + "title": "User", + "type": "object", + }, }, - }, - } + } + ) class TestCollectModels: @@ -111,47 +116,49 @@ def test_transform_model(self, schema_factory: SchemaFactory) -> None: actual = transformer.transform([User], None) # Assert - assert actual == { - "components": { - "schemas": { - "User": { - "properties": { - "address": {"$ref": "#/components/schemas/Address"}, - "created_at": {"format": "date-time", "type": "string"}, - "group": {"$ref": "#/components/schemas/Group"}, - "name": {"maxLength": 255, "type": "string"}, - "pk": {"description": "primary key", "type": "integer"}, + assert actual == Ok( + { + "components": { + "schemas": { + "User": { + "properties": { + "address": {"$ref": "#/components/schemas/Address"}, + "created_at": {"format": "date-time", "type": "string"}, + "group": {"$ref": "#/components/schemas/Group"}, + "name": {"maxLength": 255, "type": "string"}, + "pk": {"description": "primary key", "type": "integer"}, + }, + "required": ["name", "pk"], + "title": "User", + "type": "object", }, - "required": ["name", "pk"], - "title": "User", - "type": "object", - }, - "Address": { - "properties": { - "pk": {"description": "primary key", "type": "integer"}, - "street": {"maxLength": 255, "type": "string"}, - "town": {"maxLength": 255, "type": "string"}, + "Address": { + "properties": { + "pk": {"description": "primary key", "type": "integer"}, + "street": {"maxLength": 255, "type": "string"}, + "town": {"maxLength": 255, "type": "string"}, + }, + "required": ["pk", "street", "town"], + "type": "object", }, - "required": ["pk", "street", "town"], - "type": "object", - }, - "Group": { - "properties": { - "color": { - "enum": ["red", "green", "yellow", "blue"], - "maxLength": 6, - "type": "string", + "Group": { + "properties": { + "color": { + "enum": ["red", "green", "yellow", "blue"], + "maxLength": 6, + "type": "string", + }, + "created_at": {"format": "date-time", "type": "string"}, + "name": {"maxLength": 255, "type": "string"}, + "pk": {"description": "primary key", "type": "integer"}, }, - "created_at": {"format": "date-time", "type": "string"}, - "name": {"maxLength": 255, "type": "string"}, - "pk": {"description": "primary key", "type": "integer"}, + "required": ["name", "pk"], + "type": "object", }, - "required": ["name", "pk"], - "type": "object", - }, + } } } - } + ) def test_transform_module(self, schema_factory: SchemaFactory) -> None: # Arrange @@ -161,54 +168,56 @@ def test_transform_module(self, schema_factory: SchemaFactory) -> None: actual = transformer.transform([models], None) # Assert - assert actual == { - "components": { - "schemas": { - "User": { - "properties": { - "address": {"$ref": "#/components/schemas/Address"}, - "created_at": {"format": "date-time", "type": "string"}, - "group": {"$ref": "#/components/schemas/Group"}, - "name": {"maxLength": 255, "type": "string"}, - "pk": {"description": "primary key", "type": "integer"}, - }, - "required": ["name", "pk"], - "title": "User", - "type": "object", - }, - "Address": { - "properties": { - "pk": {"description": "primary key", "type": "integer"}, - "street": {"maxLength": 255, "type": "string"}, - "town": {"maxLength": 255, "type": "string"}, - "users": { - "items": {"$ref": "#/components/schemas/User"}, - "type": "array", + assert actual == Ok( + { + "components": { + "schemas": { + "User": { + "properties": { + "address": {"$ref": "#/components/schemas/Address"}, + "created_at": {"format": "date-time", "type": "string"}, + "group": {"$ref": "#/components/schemas/Group"}, + "name": {"maxLength": 255, "type": "string"}, + "pk": {"description": "primary key", "type": "integer"}, }, + "required": ["name", "pk"], + "title": "User", + "type": "object", }, - "required": ["pk", "street", "town"], - "title": "Address", - "type": "object", - }, - "Group": { - "properties": { - "color": { - "enum": ["red", "green", "yellow", "blue"], - "maxLength": 6, - "type": "string", + "Address": { + "properties": { + "pk": {"description": "primary key", "type": "integer"}, + "street": {"maxLength": 255, "type": "string"}, + "town": {"maxLength": 255, "type": "string"}, + "users": { + "items": {"$ref": "#/components/schemas/User"}, + "type": "array", + }, }, - "created_at": {"format": "date-time", "type": "string"}, - "name": {"maxLength": 255, "type": "string"}, - "pk": {"description": "primary key", "type": "integer"}, - "users": { - "items": {"$ref": "#/components/schemas/User"}, - "type": "array", + "required": ["pk", "street", "town"], + "title": "Address", + "type": "object", + }, + "Group": { + "properties": { + "color": { + "enum": ["red", "green", "yellow", "blue"], + "maxLength": 6, + "type": "string", + }, + "created_at": {"format": "date-time", "type": "string"}, + "name": {"maxLength": 255, "type": "string"}, + "pk": {"description": "primary key", "type": "integer"}, + "users": { + "items": {"$ref": "#/components/schemas/User"}, + "type": "array", + }, }, + "required": ["name", "pk"], + "title": "Group", + "type": "object", }, - "required": ["name", "pk"], - "title": "Group", - "type": "object", - }, + } } } - } + ) diff --git a/tests/regression/test_generate_schema_with_type_decorator.py b/tests/regression/test_generate_schema_with_type_decorator.py index a090c46..8b7c234 100644 --- a/tests/regression/test_generate_schema_with_type_decorator.py +++ b/tests/regression/test_generate_schema_with_type_decorator.py @@ -2,6 +2,7 @@ from typing import Any, Union import sqlalchemy as sa +from result import Result, is_ok from sqlalchemy import TypeDecorator from sqlalchemy.orm import DeclarativeMeta, declarative_base from sqlalchemy.sql.type_api import TypeEngine @@ -11,13 +12,13 @@ from sqlalchemy_to_json_schema.walkers import StructuralWalker -def _callFUT(model: DeclarativeMeta, /) -> Schema: +def _callFUT(model: DeclarativeMeta, /) -> Result[Schema, str]: # see: https://github.com/expobrain/sqlalchemy_to_json_schema/issues/6 factory = SchemaFactory(StructuralWalker) - schema = factory(model) + schema_result = factory(model) - return schema + return schema_result def _makeType(impl_: Union[type[TypeEngine], TypeEngine]) -> type[TypeDecorator]: @@ -48,7 +49,9 @@ class Hascolor(Base): color: sa.Column[str] = sa.Column(Choice(choices=candidates, length=1), nullable=False) result = _callFUT(Hascolor) - assert result["properties"]["color"] == {"type": "string", "maxLength": 1} + + assert is_ok(result) + assert result.value["properties"]["color"] == {"type": "string", "maxLength": 1} def test_it__impl_is_not_callable() -> None: @@ -62,4 +65,6 @@ class Hascolor(Base): color: sa.Column[str] = sa.Column(Choice(choices=candidates), nullable=False) result = _callFUT(Hascolor) - assert result["properties"]["color"] == {"type": "string", "maxLength": 1} + + assert is_ok(result) + assert result.unwrap()["properties"]["color"] == {"type": "string", "maxLength": 1} diff --git a/tests/test_it.py b/tests/test_it.py index 9cffa54..4174120 100644 --- a/tests/test_it.py +++ b/tests/test_it.py @@ -42,7 +42,7 @@ class AnotherUser(Base): def test_it_create_schema__and__valid_params__sucess() -> None: target = _makeOne() - schema = target(Group, excludes=["pk", "users.pk"]) + schema = target(Group, excludes=["pk", "users.pk"]).unwrap() data = { "name": "ravenclaw", "color": "blue", @@ -54,7 +54,7 @@ def test_it_create_schema__and__valid_params__sucess() -> None: def test_it_create_schema__and__invalid_params__failure() -> None: target = _makeOne() - schema = target(Group, excludes=["pk", "uesrs.pk"]) + schema = target(Group, excludes=["pk", "uesrs.pk"]).unwrap() data = { "name": "blackmage", "color": "black", @@ -67,7 +67,7 @@ def test_it_create_schema__and__invalid_params__failure() -> None: def test_it2_create_schema__and__valid_params__success() -> None: target = _makeOne() - schema = target(User, excludes=["pk", "group_id"]) + schema = target(User, excludes=["pk", "group_id"]).unwrap() data = {"name": "foo", "group": {"name": "ravenclaw", "color": "blue", "pk": 1}} validate(data, schema) diff --git a/tests/test_it_reflection.py b/tests/test_it_reflection.py index 1ca232c..3161f27 100644 --- a/tests/test_it_reflection.py +++ b/tests/test_it_reflection.py @@ -1,6 +1,7 @@ from pathlib import Path import pytest +from result import Ok from sqlalchemy import create_engine from sqlalchemy.ext.automap import AutomapBase, automap_base @@ -43,7 +44,7 @@ def test_it(db: AutomapBase) -> None: "type": "object", "required": ["artistid", "artistname"], } - assert schema == expected + assert schema == Ok(expected) def test_it2(db: AutomapBase) -> None: @@ -66,4 +67,4 @@ def test_it2(db: AutomapBase) -> None: "type": "object", "required": ["trackid"], } - assert schema == expected + assert schema == Ok(expected) diff --git a/tests/test_relation.py b/tests/test_relation.py index 30d7dac..ac8fe9d 100644 --- a/tests/test_relation.py +++ b/tests/test_relation.py @@ -6,6 +6,7 @@ import sqlalchemy as sa import sqlalchemy.orm as orm from pytest_unordered import unordered +from result import is_ok from sqlalchemy.orm import Mapped, declarative_base from sqlalchemy_to_json_schema.decisions import UseForeignKeyIfPossibleDecision @@ -56,39 +57,43 @@ def test_properties__default__includes__foreign_keys() -> None: target = _makeOne(ForeignKeyWalker) result = target(User) - assert "properties" in result - assert list(result["properties"].keys()) == unordered(["group_id", "name", "pk"]) + assert is_ok(result) + assert "properties" in result.value + assert list(result.value["properties"].keys()) == unordered(["group_id", "name", "pk"]) def test_properties__include_OnetoMany_relation() -> None: target = _makeOne(StructuralWalker, relation_decision=RelationDecision()) result = target(User) - assert "required" in result - assert list(result["properties"]) == unordered(["group", "name", "pk"]) - assert result["properties"]["group"] == {"$ref": "#/definitions/Group"} + assert is_ok(result) + assert "required" in result.value + assert list(result.value["properties"]) == unordered(["group", "name", "pk"]) + assert result.value["properties"]["group"] == {"$ref": "#/definitions/Group"} def test_properties__include_OnetoMany_relation2() -> None: target = _makeOne(StructuralWalker, relation_decision=UseForeignKeyIfPossibleDecision()) result = target(User) - assert "required" in result - assert list(result["properties"]) == unordered(["group_id", "name", "pk"]) - assert result["properties"]["group_id"] == {"type": "integer", "relation": "group"} + assert is_ok(result) + assert "required" in result.value + assert list(result.value["properties"]) == unordered(["group_id", "name", "pk"]) + assert result.value["properties"]["group_id"] == {"type": "integer", "relation": "group"} def test_properties__include_ManytoOne_backref() -> None: target = _makeOne(StructuralWalker) result = target(Group) - assert "required" in result - assert list(result["properties"]) == unordered(["name", "pk", "users"]) - assert result["properties"]["users"] == { + assert is_ok(result) + assert "required" in result.value + assert list(result.value["properties"]) == unordered(["name", "pk", "users"]) + assert result.value["properties"]["users"] == { "type": "array", "items": {"$ref": "#/definitions/User"}, } - assert result["definitions"]["User"] == { + assert result.value["definitions"]["User"] == { "type": "object", "required": ["pk"], "properties": { @@ -149,13 +154,14 @@ def test_properties__default_depth_is__traverse_all_chlidren() -> None: target = _makeOne(StructuralWalker) result = target(A0) - assert "required" in result - assert list(result["properties"]) == unordered(["children", "pk"]) - children0 = get_reference(result["properties"]["children"]["items"], result) - children1 = get_reference(children0["properties"]["children"]["items"], result) - children2 = get_reference(children1["properties"]["children"]["items"], result) - children3 = get_reference(children2["properties"]["children"]["items"], result) - children4 = get_reference(children3["properties"]["children"]["items"], result) + assert is_ok(result) + assert "required" in result.value + assert list(result.value["properties"]) == unordered(["children", "pk"]) + children0 = get_reference(result.value["properties"]["children"]["items"], result.value) + children1 = get_reference(children0["properties"]["children"]["items"], result.value) + children2 = get_reference(children1["properties"]["children"]["items"], result.value) + children3 = get_reference(children2["properties"]["children"]["items"], result.value) + children4 = get_reference(children3["properties"]["children"]["items"], result.value) assert children4["properties"]["pk"]["description"] == "primary key5" @@ -163,9 +169,10 @@ def test_properties__default_depth_is__2__traverse_depth2() -> None: target = _makeOne(StructuralWalker) result = target(A0, depth=2) - assert "required" in result - assert list(result["properties"]) == unordered(["children", "pk"]) - children0 = get_reference(result["properties"]["children"]["items"], result) + assert is_ok(result) + assert "required" in result.value + assert list(result.value["properties"]) == unordered(["children", "pk"]) + children0 = get_reference(result.value["properties"]["children"]["items"], result.value) assert children0["properties"]["pk"]["description"] == "primary key1" @@ -173,10 +180,11 @@ def test_properties__default_depth_is__3__traverse_depth3() -> None: target = _makeOne(StructuralWalker) result = target(A0, depth=3) - assert "required" in result - assert list(result["properties"]) == unordered(["children", "pk"]) - children0 = get_reference(result["properties"]["children"]["items"], result) - children1 = get_reference(children0["properties"]["children"]["items"], result) + assert is_ok(result) + assert "required" in result.value + assert list(result.value["properties"]) == unordered(["children", "pk"]) + children0 = get_reference(result.value["properties"]["children"]["items"], result.value) + children1 = get_reference(children0["properties"]["children"]["items"], result.value) assert children1["properties"]["pk"]["description"] == "primary key2" @@ -208,18 +216,22 @@ class Z(Base): def test_properties__infinite_loop() -> None: target = _makeOne(StructuralWalker, relation_decision=RelationDecision()) result = target(X) - ys = result["properties"]["ys"] - zs = get_reference(ys, result)["properties"]["zs"] - xs = get_reference(zs, result)["properties"] - assert "required" in result - assert list(result["properties"]) == unordered(["id", "ys"]) + + assert is_ok(result) + + ys = result.value["properties"]["ys"] + zs = get_reference(ys, result.value)["properties"]["zs"] + xs = get_reference(zs, result.value)["properties"] + assert "required" in result.value + assert list(result.value["properties"]) == unordered(["id", "ys"]) assert xs["id"]["description"] == "primary key" def test_properties__infinite_loop2() -> None: target = _makeOne(StructuralWalker, relation_decision=UseForeignKeyIfPossibleDecision()) result = target(X) - assert "required" in result - assert list(result["properties"]) == unordered(["id", "y_id"]) - assert result["properties"]["y_id"] == {"type": "integer", "relation": "ys"} + assert is_ok(result) + assert "required" in result.value + assert list(result.value["properties"]) == unordered(["id", "y_id"]) + assert result.value["properties"]["y_id"] == {"type": "integer", "relation": "ys"} diff --git a/tests/test_schema_factory.py b/tests/test_schema_factory.py index 41f81cc..e82968e 100644 --- a/tests/test_schema_factory.py +++ b/tests/test_schema_factory.py @@ -4,6 +4,7 @@ import pytest import sqlalchemy as sa +from result import Ok from sqlalchemy import BigInteger, FetchedValue, Integer, String, func from sqlalchemy.dialects import postgresql from sqlalchemy.ext.hybrid import hybrid_property @@ -126,15 +127,17 @@ class Model(Base): actual = target(Model) # assert - assert actual == { - "properties": { - "pk": {"type": "integer"}, - "concatenable": {"type": "array", "items": {"type": expected_type}}, - }, - "required": ["pk"], - "title": "Model", - "type": "object", - } + assert actual == Ok( + { + "properties": { + "pk": {"type": "integer"}, + "concatenable": {"type": "array", "items": {"type": expected_type}}, + }, + "required": ["pk"], + "title": "Model", + "type": "object", + } + ) @pytest.mark.parametrize("walker_cls", WALKER_CLASSES) def test_column_postgres_uuid(self, walker_cls: type[AbstractWalker]) -> None: @@ -147,12 +150,14 @@ class Model(Base): actual = schema_factory(Model) - assert actual == { - "properties": {"id": {"type": "string", "format": "uuid"}}, - "required": ["id"], - "title": "Model", - "type": "object", - } + assert actual == Ok( + { + "properties": {"id": {"type": "string", "format": "uuid"}}, + "required": ["id"], + "title": "Model", + "type": "object", + } + ) @pytest.mark.parametrize("walker_cls", WALKER_CLASSES) def test_column_json(self, walker_cls: type[AbstractWalker]) -> None: @@ -166,15 +171,17 @@ class Model(Base): actual = schema_factory(Model) - assert actual == { - "properties": { - "id": {"type": "integer"}, - "data": {"type": "object"}, - }, - "required": ["id"], - "title": "Model", - "type": "object", - } + assert actual == Ok( + { + "properties": { + "id": {"type": "integer"}, + "data": {"type": "object"}, + }, + "required": ["id"], + "title": "Model", + "type": "object", + } + ) @pytest.mark.parametrize("walker_cls", WALKER_CLASSES) def test_hybrid_property(self, walker_cls: type[AbstractWalker]) -> None: @@ -195,12 +202,14 @@ def id(self, id_: int) -> None: actual = schema_factory(Model) - assert actual == { - "properties": {"id": {"type": "integer"}}, - "required": ["id"], - "title": "Model", - "type": "object", - } + assert actual == Ok( + { + "properties": {"id": {"type": "integer"}}, + "required": ["id"], + "title": "Model", + "type": "object", + } + ) @pytest.mark.parametrize("walker_cls", WALKER_CLASSES) def test_hybrid_property_with_mixin(self, walker_cls: type[AbstractWalker]) -> None: @@ -222,12 +231,14 @@ class Model(Base, IdMixin): actual = schema_factory(Model) - assert actual == { - "properties": {"id": {"type": "integer"}}, - "required": ["id"], - "title": "Model", - "type": "object", - } + assert actual == Ok( + { + "properties": {"id": {"type": "integer"}}, + "required": ["id"], + "title": "Model", + "type": "object", + } + ) @pytest.mark.parametrize( "walker_cls, expected", @@ -305,7 +316,7 @@ def other_model_id(self, other_model_id: int) -> None: actual = schema_factory(ModelWithRelationship) - assert actual == expected + assert actual == Ok(expected) @pytest.mark.parametrize("walker_cls", WALKER_CLASSES) @pytest.mark.parametrize( @@ -351,9 +362,11 @@ class Model(Base): actual = target(Model) # assert - assert actual == { - "properties": {"pk": {"type": expected_type}}, - "required": ["pk"], - "title": "Model", - "type": "object", - } + assert actual == Ok( + { + "properties": {"pk": {"type": expected_type}}, + "required": ["pk"], + "title": "Model", + "type": "object", + } + ) diff --git a/tests/test_walker.py b/tests/test_walker.py index 3dd78dc..cd434f1 100644 --- a/tests/test_walker.py +++ b/tests/test_walker.py @@ -2,6 +2,7 @@ import sqlalchemy as sa import sqlalchemy.orm as orm from pytest_unordered import unordered +from result import Err, is_ok from sqlalchemy.orm import declarative_base from sqlalchemy_to_json_schema.exceptions import InvalidStatus @@ -39,39 +40,44 @@ def test_type__is_object() -> None: target = _makeOne() result = target(Group) - assert "type" in result - assert result["type"] == "object" + assert is_ok(result) + assert "type" in result.unwrap() + assert result.unwrap()["type"] == "object" def test_properties__are__all_of_columns() -> None: target = _makeOne() result = target(Group) - assert "properties" in result - assert list(result["properties"].keys()) == unordered(["color", "name", "pk"]) + assert is_ok(result) + assert "properties" in result.unwrap() + assert list(result.unwrap()["properties"].keys()) == unordered(["color", "name", "pk"]) def test_title__id__model_class_name() -> None: target = _makeOne() result = target(Group) - assert "title" in result - assert result["title"] == Group.__name__ + assert is_ok(result) + assert "title" in result.unwrap() + assert result.unwrap()["title"] == Group.__name__ def test_description__is__docstring_of_model() -> None: target = _makeOne() result = target(Group) - assert "description" in result - assert result["description"] == Group.__doc__ + assert is_ok(result) + assert "description" in result.unwrap() + assert result.unwrap()["description"] == Group.__doc__ def test_properties__all__this_is_slackoff_little_bit__all_is_all() -> None: # hmm. target = _makeOne() result = target(Group) - assert result["properties"] == { + assert is_ok(result) + assert result.unwrap()["properties"] == { "color": { "maxLength": 6, "enum": ["red", "green", "yellow", "blue"], @@ -88,13 +94,17 @@ def test_properties__all__this_is_slackoff_little_bit__all_is_all() -> None: # def test__filtering_by__includes() -> None: target = _makeOne() result = target(Group, includes=["pk"]) - assert list(result["properties"].keys()) == unordered(["pk"]) + + assert is_ok(result) + assert list(result.unwrap()["properties"].keys()) == unordered(["pk"]) def test__filtering_by__excludes() -> None: target = _makeOne() result = target(Group, excludes=["pk"]) - assert list(result["properties"].keys()) == unordered(["color", "name"]) + + assert is_ok(result) + assert list(result.unwrap()["properties"].keys()) == unordered(["color", "name"]) def test__filtering_by__excludes_and_includes__conflict() -> None: @@ -112,7 +122,8 @@ def test__overrides__add() -> None: overrides = {"name": {"maxLength": 100}} result = target(Group, includes=["name"], overrides=overrides) - assert result["properties"] == {"name": {"maxLength": 100, "type": "string"}} + assert is_ok(result) + assert result.unwrap()["properties"] == {"name": {"maxLength": 100, "type": "string"}} @pytest.mark.skip("to be fixed") @@ -121,11 +132,14 @@ def test__overrides__pop() -> None: overrides = {"name": {"maxLength": pop_marker}} result = target(Group, includes=["name"], overrides=overrides) - assert result["properties"] == {"name": {"type": "string"}} + assert is_ok(result) + assert result.unwrap()["properties"] == {"name": {"type": "string"}} def test__overrides__wrong_column() -> None: target = _makeOne() overrides = {"*missing-field*": {"maxLength": 100}} - with pytest.raises(InvalidStatus): - target(Group, includes=["name"], overrides=overrides) + + actual = target(Group, includes=["name"], overrides=overrides) + + assert actual == Err(f"invalid overrides: {set(overrides.keys())}")