tigrbl-tests 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tests/__init__.py +0 -0
  2. tests/conftest.py +285 -0
  3. tests/i9n/__init__.py +0 -0
  4. tests/i9n/test_acronym_route_name.py +16 -0
  5. tests/i9n/test_allow_anon.py +239 -0
  6. tests/i9n/test_apikey_generation.py +47 -0
  7. tests/i9n/test_authn_provider_integration.py +67 -0
  8. tests/i9n/test_bindings_integration.py +108 -0
  9. tests/i9n/test_bindings_modules.py +149 -0
  10. tests/i9n/test_bulk_docs_client.py +99 -0
  11. tests/i9n/test_core_access.py +164 -0
  12. tests/i9n/test_error_mappings.py +360 -0
  13. tests/i9n/test_field_spec_effects.py +117 -0
  14. tests/i9n/test_header_io_uvicorn.py +71 -0
  15. tests/i9n/test_healthz_methodz_hookz.py +203 -0
  16. tests/i9n/test_hook_ctx_v3_i9n.py +392 -0
  17. tests/i9n/test_hook_lifecycle.py +452 -0
  18. tests/i9n/test_iospec_attributes.py +368 -0
  19. tests/i9n/test_iospec_integration.py +181 -0
  20. tests/i9n/test_key_digest_uvicorn.py +151 -0
  21. tests/i9n/test_list_filters_optional.py +20 -0
  22. tests/i9n/test_mixins.py +534 -0
  23. tests/i9n/test_nested_path_schema_and_rpc.py +34 -0
  24. tests/i9n/test_nested_routing_depth.py +118 -0
  25. tests/i9n/test_op_ctx_alias_examples.py +62 -0
  26. tests/i9n/test_op_ctx_behavior.py +395 -0
  27. tests/i9n/test_op_ctx_core_crud_order.py +219 -0
  28. tests/i9n/test_openapi_clear_response_schema.py +35 -0
  29. tests/i9n/test_openapi_schema_examples_presence.py +81 -0
  30. tests/i9n/test_opspec_effects_i9n_test.py +193 -0
  31. tests/i9n/test_owner_tenant_policy.py +173 -0
  32. tests/i9n/test_request_extras.py +74 -0
  33. tests/i9n/test_request_extras_provider.py +27 -0
  34. tests/i9n/test_response_extras_provider.py +22 -0
  35. tests/i9n/test_rest_fallback_serialization.py +66 -0
  36. tests/i9n/test_rest_row_serialization.py +81 -0
  37. tests/i9n/test_rest_rpc_parity_v3.py +0 -0
  38. tests/i9n/test_row_result_serialization.py +84 -0
  39. tests/i9n/test_schema.py +45 -0
  40. tests/i9n/test_schema_ctx_attributes_integration.py +178 -0
  41. tests/i9n/test_schema_ctx_op_ctx_integration.py +88 -0
  42. tests/i9n/test_schema_ctx_spec_integration.py +209 -0
  43. tests/i9n/test_sqlite_attachments.py +36 -0
  44. tests/i9n/test_storage_spec_integration.py +126 -0
  45. tests/i9n/test_symmetry_parity.py +26 -0
  46. tests/i9n/test_v3_bulk_rest_endpoints.py +120 -0
  47. tests/i9n/test_v3_default_rest_ops.py +145 -0
  48. tests/i9n/test_v3_default_rpc_ops.py +234 -0
  49. tests/i9n/test_v3_opspec_attributes.py +272 -0
  50. tests/i9n/test_verb_alias_policy.py +57 -0
  51. tests/i9n/uvicorn_utils.py +43 -0
  52. tests/perf/__init__.py +0 -0
  53. tests/perf/test_collect_caching.py +42 -0
  54. tests/perf/test_hookz_performance.py +89 -0
  55. tests/perf/test_methodz_performance.py +99 -0
  56. tests/unit/__init__.py +0 -0
  57. tests/unit/decorators/test_alias_ctx_bindings.py +34 -0
  58. tests/unit/decorators/test_engine_ctx_bindings.py +57 -0
  59. tests/unit/decorators/test_hook_ctx_bindings.py +53 -0
  60. tests/unit/decorators/test_op_alias_bindings.py +39 -0
  61. tests/unit/decorators/test_op_ctx_bindings.py +82 -0
  62. tests/unit/decorators/test_response_ctx_bindings.py +32 -0
  63. tests/unit/decorators/test_schema_ctx_bindings.py +39 -0
  64. tests/unit/response_utils.py +142 -0
  65. tests/unit/runtime/atoms/test_emit_paired_post.py +27 -0
  66. tests/unit/runtime/atoms/test_emit_paired_pre.py +38 -0
  67. tests/unit/runtime/atoms/test_emit_readtime_alias.py +41 -0
  68. tests/unit/runtime/atoms/test_out_masking.py +76 -0
  69. tests/unit/runtime/atoms/test_refresh_demand.py +43 -0
  70. tests/unit/runtime/atoms/test_resolve_assemble.py +45 -0
  71. tests/unit/runtime/atoms/test_resolve_paired_gen.py +65 -0
  72. tests/unit/runtime/atoms/test_schema_collect_in.py +44 -0
  73. tests/unit/runtime/atoms/test_schema_collect_out.py +43 -0
  74. tests/unit/runtime/atoms/test_storage_to_stored.py +45 -0
  75. tests/unit/runtime/atoms/test_wire_build_in.py +13 -0
  76. tests/unit/runtime/atoms/test_wire_build_out.py +40 -0
  77. tests/unit/runtime/atoms/test_wire_dump.py +14 -0
  78. tests/unit/runtime/atoms/test_wire_validate_in.py +69 -0
  79. tests/unit/runtime/test_events_phases.py +14 -0
  80. tests/unit/test_acol_vcol_knobs.py +96 -0
  81. tests/unit/test_alias_ctx_op_alias_attributes.py +75 -0
  82. tests/unit/test_alias_ctx_op_attributes.py +61 -0
  83. tests/unit/test_api_level_set_auth.py +25 -0
  84. tests/unit/test_app_model_defaults.py +28 -0
  85. tests/unit/test_app_reexport.py +6 -0
  86. tests/unit/test_base_facade_initialize.py +71 -0
  87. tests/unit/test_build_list_params_spec_model.py +33 -0
  88. tests/unit/test_bulk_body_annotation.py +23 -0
  89. tests/unit/test_bulk_response_schema.py +153 -0
  90. tests/unit/test_colspec_map_isolation.py +19 -0
  91. tests/unit/test_column_collect_mixins.py +21 -0
  92. tests/unit/test_column_rest_rpc_results.py +298 -0
  93. tests/unit/test_column_table_orm_binding.py +51 -0
  94. tests/unit/test_config_dataclass_none.py +12 -0
  95. tests/unit/test_core_crud_bulk_ops.py +160 -0
  96. tests/unit/test_core_crud_default_ops.py +174 -0
  97. tests/unit/test_core_crud_methods.py +337 -0
  98. tests/unit/test_core_wrap_memoization.py +67 -0
  99. tests/unit/test_db_dependency.py +19 -0
  100. tests/unit/test_decorator_and_collect.py +47 -0
  101. tests/unit/test_default_tags.py +20 -0
  102. tests/unit/test_engine_spec_and_shortcuts.py +84 -0
  103. tests/unit/test_engine_usage_levels.py +36 -0
  104. tests/unit/test_field_spec_attrs.py +95 -0
  105. tests/unit/test_file_response.py +148 -0
  106. tests/unit/test_handler_step_qualname.py +30 -0
  107. tests/unit/test_hook_ctx_attributes.py +33 -0
  108. tests/unit/test_hook_ctx_binding.py +55 -0
  109. tests/unit/test_hookz_empty_phase.py +37 -0
  110. tests/unit/test_hybrid_session_run_sync.py +18 -0
  111. tests/unit/test_in_tx.py +16 -0
  112. tests/unit/test_include_models_base_prefix.py +29 -0
  113. tests/unit/test_initialize_cross_ddl.py +26 -0
  114. tests/unit/test_io_spec_attributes.py +171 -0
  115. tests/unit/test_iospec_attributes.py +90 -0
  116. tests/unit/test_iospec_effects.py +173 -0
  117. tests/unit/test_jsonrpc_id_example.py +9 -0
  118. tests/unit/test_jsonrpc_router_default_tag.py +10 -0
  119. tests/unit/test_kernel_invoke_ctx.py +14 -0
  120. tests/unit/test_kernel_opview_on_demand.py +42 -0
  121. tests/unit/test_kernel_plan_labels.py +40 -0
  122. tests/unit/test_kernelz_endpoint.py +65 -0
  123. tests/unit/test_make_column_shortcuts.py +80 -0
  124. tests/unit/test_mixins_sqlalchemy.py +13 -0
  125. tests/unit/test_op_alias.py +70 -0
  126. tests/unit/test_op_class_engine_binding.py +38 -0
  127. tests/unit/test_op_ctx_arity_paths.py +83 -0
  128. tests/unit/test_op_ctx_attributes.py +147 -0
  129. tests/unit/test_op_ctx_core_crud_integration.py +376 -0
  130. tests/unit/test_op_ctx_dynamic_attach.py +19 -0
  131. tests/unit/test_op_ctx_persist_options.py +75 -0
  132. tests/unit/test_opspec_effects.py +153 -0
  133. tests/unit/test_postgres_engine_errors.py +17 -0
  134. tests/unit/test_postgres_env_vars.py +17 -0
  135. tests/unit/test_relationship_alias_cols.py +98 -0
  136. tests/unit/test_request_body_schema.py +54 -0
  137. tests/unit/test_request_response_examples.py +169 -0
  138. tests/unit/test_resolver_precedence.py +49 -0
  139. tests/unit/test_response_alias_table_rpc.py +40 -0
  140. tests/unit/test_response_ctx_precedence.py +62 -0
  141. tests/unit/test_response_diagnostics_kernelz.py +81 -0
  142. tests/unit/test_response_html_jinja_behavior.py +116 -0
  143. tests/unit/test_response_parity.py +20 -0
  144. tests/unit/test_response_rest.py +91 -0
  145. tests/unit/test_response_rpc.py +88 -0
  146. tests/unit/test_response_template.py +30 -0
  147. tests/unit/test_response_uuid.py +58 -0
  148. tests/unit/test_rest_all_default_op_verbs.py +58 -0
  149. tests/unit/test_rest_bulk_delete_suppresses_clear.py +25 -0
  150. tests/unit/test_rest_no_schema_jsonable.py +68 -0
  151. tests/unit/test_rest_operation_id_uniqueness.py +36 -0
  152. tests/unit/test_rest_rpc_parity_default_ops.py +86 -0
  153. tests/unit/test_rest_rpc_prefixes.py +40 -0
  154. tests/unit/test_rest_rpc_symmetry.py +151 -0
  155. tests/unit/test_rpc_all_default_op_verbs.py +224 -0
  156. tests/unit/test_rpc_default_ops.py +111 -0
  157. tests/unit/test_schema_ctx_attributes.py +96 -0
  158. tests/unit/test_schema_ctx_plain_class.py +34 -0
  159. tests/unit/test_schema_spec_presence.py +36 -0
  160. tests/unit/test_schemas_binding.py +29 -0
  161. tests/unit/test_security_per_route.py +43 -0
  162. tests/unit/test_should_wire_canonical.py +62 -0
  163. tests/unit/test_spec_api.py +50 -0
  164. tests/unit/test_spec_app.py +28 -0
  165. tests/unit/test_spec_column.py +24 -0
  166. tests/unit/test_spec_engine.py +61 -0
  167. tests/unit/test_spec_field.py +16 -0
  168. tests/unit/test_spec_hook.py +35 -0
  169. tests/unit/test_spec_io.py +29 -0
  170. tests/unit/test_spec_op.py +31 -0
  171. tests/unit/test_spec_storage.py +21 -0
  172. tests/unit/test_spec_table.py +21 -0
  173. tests/unit/test_sqlite_attachments.py +57 -0
  174. tests/unit/test_storage_spec_attributes.py +78 -0
  175. tests/unit/test_sys_handler_crud.py +86 -0
  176. tests/unit/test_sys_run_rollback.py +42 -0
  177. tests/unit/test_sys_tx_async_begin.py +45 -0
  178. tests/unit/test_sys_tx_begin.py +49 -0
  179. tests/unit/test_sys_tx_commit.py +59 -0
  180. tests/unit/test_table_base_exports.py +22 -0
  181. tests/unit/test_table_collect_spec.py +41 -0
  182. tests/unit/test_table_columns_namespace.py +21 -0
  183. tests/unit/test_v3_favicon_endpoint.py +17 -0
  184. tests/unit/test_v3_healthz_endpoint.py +39 -0
  185. tests/unit/test_v3_op_alias.py +88 -0
  186. tests/unit/test_v3_op_ctx_attributes.py +99 -0
  187. tests/unit/test_v3_schemas_and_decorators.py +114 -0
  188. tests/unit/test_v3_storage_spec_attributes.py +249 -0
  189. tigrbl_tests-0.3.0.dist-info/METADATA +103 -0
  190. tigrbl_tests-0.3.0.dist-info/RECORD +192 -0
  191. tigrbl_tests-0.3.0.dist-info/WHEEL +4 -0
  192. tigrbl_tests-0.3.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+ from sqlalchemy import Column, Integer
5
+
6
+ from tigrbl.app._app import App as _App
7
+ from tigrbl.api._api import Api as _Api
8
+ from tigrbl.engine import resolver as _resolver
9
+ from tigrbl.engine.shortcuts import mem
10
+ from tigrbl.table import Base
11
+
12
+
13
+ class Widget(Base):
14
+ __tablename__ = "widgets"
15
+
16
+ id = Column(Integer, primary_key=True)
17
+
18
+
19
+ class SimpleApp(_App):
20
+ TITLE = "TestApp"
21
+ VERSION = "0.0"
22
+ LIFESPAN = None
23
+ APIS: tuple = ()
24
+ MODELS: tuple = ()
25
+ MIDDLEWARES: tuple = ()
26
+
27
+
28
+ class SimpleApi(_Api):
29
+ PREFIX = ""
30
+ TAGS: list[str] = []
31
+
32
+
33
+ def test_base_app_supports_initialize():
34
+ app = SimpleApp(engine=mem(async_=False))
35
+ app.models["Widget"] = Widget
36
+
37
+ try:
38
+ app.initialize()
39
+ finally:
40
+ _resolver.set_default(None)
41
+
42
+ assert getattr(app, "_ddl_executed", False) is True
43
+ tables = getattr(app, "tables", None)
44
+ assert tables is not None
45
+ assert getattr(tables, "Widget", None) is Widget.__table__
46
+
47
+
48
+ def test_base_api_supports_initialize_sync():
49
+ api = SimpleApi(engine=mem(async_=False))
50
+ api.models["Widget"] = Widget
51
+
52
+ api.initialize()
53
+
54
+ assert getattr(api, "_ddl_executed", False) is True
55
+ assert api.tables["Widget"] is Widget.__table__
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ async def test_base_api_supports_initialize_async():
60
+ class Gadget(Base):
61
+ __tablename__ = "gadgets"
62
+
63
+ id = Column(Integer, primary_key=True)
64
+
65
+ api = SimpleApi(engine=mem())
66
+ api.models["Gadget"] = Gadget
67
+
68
+ await api.initialize()
69
+
70
+ assert getattr(api, "_ddl_executed", False) is True
71
+ assert api.tables["Gadget"] is Gadget.__table__
@@ -0,0 +1,33 @@
1
+ from tigrbl.orm.tables._base import _materialize_colspecs_to_sqla
2
+ from tigrbl.specs import acol, S, F, IO
3
+ from tigrbl.types import Integer, String
4
+ from tigrbl.schema import _build_list_params
5
+ from sqlalchemy.orm import Mapped, DeclarativeBase
6
+
7
+
8
+ class LocalBase(DeclarativeBase):
9
+ def __init_subclass__(cls, **kw): # pragma: no cover - simple mapper hook
10
+ _materialize_colspecs_to_sqla(cls)
11
+ super().__init_subclass__(**kw)
12
+
13
+
14
+ class Thing(LocalBase):
15
+ __tablename__ = "spec_things"
16
+
17
+ id: Mapped[int] = acol(
18
+ storage=S(type_=Integer, primary_key=True),
19
+ field=F(py_type=int),
20
+ io=IO(out_verbs=("read", "list")),
21
+ )
22
+ name: Mapped[str] = acol(
23
+ storage=S(type_=String),
24
+ field=F(py_type=str),
25
+ io=IO(out_verbs=("read", "list"), filter_ops=("eq", "like")),
26
+ )
27
+
28
+
29
+ def test_build_list_params_with_spec_only_model():
30
+ params = _build_list_params(Thing)
31
+ fields = set(params.model_fields.keys())
32
+ assert "name" in fields
33
+ assert "name__like" in fields
@@ -0,0 +1,23 @@
1
+ from typing import get_args, get_origin
2
+
3
+ from tigrbl.bindings.rest.collection import _make_collection_endpoint
4
+ from tigrbl.orm.mixins import BulkCapable, GUIDPk
5
+ from tigrbl.op import OpSpec
6
+ from tigrbl.orm.tables import Base
7
+ from tigrbl.types import Column, String
8
+
9
+
10
+ def test_bulk_create_body_annotation_is_list() -> None:
11
+ Base.metadata.clear()
12
+
13
+ class Widget(Base, GUIDPk, BulkCapable):
14
+ __tablename__ = "widgets_anno"
15
+ name = Column(String, nullable=False)
16
+
17
+ sp = OpSpec(alias="bulk_create", target="bulk_create")
18
+ endpoint = _make_collection_endpoint(
19
+ Widget, sp, resource="widget", db_dep=lambda: None
20
+ )
21
+ body_ann = endpoint.__annotations__["body"]
22
+ assert get_origin(body_ann) is list
23
+ assert get_args(body_ann)[0] is getattr(Widget.schemas.bulk_create, "in_item")
@@ -0,0 +1,153 @@
1
+ from tigrbl.bindings.rest.router import _build_router
2
+ from tigrbl.op import OpSpec
3
+ from tigrbl.orm.tables import Base
4
+ from tigrbl.orm.mixins import GUIDPk, BulkCapable, Replaceable
5
+ from tigrbl.types import Column, String, App
6
+
7
+
8
+ class Widget(Base, GUIDPk, BulkCapable, Replaceable):
9
+ __tablename__ = "widgets_bulk_schema"
10
+ name = Column(String, nullable=False)
11
+
12
+
13
+ def _openapi_for(ops):
14
+ router = _build_router(Widget, [OpSpec(alias=a, target=t) for a, t in ops])
15
+ app = App()
16
+ app.include_router(router)
17
+ return app.openapi()
18
+
19
+
20
+ def test_create_request_schema_is_object():
21
+ spec = _openapi_for([("create", "create")])
22
+ path = f"/{Widget.__name__.lower()}"
23
+ schema = spec["paths"][path]["post"]["requestBody"]["content"]["application/json"][
24
+ "schema"
25
+ ]
26
+ # The create handler for bulk-capable tables accepts either a single object
27
+ # or an array of objects. Inspect the first variant to ensure it is an
28
+ # object schema.
29
+ if "anyOf" in schema:
30
+ schema = schema["anyOf"][0]
31
+ if "$ref" in schema:
32
+ ref = schema["$ref"].split("/")[-1]
33
+ schema = spec["components"]["schemas"][ref]
34
+ assert schema.get("type") == "object"
35
+
36
+
37
+ def test_bulk_create_response_schema():
38
+ spec = _openapi_for([("bulk_create", "bulk_create")])
39
+ path = f"/{Widget.__name__.lower()}"
40
+ ref = spec["paths"][path]["post"]["responses"]["200"]["content"][
41
+ "application/json"
42
+ ]["schema"]["$ref"]
43
+ assert ref.endswith("WidgetBulkCreateResponse")
44
+ comp = spec["components"]["schemas"]["WidgetBulkCreateResponse"]
45
+ assert comp["type"] == "array"
46
+ items_ref = comp["items"]["$ref"]
47
+ assert items_ref.endswith("WidgetRead")
48
+
49
+
50
+ def test_bulk_create_request_schema_has_item_ref():
51
+ spec = _openapi_for([("bulk_create", "bulk_create")])
52
+ path = f"/{Widget.__name__.lower()}"
53
+ schema = spec["paths"][path]["post"]["requestBody"]["content"]["application/json"][
54
+ "schema"
55
+ ]
56
+ assert schema["type"] == "array"
57
+ items_ref = schema["items"]["$ref"]
58
+ assert items_ref.endswith("WidgetBulkCreateItem")
59
+
60
+
61
+ def test_create_and_bulk_create_handlers_and_schemas_bound():
62
+ _ = _openapi_for(
63
+ [
64
+ ("create", "create"),
65
+ ("bulk_create", "bulk_create"),
66
+ ]
67
+ )
68
+ assert hasattr(Widget.schemas, "create")
69
+ assert hasattr(Widget.schemas, "bulk_create")
70
+ assert hasattr(Widget.handlers, "create")
71
+ assert hasattr(Widget.handlers, "bulk_create")
72
+ assert hasattr(Widget.handlers.create, "core")
73
+ assert hasattr(Widget.handlers.bulk_create, "core")
74
+
75
+
76
+ def test_bulk_delete_response_schema():
77
+ spec = _openapi_for([("bulk_delete", "bulk_delete")])
78
+ path = f"/{Widget.__name__.lower()}"
79
+ ref = spec["paths"][path]["delete"]["responses"]["200"]["content"][
80
+ "application/json"
81
+ ]["schema"]["$ref"]
82
+ assert ref.endswith("WidgetBulkDeleteResponse")
83
+ comp = spec["components"]["schemas"]["WidgetBulkDeleteResponse"]
84
+ props = comp.get("properties", {})
85
+ assert "deleted" in props
86
+ assert props["deleted"]["type"] == "integer"
87
+
88
+
89
+ def test_bulk_update_request_and_response_schemas():
90
+ spec = _openapi_for([("bulk_update", "bulk_update")])
91
+ path = f"/{Widget.__name__.lower()}"
92
+ # request schema
93
+ req_schema = spec["paths"][path]["patch"]["requestBody"]["content"][
94
+ "application/json"
95
+ ]["schema"]
96
+ assert req_schema["type"] == "array"
97
+ assert req_schema["items"]["$ref"].endswith("WidgetBulkUpdateItem")
98
+ # response schema
99
+ resp_ref = spec["paths"][path]["patch"]["responses"]["200"]["content"][
100
+ "application/json"
101
+ ]["schema"]["$ref"]
102
+ assert resp_ref.endswith("WidgetBulkUpdateResponse")
103
+ resp_comp = spec["components"]["schemas"]["WidgetBulkUpdateResponse"]
104
+ assert resp_comp["items"]["$ref"].endswith("WidgetRead")
105
+ assert "WidgetRead" in spec["components"]["schemas"]
106
+
107
+
108
+ def test_bulk_replace_request_and_response_schemas():
109
+ spec = _openapi_for([("bulk_replace", "bulk_replace")])
110
+ path = f"/{Widget.__name__.lower()}"
111
+ # request schema
112
+ req_schema = spec["paths"][path]["put"]["requestBody"]["content"][
113
+ "application/json"
114
+ ]["schema"]
115
+ assert req_schema["type"] == "array"
116
+ assert req_schema["items"]["$ref"].endswith("WidgetBulkReplaceItem")
117
+ # response schema
118
+ resp_ref = spec["paths"][path]["put"]["responses"]["200"]["content"][
119
+ "application/json"
120
+ ]["schema"]["$ref"]
121
+ assert resp_ref.endswith("WidgetBulkReplaceResponse")
122
+ resp_comp = spec["components"]["schemas"]["WidgetBulkReplaceResponse"]
123
+ assert resp_comp["items"]["$ref"].endswith("WidgetRead")
124
+
125
+
126
+ def test_bulk_merge_request_and_response_schemas():
127
+ spec = _openapi_for([("bulk_merge", "bulk_merge")])
128
+ path = f"/{Widget.__name__.lower()}"
129
+ # request schema
130
+ req_schema = spec["paths"][path]["patch"]["requestBody"]["content"][
131
+ "application/json"
132
+ ]["schema"]
133
+ assert req_schema["type"] == "array"
134
+ assert req_schema["items"]["type"] == "object"
135
+ # bulk merge currently returns no content in the response
136
+ assert "content" not in spec["paths"][path]["patch"]["responses"]["200"]
137
+
138
+
139
+ def test_update_and_bulk_update_schema_names_do_not_collide():
140
+ spec = _openapi_for([("update", "update"), ("bulk_update", "bulk_update")])
141
+ base = f"/{Widget.__name__.lower()}"
142
+ update_path = f"{base}/{{item_id}}"
143
+ # single update schema
144
+ upd_ref = spec["paths"][update_path]["patch"]["requestBody"]["content"][
145
+ "application/json"
146
+ ]["schema"]["$ref"]
147
+ assert upd_ref.endswith("WidgetUpdateRequest")
148
+ # bulk update schema
149
+ bulk_schema = spec["paths"][base]["patch"]["requestBody"]["content"][
150
+ "application/json"
151
+ ]["schema"]
152
+ assert bulk_schema["type"] == "array"
153
+ assert bulk_schema["items"]["$ref"].endswith("WidgetBulkUpdateItem")
@@ -0,0 +1,19 @@
1
+ from tigrbl.column import makeVirtualColumn
2
+
3
+
4
+ class Base:
5
+ base = makeVirtualColumn()
6
+
7
+
8
+ class One(Base):
9
+ one = makeVirtualColumn()
10
+
11
+
12
+ class Two(Base):
13
+ two = makeVirtualColumn()
14
+
15
+
16
+ def test_colspec_maps_are_isolated() -> None:
17
+ assert set(Base.__tigrbl_colspecs__) == {"base"}
18
+ assert set(One.__tigrbl_colspecs__) == {"base", "one"}
19
+ assert set(Two.__tigrbl_colspecs__) == {"base", "two"}
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from tigrbl.column.mro_collect import mro_collect_columns
4
+ from tigrbl.orm.mixins import GUIDPk
5
+ from tigrbl.orm.tables._base import Base
6
+ from tigrbl.specs import S, acol
7
+ from tigrbl.types import Mapped, String
8
+
9
+
10
+ class NameMixin:
11
+ name: Mapped[str] = acol(storage=S(String, nullable=False))
12
+
13
+
14
+ class Thing(Base, GUIDPk, NameMixin):
15
+ __tablename__ = "thing_collect_mixins"
16
+
17
+
18
+ def test_collect_columns_includes_mixin_fields():
19
+ specs = mro_collect_columns(Thing)
20
+ assert "id" in specs
21
+ assert "name" in specs
@@ -0,0 +1,298 @@
1
+ import pytest
2
+ from fastapi.testclient import TestClient
3
+
4
+ from tigrbl import TigrblApi, alias_ctx
5
+ from tigrbl.column import F, IO, S, makeColumn, makeVirtualColumn
6
+ from tigrbl.engine.shortcuts import engine as build_engine, mem
7
+ from tigrbl.orm.tables import Base
8
+ from tigrbl.types import App, Integer, Mapped, String
9
+
10
+
11
+ # Helper to bootstrap API and test client for a model
12
+
13
+
14
+ def _setup_api(model):
15
+ eng = build_engine(mem(async_=False))
16
+ api = TigrblApi(engine=eng)
17
+ api.include_model(model)
18
+ api.initialize()
19
+
20
+ app = App()
21
+ app.include_router(api)
22
+ client = TestClient(app)
23
+ return api, client, eng
24
+
25
+
26
+ @pytest.mark.parametrize("use_mapped", [True, False])
27
+ @pytest.mark.asyncio
28
+ async def test_make_column_only_rest_rpc(use_mapped):
29
+ Base.metadata.clear()
30
+
31
+ class Thing(Base):
32
+ __tablename__ = f"mc_only_{'m' if use_mapped else 'u'}"
33
+ if not use_mapped:
34
+ __allow_unmapped__ = True
35
+ if use_mapped:
36
+ id: Mapped[int] = makeColumn(
37
+ storage=S(type_=Integer, primary_key=True, autoincrement=True)
38
+ )
39
+ name: Mapped[str] = makeColumn(storage=S(type_=String, nullable=False))
40
+ else:
41
+ id = makeColumn(
42
+ storage=S(type_=Integer, primary_key=True, autoincrement=True)
43
+ )
44
+ name = makeColumn(storage=S(type_=String, nullable=False))
45
+
46
+ api, client, eng = _setup_api(Thing)
47
+ try:
48
+ with eng.session() as db:
49
+ created = await api.rpc_call(Thing, "create", {"name": "x"}, db=db)
50
+ db_read = await api.core.Thing.read({"id": created["id"]}, db=db)
51
+ rpc_read = await api.rpc_call(Thing, "read", {"id": created["id"]}, db=db)
52
+ resp = client.get(f"/{Thing.__name__.lower()}/{created['id']}")
53
+ assert resp.status_code == 200
54
+ rest_data = resp.json()
55
+ assert db_read == rpc_read == rest_data == {"id": created["id"], "name": "x"}
56
+ finally:
57
+ raw_eng, _ = eng.raw()
58
+ raw_eng.dispose()
59
+
60
+
61
+ @pytest.mark.parametrize("use_mapped", [True, False])
62
+ @pytest.mark.asyncio
63
+ async def test_make_virtual_column_only_rest_rpc(use_mapped):
64
+ Base.metadata.clear()
65
+
66
+ class Thing(Base):
67
+ __tablename__ = f"mv_only_{'m' if use_mapped else 'u'}"
68
+ if not use_mapped:
69
+ __allow_unmapped__ = True
70
+ if use_mapped:
71
+ id: Mapped[int] = makeColumn(
72
+ storage=S(type_=Integer, primary_key=True, autoincrement=True)
73
+ )
74
+ code: str = makeVirtualColumn(
75
+ field=F(py_type=str),
76
+ io=IO(out_verbs=("read",)),
77
+ read_producer=lambda obj, ctx: f"v-{obj.id}",
78
+ nullable=True,
79
+ )
80
+ else:
81
+ id = makeColumn(
82
+ storage=S(type_=Integer, primary_key=True, autoincrement=True)
83
+ )
84
+ code: str = makeVirtualColumn(
85
+ field=F(py_type=str),
86
+ io=IO(out_verbs=("read",)),
87
+ read_producer=lambda obj, ctx: f"v-{obj.id}",
88
+ nullable=True,
89
+ )
90
+
91
+ api, client, eng = _setup_api(Thing)
92
+ try:
93
+ with eng.session() as db:
94
+ created = await api.rpc_call(Thing, "create", {}, db=db)
95
+ db_read = await api.core.Thing.read({"id": created["id"]}, db=db)
96
+ rpc_read = await api.rpc_call(Thing, "read", {"id": created["id"]}, db=db)
97
+ resp = client.get(f"/{Thing.__name__.lower()}/{created['id']}")
98
+ assert resp.status_code == 200
99
+ rest_data = resp.json()
100
+ assert db_read == rpc_read == rest_data == {"id": created["id"], "code": None}
101
+ finally:
102
+ raw_eng, _ = eng.raw()
103
+ raw_eng.dispose()
104
+
105
+
106
+ @pytest.mark.parametrize("use_mapped", [True, False])
107
+ @pytest.mark.asyncio
108
+ async def test_make_column_with_alias_rest_rpc(use_mapped):
109
+ Base.metadata.clear()
110
+
111
+ @alias_ctx(read="fetch")
112
+ class Thing(Base):
113
+ __tablename__ = f"mc_alias_{'m' if use_mapped else 'u'}"
114
+ if not use_mapped:
115
+ __allow_unmapped__ = True
116
+ if use_mapped:
117
+ id: Mapped[int] = makeColumn(
118
+ storage=S(type_=Integer, primary_key=True, autoincrement=True)
119
+ )
120
+ name: Mapped[str] = makeColumn(storage=S(type_=String, nullable=False))
121
+ else:
122
+ id = makeColumn(
123
+ storage=S(type_=Integer, primary_key=True, autoincrement=True)
124
+ )
125
+ name = makeColumn(storage=S(type_=String, nullable=False))
126
+
127
+ api, client, eng = _setup_api(Thing)
128
+ try:
129
+ with eng.session() as db:
130
+ created = await api.rpc_call(Thing, "create", {"name": "y"}, db=db)
131
+ db_read = await api.core.Thing.fetch({"id": created["id"]}, db=db)
132
+ rpc_read = await api.rpc_call(Thing, "fetch", {"id": created["id"]}, db=db)
133
+ resp = client.get(f"/{Thing.__name__.lower()}/{created['id']}")
134
+ assert resp.status_code == 200
135
+ rest_data = resp.json()
136
+ assert db_read == rpc_read == rest_data == {"id": created["id"], "name": "y"}
137
+ finally:
138
+ raw_eng, _ = eng.raw()
139
+ raw_eng.dispose()
140
+
141
+
142
+ @pytest.mark.parametrize("use_mapped", [True, False])
143
+ @pytest.mark.asyncio
144
+ async def test_make_virtual_column_with_aliases_rest_rpc(use_mapped):
145
+ Base.metadata.clear()
146
+
147
+ @alias_ctx(create="register", read="fetch")
148
+ class Thing(Base):
149
+ __tablename__ = f"mv_alias_{'m' if use_mapped else 'u'}"
150
+ if not use_mapped:
151
+ __allow_unmapped__ = True
152
+ if use_mapped:
153
+ id: Mapped[int] = makeColumn(
154
+ storage=S(type_=Integer, primary_key=True, autoincrement=True)
155
+ )
156
+ code: str = makeVirtualColumn(
157
+ field=F(py_type=str),
158
+ io=IO(out_verbs=("read",)),
159
+ read_producer=lambda obj, ctx: f"v-{obj.id}",
160
+ nullable=True,
161
+ )
162
+ else:
163
+ id = makeColumn(
164
+ storage=S(type_=Integer, primary_key=True, autoincrement=True)
165
+ )
166
+ code: str = makeVirtualColumn(
167
+ field=F(py_type=str),
168
+ io=IO(out_verbs=("read",)),
169
+ read_producer=lambda obj, ctx: f"v-{obj.id}",
170
+ nullable=True,
171
+ )
172
+
173
+ api, client, eng = _setup_api(Thing)
174
+ try:
175
+ with eng.session() as db:
176
+ created = await api.rpc_call(Thing, "register", {}, db=db)
177
+ db_read = await api.core.Thing.fetch({"id": created["id"]}, db=db)
178
+ rpc_read = await api.rpc_call(Thing, "fetch", {"id": created["id"]}, db=db)
179
+ resp = client.get(f"/{Thing.__name__.lower()}/{created['id']}")
180
+ assert resp.status_code == 200
181
+ rest_data = resp.json()
182
+ assert db_read == rpc_read == rest_data == {"id": created["id"], "code": None}
183
+ finally:
184
+ raw_eng, _ = eng.raw()
185
+ raw_eng.dispose()
186
+
187
+
188
+ @pytest.mark.parametrize("use_mapped", [True, False])
189
+ @pytest.mark.asyncio
190
+ async def test_make_column_and_virtual_rest_rpc(use_mapped):
191
+ Base.metadata.clear()
192
+
193
+ class Thing(Base):
194
+ __tablename__ = f"both_{'m' if use_mapped else 'u'}"
195
+ if not use_mapped:
196
+ __allow_unmapped__ = True
197
+ if use_mapped:
198
+ id: Mapped[int] = makeColumn(
199
+ storage=S(type_=Integer, primary_key=True, autoincrement=True)
200
+ )
201
+ name: Mapped[str] = makeColumn(storage=S(type_=String, nullable=False))
202
+ upper: str = makeVirtualColumn(
203
+ field=F(py_type=str),
204
+ io=IO(out_verbs=("read",)),
205
+ read_producer=lambda obj, ctx: obj.name.upper(),
206
+ nullable=True,
207
+ )
208
+ else:
209
+ id = makeColumn(
210
+ storage=S(type_=Integer, primary_key=True, autoincrement=True)
211
+ )
212
+ name = makeColumn(storage=S(type_=String, nullable=False))
213
+ upper: str = makeVirtualColumn(
214
+ field=F(py_type=str),
215
+ io=IO(out_verbs=("read",)),
216
+ read_producer=lambda obj, ctx: obj.name.upper(),
217
+ nullable=True,
218
+ )
219
+
220
+ api, client, eng = _setup_api(Thing)
221
+ try:
222
+ with eng.session() as db:
223
+ created = await api.rpc_call(Thing, "create", {"name": "Ada"}, db=db)
224
+ db_read = await api.core.Thing.read({"id": created["id"]}, db=db)
225
+ rpc_read = await api.rpc_call(Thing, "read", {"id": created["id"]}, db=db)
226
+ resp = client.get(f"/{Thing.__name__.lower()}/{created['id']}")
227
+ assert resp.status_code == 200
228
+ rest_data = resp.json()
229
+ assert (
230
+ db_read
231
+ == rpc_read
232
+ == rest_data
233
+ == {
234
+ "id": created["id"],
235
+ "name": "Ada",
236
+ "upper": None,
237
+ }
238
+ )
239
+ finally:
240
+ raw_eng, _ = eng.raw()
241
+ raw_eng.dispose()
242
+
243
+
244
+ @pytest.mark.parametrize("use_mapped", [True, False])
245
+ @pytest.mark.asyncio
246
+ async def test_make_column_and_virtual_with_alias_rest_rpc(use_mapped):
247
+ Base.metadata.clear()
248
+
249
+ @alias_ctx(create="register", read="fetch")
250
+ class Thing(Base):
251
+ __tablename__ = f"both_alias_{'m' if use_mapped else 'u'}"
252
+ if not use_mapped:
253
+ __allow_unmapped__ = True
254
+ if use_mapped:
255
+ id: Mapped[int] = makeColumn(
256
+ storage=S(type_=Integer, primary_key=True, autoincrement=True)
257
+ )
258
+ name: Mapped[str] = makeColumn(storage=S(type_=String, nullable=False))
259
+ upper: str = makeVirtualColumn(
260
+ field=F(py_type=str),
261
+ io=IO(out_verbs=("read",)),
262
+ read_producer=lambda obj, ctx: obj.name.upper(),
263
+ nullable=True,
264
+ )
265
+ else:
266
+ id = makeColumn(
267
+ storage=S(type_=Integer, primary_key=True, autoincrement=True)
268
+ )
269
+ name = makeColumn(storage=S(type_=String, nullable=False))
270
+ upper: str = makeVirtualColumn(
271
+ field=F(py_type=str),
272
+ io=IO(out_verbs=("read",)),
273
+ read_producer=lambda obj, ctx: obj.name.upper(),
274
+ nullable=True,
275
+ )
276
+
277
+ api, client, eng = _setup_api(Thing)
278
+ try:
279
+ with eng.session() as db:
280
+ created = await api.rpc_call(Thing, "register", {"name": "Bob"}, db=db)
281
+ db_read = await api.core.Thing.fetch({"id": created["id"]}, db=db)
282
+ rpc_read = await api.rpc_call(Thing, "fetch", {"id": created["id"]}, db=db)
283
+ resp = client.get(f"/{Thing.__name__.lower()}/{created['id']}")
284
+ assert resp.status_code == 200
285
+ rest_data = resp.json()
286
+ assert (
287
+ db_read
288
+ == rpc_read
289
+ == rest_data
290
+ == {
291
+ "id": created["id"],
292
+ "name": "Bob",
293
+ "upper": None,
294
+ }
295
+ )
296
+ finally:
297
+ raw_eng, _ = eng.raw()
298
+ raw_eng.dispose()
@@ -0,0 +1,51 @@
1
+ from tigrbl.column import F, S, acol, vcol
2
+ from tigrbl.orm.tables import Base
3
+ from tigrbl.types import Integer, Mapped, String
4
+ from sqlalchemy import create_engine, select
5
+ from sqlalchemy.orm import Session
6
+
7
+
8
+ def test_acol_vcol_bind_to_table_and_orm() -> None:
9
+ """`acol`/`vcol` integrate with table metadata and ORM instances."""
10
+
11
+ Base.metadata.clear()
12
+ engine = create_engine("sqlite:///:memory:")
13
+
14
+ class User(Base):
15
+ __tablename__ = "users"
16
+ id: Mapped[int] = acol(
17
+ storage=S(type_=Integer, primary_key=True, autoincrement=True)
18
+ )
19
+ name: Mapped[str] = acol(storage=S(type_=String, nullable=False))
20
+ upper: str = vcol(
21
+ field=F(py_type=str),
22
+ read_producer=lambda obj, ctx: obj.name.upper(),
23
+ nullable=True,
24
+ )
25
+
26
+ Base.metadata.create_all(engine)
27
+
28
+ # Table contains columns for both persisted and virtual specs
29
+ assert set(User.__table__.c.keys()) == {"id", "name", "upper"}
30
+
31
+ # Spec map tracks both persisted and virtual columns
32
+ assert set(User.__tigrbl_cols__.keys()) == {"id", "name", "upper"}
33
+ assert User.__tigrbl_cols__["upper"].storage is None
34
+
35
+ with Session(engine) as session:
36
+ user = User(name="Alice")
37
+ session.add(user)
38
+ session.commit()
39
+ session.refresh(user)
40
+
41
+ # persisted column round-trips through the DB
42
+ result = session.execute(select(User.name)).scalar_one()
43
+ assert result == "Alice"
44
+
45
+ # DB column for virtual spec defaults to NULL
46
+ raw = session.execute(select(User.upper)).scalar_one()
47
+ assert raw is None
48
+
49
+ # virtual column value produced dynamically
50
+ spec = User.__tigrbl_cols__["upper"]
51
+ assert spec.read_producer(user, {}) == "ALICE"
@@ -0,0 +1,12 @@
1
+ from dataclasses import dataclass
2
+
3
+ from tigrbl.config import resolve_cfg
4
+
5
+
6
+ def test_dataclass_none_fields_do_not_override_defaults() -> None:
7
+ @dataclass
8
+ class AppSpec:
9
+ trace: dict | None = None
10
+
11
+ cfg = resolve_cfg(appspec=AppSpec())
12
+ assert cfg.trace == {"enabled": True}