boti-data 1.2.0__tar.gz → 1.3.0__tar.gz

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 (119) hide show
  1. {boti_data-1.2.0 → boti_data-1.3.0}/PKG-INFO +39 -1
  2. {boti_data-1.2.0 → boti_data-1.3.0}/README.md +38 -0
  3. {boti_data-1.2.0 → boti_data-1.3.0}/pyproject.toml +1 -1
  4. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/__init__.py +4 -0
  5. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/__init__.py +8 -1
  6. boti_data-1.3.0/src/boti_data/db/sql_repository.py +354 -0
  7. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data.egg-info/PKG-INFO +39 -1
  8. boti_data-1.2.0/src/boti_data/db/sql_repository.py +0 -186
  9. {boti_data-1.2.0 → boti_data-1.3.0}/setup.cfg +0 -0
  10. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/arrow_schema.py +0 -0
  11. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/connection_catalog.py +0 -0
  12. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/datacube/__init__.py +0 -0
  13. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/datacube/artifact.py +0 -0
  14. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/datacube/base.py +0 -0
  15. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/datacube/contract.py +0 -0
  16. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/datacube/resource.py +0 -0
  17. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/dataset/__init__.py +0 -0
  18. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/dataset/hybrid.py +0 -0
  19. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/dataset/hybrid_support.py +0 -0
  20. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/arrow_schema_mapper.py +0 -0
  21. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/engine_registry.py +0 -0
  22. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/partitioned_execution.py +0 -0
  23. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/partitioned_loader.py +0 -0
  24. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/partitioned_planner.py +0 -0
  25. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/partitioned_planner_async.py +0 -0
  26. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/partitioned_planning_math.py +0 -0
  27. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/partitioned_row_materialization.py +0 -0
  28. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/partitioned_statements.py +0 -0
  29. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/partitioned_types.py +0 -0
  30. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/sql_config.py +0 -0
  31. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/sql_engine.py +0 -0
  32. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/sql_manager.py +0 -0
  33. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/sql_model_builder.py +0 -0
  34. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/sql_model_module_injection.py +0 -0
  35. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/sql_model_registry.py +0 -0
  36. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/sql_model_registry_types.py +0 -0
  37. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/sql_readonly.py +0 -0
  38. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/sql_resource.py +0 -0
  39. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/sqlalchemy_async.py +0 -0
  40. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/db/vector_search.py +0 -0
  41. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/enrichment/__init__.py +0 -0
  42. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/enrichment/async_enricher.py +0 -0
  43. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/enrichment/composite.py +0 -0
  44. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/enrichment/protocol.py +0 -0
  45. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/enrichment/specs.py +0 -0
  46. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/enrichment/transformers.py +0 -0
  47. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/field_map.py +0 -0
  48. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/filters/__init__.py +0 -0
  49. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/filters/arrow_kernels.py +0 -0
  50. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/filters/arrow_kernels_comparison.py +0 -0
  51. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/filters/arrow_kernels_compile.py +0 -0
  52. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/filters/arrow_kernels_string.py +0 -0
  53. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/filters/backend_ops.py +0 -0
  54. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/filters/expressions.py +0 -0
  55. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/filters/handler.py +0 -0
  56. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/filters/parquet_compiler.py +0 -0
  57. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/filters/utils.py +0 -0
  58. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/filters/value_parsing.py +0 -0
  59. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/__init__.py +0 -0
  60. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/_backend_strategies.py +0 -0
  61. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/_factory.py +0 -0
  62. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/_gateway_init.py +0 -0
  63. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/_payloads.py +0 -0
  64. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/_series_filters.py +0 -0
  65. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/arrow_adapters.py +0 -0
  66. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/build_context.py +0 -0
  67. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/chunking.py +0 -0
  68. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/configured_load.py +0 -0
  69. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/core.py +0 -0
  70. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/core_load.py +0 -0
  71. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/datacube_strategy.py +0 -0
  72. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/frame_convert.py +0 -0
  73. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/frame_strategies.py +0 -0
  74. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/incremental.py +0 -0
  75. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/load_execution.py +0 -0
  76. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/load_request.py +0 -0
  77. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/loaders.py +0 -0
  78. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/normalization.py +0 -0
  79. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/parquet_strategy.py +0 -0
  80. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/planning.py +0 -0
  81. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/policies.py +0 -0
  82. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/post_process.py +0 -0
  83. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/requests.py +0 -0
  84. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/return_type.py +0 -0
  85. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/select_cache.py +0 -0
  86. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/semi_join.py +0 -0
  87. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/sql_guard.py +0 -0
  88. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/sql_partitioned_exec.py +0 -0
  89. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/sql_size_estimation.py +0 -0
  90. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/sql_strategy.py +0 -0
  91. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/gateway/sql_strategy_configured_async.py +0 -0
  92. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/helper.py +0 -0
  93. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/joins.py +0 -0
  94. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/parquet/__init__.py +0 -0
  95. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/parquet/config.py +0 -0
  96. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/parquet/discovery.py +0 -0
  97. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/parquet/file_info.py +0 -0
  98. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/parquet/filesystem.py +0 -0
  99. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/parquet/reader.py +0 -0
  100. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/parquet/reader_config.py +0 -0
  101. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/parquet/resource.py +0 -0
  102. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/parquet/schema_filters.py +0 -0
  103. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/pipelines/__init__.py +0 -0
  104. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/pipelines/base.py +0 -0
  105. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/pipelines/parquet_pipeline.py +0 -0
  106. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/pipelines/registry.py +0 -0
  107. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/pipelines/sinks.py +0 -0
  108. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/pipelines/sinks_common.py +0 -0
  109. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/pipelines/sinks_parquet.py +0 -0
  110. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/pipelines/sinks_text.py +0 -0
  111. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/py.typed +0 -0
  112. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/schema.py +0 -0
  113. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/watermark/__init__.py +0 -0
  114. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/watermark/incremental.py +0 -0
  115. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data/watermark/store.py +0 -0
  116. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data.egg-info/SOURCES.txt +0 -0
  117. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data.egg-info/dependency_links.txt +0 -0
  118. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data.egg-info/requires.txt +0 -0
  119. {boti_data-1.2.0 → boti_data-1.3.0}/src/boti_data.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boti-data
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Data infrastructure for the Boti ecosystem
5
5
  Author: Luis Valverde
6
6
  Maintainer-email: Luis Valverde <lvalverdeb@gmail.com>
@@ -920,6 +920,44 @@ async with AsyncSqlRepository(config, "users", query_only=False) as users:
920
920
  row = await users.get(1)
921
921
  ```
922
922
 
923
+ #### Multi-table atomic transactions
924
+
925
+ By default each `SqlRepository` owns its own connection and commits after every write — fine for independent single-row operations, but not when several writes (potentially across different tables) need to succeed or fail together. Pass an already-open `session` instead of `config` to bind a repository to a transaction you control; writes `flush()` instead of committing, so nothing is durable until you commit the session yourself:
926
+
927
+ ```python
928
+ with SqlDatabaseResource(config) as db, db.session() as session:
929
+ with SqlRepository(session=session, table_name="audit_log") as audit_log:
930
+ with SqlRepository(session=session, table_name="embeddings") as embeddings:
931
+ audit_log.insert({"event": "enroll"})
932
+ embeddings.insert({"subject_id": 1, "vector": [...]})
933
+ session.commit() # both rows commit together, or neither does on an exception
934
+ ```
935
+
936
+ `SqlUnitOfWork` wraps this pattern for the common case — one config, several tables, one commit/rollback:
937
+
938
+ ```python
939
+ from boti_data import SqlDatabaseConfig, SqlUnitOfWork
940
+
941
+ config = SqlDatabaseConfig(connection_url="postgresql+psycopg://user:pass@host/mydb")
942
+
943
+ with SqlUnitOfWork(config, query_only=False) as uow:
944
+ uow.repository("audit_log").insert({"event": "enroll"})
945
+ uow.repository("embeddings").insert({"subject_id": 1, "vector": [...]})
946
+ # commits both on clean exit; rolls back both on any exception
947
+ ```
948
+
949
+ `query_only` on a `session`-bound `SqlRepository` is inert — the session's own class (e.g. a `ReadOnlySession`) already determines writability, since this path never builds a new guarded session itself. `AsyncSqlUnitOfWork` mirrors `SqlUnitOfWork`, with an async `repository()` method (returns an already-set-up `AsyncSqlRepository`, so callers don't need to `async with` each one individually):
950
+
951
+ ```python
952
+ from boti_data import AsyncSqlUnitOfWork
953
+
954
+ async with AsyncSqlUnitOfWork(config, query_only=False) as uow:
955
+ audit_log = await uow.repository("audit_log")
956
+ embeddings = await uow.repository("embeddings")
957
+ await audit_log.insert({"event": "enroll"})
958
+ await embeddings.insert({"subject_id": 1, "vector": [...]})
959
+ ```
960
+
923
961
  ### Nearest-neighbour search (pgvector)
924
962
 
925
963
  `boti_data.db.nearest_neighbors`/`vector_distance` build an `ORDER BY <distance> LIMIT k` statement against a pgvector column, with the query vector bound as a driver parameter — never string-interpolated into the SQL, regardless of how large the vector is. Requires `boti-data[pgvector]` (see [Installation](#installation)) and a table reflected via `SqlAlchemyModelBuilder`/`SqlRepository`/`DataGateway`.
@@ -885,6 +885,44 @@ async with AsyncSqlRepository(config, "users", query_only=False) as users:
885
885
  row = await users.get(1)
886
886
  ```
887
887
 
888
+ #### Multi-table atomic transactions
889
+
890
+ By default each `SqlRepository` owns its own connection and commits after every write — fine for independent single-row operations, but not when several writes (potentially across different tables) need to succeed or fail together. Pass an already-open `session` instead of `config` to bind a repository to a transaction you control; writes `flush()` instead of committing, so nothing is durable until you commit the session yourself:
891
+
892
+ ```python
893
+ with SqlDatabaseResource(config) as db, db.session() as session:
894
+ with SqlRepository(session=session, table_name="audit_log") as audit_log:
895
+ with SqlRepository(session=session, table_name="embeddings") as embeddings:
896
+ audit_log.insert({"event": "enroll"})
897
+ embeddings.insert({"subject_id": 1, "vector": [...]})
898
+ session.commit() # both rows commit together, or neither does on an exception
899
+ ```
900
+
901
+ `SqlUnitOfWork` wraps this pattern for the common case — one config, several tables, one commit/rollback:
902
+
903
+ ```python
904
+ from boti_data import SqlDatabaseConfig, SqlUnitOfWork
905
+
906
+ config = SqlDatabaseConfig(connection_url="postgresql+psycopg://user:pass@host/mydb")
907
+
908
+ with SqlUnitOfWork(config, query_only=False) as uow:
909
+ uow.repository("audit_log").insert({"event": "enroll"})
910
+ uow.repository("embeddings").insert({"subject_id": 1, "vector": [...]})
911
+ # commits both on clean exit; rolls back both on any exception
912
+ ```
913
+
914
+ `query_only` on a `session`-bound `SqlRepository` is inert — the session's own class (e.g. a `ReadOnlySession`) already determines writability, since this path never builds a new guarded session itself. `AsyncSqlUnitOfWork` mirrors `SqlUnitOfWork`, with an async `repository()` method (returns an already-set-up `AsyncSqlRepository`, so callers don't need to `async with` each one individually):
915
+
916
+ ```python
917
+ from boti_data import AsyncSqlUnitOfWork
918
+
919
+ async with AsyncSqlUnitOfWork(config, query_only=False) as uow:
920
+ audit_log = await uow.repository("audit_log")
921
+ embeddings = await uow.repository("embeddings")
922
+ await audit_log.insert({"event": "enroll"})
923
+ await embeddings.insert({"subject_id": 1, "vector": [...]})
924
+ ```
925
+
888
926
  ### Nearest-neighbour search (pgvector)
889
927
 
890
928
  `boti_data.db.nearest_neighbors`/`vector_distance` build an `ORDER BY <distance> LIMIT k` statement against a pgvector column, with the query vector bound as a driver parameter — never string-interpolated into the SQL, regardless of how large the vector is. Requires `boti-data[pgvector]` (see [Installation](#installation)) and a table reflected via `SqlAlchemyModelBuilder`/`SqlRepository`/`DataGateway`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "boti-data"
3
- version = "1.2.0"
3
+ version = "1.3.0"
4
4
  description = "Data infrastructure for the Boti ecosystem"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -12,6 +12,7 @@ __all__ = [
12
12
  "AsyncFrameEnricher",
13
13
  "AsyncSqlDatabaseResource",
14
14
  "AsyncSqlRepository",
15
+ "AsyncSqlUnitOfWork",
15
16
  "AttachmentSpec",
16
17
  "BaseDataCube",
17
18
  "BaseArtifact",
@@ -61,6 +62,7 @@ __all__ = [
61
62
  "SqlPartitionedLoader",
62
63
  "SqlModelRegistry",
63
64
  "SqlRepository",
65
+ "SqlUnitOfWork",
64
66
  "TrueExpr",
65
67
  "VectorMetric",
66
68
  "WatermarkStore",
@@ -105,6 +107,8 @@ _LAZY = {
105
107
  "SqlPartitionSpec": ("boti_data.db.partitioned_types", "SqlPartitionSpec"),
106
108
  "SqlRepository": ("boti_data.db.sql_repository", "SqlRepository"),
107
109
  "AsyncSqlRepository": ("boti_data.db.sql_repository", "AsyncSqlRepository"),
110
+ "SqlUnitOfWork": ("boti_data.db.sql_repository", "SqlUnitOfWork"),
111
+ "AsyncSqlUnitOfWork": ("boti_data.db.sql_repository", "AsyncSqlUnitOfWork"),
108
112
  "nearest_neighbors": ("boti_data.db.vector_search", "nearest_neighbors"),
109
113
  "vector_distance": ("boti_data.db.vector_search", "vector_distance"),
110
114
  "VectorMetric": ("boti_data.db.vector_search", "VectorMetric"),
@@ -21,13 +21,19 @@ from boti_data.db.sql_model_registry import (
21
21
  SqlModelRegistry,
22
22
  get_global_registry,
23
23
  )
24
- from boti_data.db.sql_repository import AsyncSqlRepository, SqlRepository
24
+ from boti_data.db.sql_repository import (
25
+ AsyncSqlRepository,
26
+ AsyncSqlUnitOfWork,
27
+ SqlRepository,
28
+ SqlUnitOfWork,
29
+ )
25
30
  from boti_data.db.sqlalchemy_async import ensure_greenlet_available
26
31
  from boti_data.db.vector_search import nearest_neighbors, vector_distance
27
32
 
28
33
  __all__ = [
29
34
  "AsyncSqlDatabaseResource",
30
35
  "AsyncSqlRepository",
36
+ "AsyncSqlUnitOfWork",
31
37
  "BuilderConfig",
32
38
  "DefaultBase",
33
39
  "EngineRegistry",
@@ -41,6 +47,7 @@ __all__ = [
41
47
  "SqlPartitionedLoader",
42
48
  "SqlModelRegistry",
43
49
  "SqlRepository",
50
+ "SqlUnitOfWork",
44
51
  "ensure_greenlet_available",
45
52
  "get_global_registry",
46
53
  "nearest_neighbors",
@@ -0,0 +1,354 @@
1
+ """
2
+ Lightweight single-row transactional access (get/insert/update/delete by
3
+ primary key), returning plain dicts instead of a DataFrame — plus
4
+ SqlUnitOfWork/AsyncSqlUnitOfWork for composing writes across several tables
5
+ into one atomic transaction.
6
+
7
+ boti_data's flagship API (DataGateway/DataHelper) is dataframe-shaped and
8
+ bulk-oriented, pulling in the dask/pandas/polars dependency chain even for a
9
+ consumer that only ever touches one row at a time (e.g. an OLTP request
10
+ handler at p95 <300ms). SqlRepository/AsyncSqlRepository sit directly on
11
+ SqlDatabaseResource/AsyncSqlDatabaseResource + SqlModelRegistry — both already
12
+ free of that dependency chain — so this module can be imported and used
13
+ without dask/pandas/polars installed at all.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from contextlib import asynccontextmanager, contextmanager
19
+ from typing import Any
20
+
21
+ from boti.core.lifecycle import LifecycleCore
22
+ from boti.core.logger import Logger
23
+ from sqlalchemy import inspect as sa_inspect
24
+ from sqlalchemy.ext.asyncio import AsyncSession
25
+ from sqlalchemy.orm import Session
26
+
27
+ from boti_data.db.sql_config import SqlDatabaseConfig
28
+ from boti_data.db.sql_model_registry import get_global_registry
29
+ from boti_data.db.sql_resource import AsyncSqlDatabaseResource, SqlDatabaseResource
30
+
31
+ __all__ = ["AsyncSqlRepository", "AsyncSqlUnitOfWork", "SqlRepository", "SqlUnitOfWork"]
32
+
33
+
34
+ def _row_to_dict(instance: Any) -> dict[str, Any]:
35
+ mapper = sa_inspect(instance).mapper
36
+ return {attr.key: getattr(instance, attr.key) for attr in mapper.column_attrs}
37
+
38
+
39
+ def _require_table_name(table_name: str | None) -> str:
40
+ if table_name is None:
41
+ raise ValueError("table_name is required.")
42
+ return table_name
43
+
44
+
45
+ def _require_exactly_one_source(config: SqlDatabaseConfig | None, session: Any | None) -> None:
46
+ if (config is None) == (session is None):
47
+ raise ValueError("Provide exactly one of 'config' or 'session'.")
48
+
49
+
50
+ class SqlRepository(LifecycleCore):
51
+ """Single-row get/insert/update/delete against one table, by primary key.
52
+
53
+ A thin repository layer over SqlDatabaseResource + SqlModelRegistry for
54
+ consumers that want cheap transactional CRUD without pulling in
55
+ DataGateway/DataHelper's dataframe-oriented machinery. Rows are plain
56
+ ``dict[str, Any]``, never a DataFrame.
57
+
58
+ Exactly one of ``config``/``session`` must be given:
59
+
60
+ - ``config``: the repository owns a private ``SqlDatabaseResource`` and a
61
+ fresh, ``query_only``-guarded session per call, committing after every
62
+ write. ``query_only`` defaults to ``True`` here and always wins over
63
+ whatever ``config.query_only`` says — insert/update/delete raise via the
64
+ same ``ReadOnlySession`` guard SqlDatabaseResource uses until a caller
65
+ passes ``query_only=False`` explicitly. This is deliberate: a ``config``
66
+ object may be shared with or reused from a context where writes are
67
+ already enabled, and a repository shouldn't silently inherit write
68
+ access nobody asked it for.
69
+ - ``session``: an already-open ``Session`` supplied by the caller (e.g.
70
+ from ``SqlUnitOfWork``, or hand-rolled session code composing several
71
+ repositories into one transaction). Writes ``flush()`` instead of
72
+ ``commit()`` — the caller controls the transaction boundary — and
73
+ closing this repository never closes the shared session. ``query_only``
74
+ is accepted for signature uniformity but is inert here: the session's
75
+ own class (``ReadOnlySession`` or not) already determines writability,
76
+ since this path never constructs a new guarded session.
77
+
78
+ ``pk`` accepts a scalar or a tuple for composite keys, mirroring
79
+ ``Session.get()``'s own signature.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ config: SqlDatabaseConfig | None = None,
85
+ table_name: str | None = None,
86
+ *,
87
+ schema: str | None = None,
88
+ query_only: bool = True,
89
+ session: Session | None = None,
90
+ ) -> None:
91
+ table_name = _require_table_name(table_name)
92
+ _require_exactly_one_source(config, session)
93
+ self.logger = Logger.default_logger(logger_name=self.__class__.__name__)
94
+ super().__init__()
95
+ self._owns_resource = session is None
96
+ self._session_override = session
97
+ if session is not None:
98
+ self._resource = None
99
+ engine = session.bind
100
+ else:
101
+ effective_config = config.model_copy(update={"query_only": query_only})
102
+ self._resource = SqlDatabaseResource(effective_config)
103
+ engine = self._resource.engine
104
+ self._model = get_global_registry().get_model(engine, table_name, schema=schema)
105
+
106
+ def _cleanup(self) -> None:
107
+ if self._resource is not None:
108
+ self._resource.close()
109
+
110
+ @property
111
+ def engine(self) -> Any:
112
+ return self._resource.engine if self._resource is not None else self._session_override.bind
113
+
114
+ @contextmanager
115
+ def _session_scope(self):
116
+ if self._session_override is not None:
117
+ yield self._session_override
118
+ else:
119
+ with self._resource.session() as session:
120
+ yield session
121
+
122
+ def _finalize_write(self, session: Session) -> None:
123
+ if self._owns_resource:
124
+ session.commit()
125
+ else:
126
+ session.flush()
127
+
128
+ def get(self, pk: Any) -> dict[str, Any] | None:
129
+ with self._session_scope() as session:
130
+ instance = session.get(self._model, pk)
131
+ return _row_to_dict(instance) if instance is not None else None
132
+
133
+ def insert(self, values: dict[str, Any]) -> dict[str, Any]:
134
+ with self._session_scope() as session:
135
+ instance = self._model(**values)
136
+ session.add(instance)
137
+ self._finalize_write(session)
138
+ session.refresh(instance)
139
+ return _row_to_dict(instance)
140
+
141
+ def update(self, pk: Any, values: dict[str, Any]) -> dict[str, Any] | None:
142
+ with self._session_scope() as session:
143
+ instance = session.get(self._model, pk)
144
+ if instance is None:
145
+ return None
146
+ for key, value in values.items():
147
+ setattr(instance, key, value)
148
+ self._finalize_write(session)
149
+ session.refresh(instance)
150
+ return _row_to_dict(instance)
151
+
152
+ def delete(self, pk: Any) -> bool:
153
+ with self._session_scope() as session:
154
+ instance = session.get(self._model, pk)
155
+ if instance is None:
156
+ return False
157
+ session.delete(instance)
158
+ self._finalize_write(session)
159
+ return True
160
+
161
+
162
+ class AsyncSqlRepository(LifecycleCore):
163
+ """Async counterpart to :class:`SqlRepository` — see its docstring,
164
+ including the ``config``/``session`` and ``query_only`` semantics.
165
+
166
+ Async-only by design, mirroring ``AsyncSqlDatabaseResource``: real
167
+ resource acquisition (the engine, the reflected model) happens in
168
+ ``__aenter__``, not ``__init__``, since resolving the engine requires
169
+ awaiting ``EngineRegistry.get_or_create_async()`` (``config`` path) or
170
+ ``SqlModelRegistry.get_model_async()`` (``session`` path either way).
171
+ """
172
+
173
+ def __init__(
174
+ self,
175
+ config: SqlDatabaseConfig | None = None,
176
+ table_name: str | None = None,
177
+ *,
178
+ schema: str | None = None,
179
+ query_only: bool = True,
180
+ session: AsyncSession | None = None,
181
+ ) -> None:
182
+ table_name = _require_table_name(table_name)
183
+ _require_exactly_one_source(config, session)
184
+ self.logger = Logger.default_logger(logger_name=self.__class__.__name__)
185
+ self._config = (
186
+ config.model_copy(update={"query_only": query_only}) if config is not None else None
187
+ )
188
+ self._table_name = table_name
189
+ self._schema = schema
190
+ self._session_override = session
191
+ self._owns_resource = session is None
192
+ self._resource: AsyncSqlDatabaseResource | None = None
193
+ self._model: type[Any] | None = None
194
+ super().__init__()
195
+
196
+ async def __aenter__(self) -> AsyncSqlRepository:
197
+ await super().__aenter__()
198
+ if self._session_override is not None:
199
+ engine = self._session_override.bind
200
+ else:
201
+ self._resource = await AsyncSqlDatabaseResource(self._config).__aenter__()
202
+ engine = self._resource.engine
203
+ self._model = await get_global_registry().get_model_async(
204
+ engine, self._table_name, schema=self._schema
205
+ )
206
+ return self
207
+
208
+ async def _acleanup(self) -> None:
209
+ if self._resource is not None:
210
+ await self._resource.aclose()
211
+
212
+ @property
213
+ def engine(self) -> Any:
214
+ if self._resource is not None:
215
+ return self._resource.engine
216
+ if self._session_override is not None:
217
+ return self._session_override.bind
218
+ raise RuntimeError("AsyncSqlRepository is not bound — use 'async with'.")
219
+
220
+ @asynccontextmanager
221
+ async def _session_scope(self):
222
+ if self._session_override is not None:
223
+ yield self._session_override
224
+ else:
225
+ async with self._resource.session() as session:
226
+ yield session
227
+
228
+ async def _finalize_write(self, session: AsyncSession) -> None:
229
+ if self._owns_resource:
230
+ await session.commit()
231
+ else:
232
+ await session.flush()
233
+
234
+ async def get(self, pk: Any) -> dict[str, Any] | None:
235
+ async with self._session_scope() as session:
236
+ instance = await session.get(self._model, pk)
237
+ return _row_to_dict(instance) if instance is not None else None
238
+
239
+ async def insert(self, values: dict[str, Any]) -> dict[str, Any]:
240
+ async with self._session_scope() as session:
241
+ instance = self._model(**values)
242
+ session.add(instance)
243
+ await self._finalize_write(session)
244
+ await session.refresh(instance)
245
+ return _row_to_dict(instance)
246
+
247
+ async def update(self, pk: Any, values: dict[str, Any]) -> dict[str, Any] | None:
248
+ async with self._session_scope() as session:
249
+ instance = await session.get(self._model, pk)
250
+ if instance is None:
251
+ return None
252
+ for key, value in values.items():
253
+ setattr(instance, key, value)
254
+ await self._finalize_write(session)
255
+ await session.refresh(instance)
256
+ return _row_to_dict(instance)
257
+
258
+ async def delete(self, pk: Any) -> bool:
259
+ async with self._session_scope() as session:
260
+ instance = await session.get(self._model, pk)
261
+ if instance is None:
262
+ return False
263
+ await session.delete(instance)
264
+ await self._finalize_write(session)
265
+ return True
266
+
267
+
268
+ class SqlUnitOfWork:
269
+ """Shares one transaction across several ``SqlRepository`` instances,
270
+ potentially against different tables, so their writes commit or roll back
271
+ together.
272
+
273
+ Not a ``LifecycleCore`` subclass: ``LifecycleCore.__exit__`` is marked
274
+ ``@final`` specifically so cleanup-suppression logic can't be overridden,
275
+ but that also means there's no hook that receives exception info to
276
+ decide "commit on success, roll back on exception" — exactly what a unit
277
+ of work needs. A small hand-rolled context manager is simpler than
278
+ fighting that sealed contract.
279
+
280
+ ``query_only`` defaults to ``True``, consistent with ``SqlRepository``'s
281
+ own default — pass ``query_only=False`` explicitly to actually write.
282
+ """
283
+
284
+ def __init__(self, config: SqlDatabaseConfig, *, query_only: bool = True) -> None:
285
+ effective_config = config.model_copy(update={"query_only": query_only})
286
+ self._resource = SqlDatabaseResource(effective_config)
287
+ self._session: Session = self._resource.session()
288
+ self._repositories: list[SqlRepository] = []
289
+
290
+ def repository(self, table_name: str, *, schema: str | None = None) -> SqlRepository:
291
+ repo = SqlRepository(session=self._session, table_name=table_name, schema=schema)
292
+ self._repositories.append(repo)
293
+ return repo
294
+
295
+ def __enter__(self) -> SqlUnitOfWork:
296
+ return self
297
+
298
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
299
+ try:
300
+ if exc_type is None:
301
+ self._session.commit()
302
+ else:
303
+ self._session.rollback()
304
+ finally:
305
+ # Each handed-out repository has its own GC leak-warning
306
+ # finalizer (see boti.core.lifecycle) even though its own
307
+ # _cleanup() is a no-op in session-injected mode — close them
308
+ # here so that finalizer doesn't fire spuriously.
309
+ for repo in self._repositories:
310
+ repo.close()
311
+ self._session.close()
312
+ self._resource.close()
313
+
314
+
315
+ class AsyncSqlUnitOfWork:
316
+ """Async counterpart to :class:`SqlUnitOfWork` — see its docstring.
317
+
318
+ ``repository()`` is async here (unlike the sync version) because binding
319
+ an ``AsyncSqlRepository`` to a shared session requires awaiting
320
+ ``SqlModelRegistry.get_model_async()`` — the returned repository is
321
+ already fully set up, so callers don't need to ``async with`` it too.
322
+ """
323
+
324
+ def __init__(self, config: SqlDatabaseConfig, *, query_only: bool = True) -> None:
325
+ self._config = config.model_copy(update={"query_only": query_only})
326
+ self._resource: AsyncSqlDatabaseResource | None = None
327
+ self._session: AsyncSession | None = None
328
+ self._repositories: list[AsyncSqlRepository] = []
329
+
330
+ async def repository(self, table_name: str, *, schema: str | None = None) -> AsyncSqlRepository:
331
+ repo = AsyncSqlRepository(session=self._session, table_name=table_name, schema=schema)
332
+ await repo.__aenter__()
333
+ self._repositories.append(repo)
334
+ return repo
335
+
336
+ async def __aenter__(self) -> AsyncSqlUnitOfWork:
337
+ self._resource = await AsyncSqlDatabaseResource(self._config).__aenter__()
338
+ self._session = self._resource.session()
339
+ return self
340
+
341
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
342
+ try:
343
+ if exc_type is None:
344
+ await self._session.commit()
345
+ else:
346
+ await self._session.rollback()
347
+ finally:
348
+ # See SqlUnitOfWork.__exit__'s comment: each handed-out repository
349
+ # has its own GC leak-warning finalizer even though its _acleanup()
350
+ # is a no-op in session-injected mode.
351
+ for repo in self._repositories:
352
+ await repo.aclose()
353
+ await self._session.close()
354
+ await self._resource.aclose()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boti-data
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Data infrastructure for the Boti ecosystem
5
5
  Author: Luis Valverde
6
6
  Maintainer-email: Luis Valverde <lvalverdeb@gmail.com>
@@ -920,6 +920,44 @@ async with AsyncSqlRepository(config, "users", query_only=False) as users:
920
920
  row = await users.get(1)
921
921
  ```
922
922
 
923
+ #### Multi-table atomic transactions
924
+
925
+ By default each `SqlRepository` owns its own connection and commits after every write — fine for independent single-row operations, but not when several writes (potentially across different tables) need to succeed or fail together. Pass an already-open `session` instead of `config` to bind a repository to a transaction you control; writes `flush()` instead of committing, so nothing is durable until you commit the session yourself:
926
+
927
+ ```python
928
+ with SqlDatabaseResource(config) as db, db.session() as session:
929
+ with SqlRepository(session=session, table_name="audit_log") as audit_log:
930
+ with SqlRepository(session=session, table_name="embeddings") as embeddings:
931
+ audit_log.insert({"event": "enroll"})
932
+ embeddings.insert({"subject_id": 1, "vector": [...]})
933
+ session.commit() # both rows commit together, or neither does on an exception
934
+ ```
935
+
936
+ `SqlUnitOfWork` wraps this pattern for the common case — one config, several tables, one commit/rollback:
937
+
938
+ ```python
939
+ from boti_data import SqlDatabaseConfig, SqlUnitOfWork
940
+
941
+ config = SqlDatabaseConfig(connection_url="postgresql+psycopg://user:pass@host/mydb")
942
+
943
+ with SqlUnitOfWork(config, query_only=False) as uow:
944
+ uow.repository("audit_log").insert({"event": "enroll"})
945
+ uow.repository("embeddings").insert({"subject_id": 1, "vector": [...]})
946
+ # commits both on clean exit; rolls back both on any exception
947
+ ```
948
+
949
+ `query_only` on a `session`-bound `SqlRepository` is inert — the session's own class (e.g. a `ReadOnlySession`) already determines writability, since this path never builds a new guarded session itself. `AsyncSqlUnitOfWork` mirrors `SqlUnitOfWork`, with an async `repository()` method (returns an already-set-up `AsyncSqlRepository`, so callers don't need to `async with` each one individually):
950
+
951
+ ```python
952
+ from boti_data import AsyncSqlUnitOfWork
953
+
954
+ async with AsyncSqlUnitOfWork(config, query_only=False) as uow:
955
+ audit_log = await uow.repository("audit_log")
956
+ embeddings = await uow.repository("embeddings")
957
+ await audit_log.insert({"event": "enroll"})
958
+ await embeddings.insert({"subject_id": 1, "vector": [...]})
959
+ ```
960
+
923
961
  ### Nearest-neighbour search (pgvector)
924
962
 
925
963
  `boti_data.db.nearest_neighbors`/`vector_distance` build an `ORDER BY <distance> LIMIT k` statement against a pgvector column, with the query vector bound as a driver parameter — never string-interpolated into the SQL, regardless of how large the vector is. Requires `boti-data[pgvector]` (see [Installation](#installation)) and a table reflected via `SqlAlchemyModelBuilder`/`SqlRepository`/`DataGateway`.
@@ -1,186 +0,0 @@
1
- """
2
- Lightweight single-row transactional access (get/insert/update/delete by
3
- primary key), returning plain dicts instead of a DataFrame.
4
-
5
- boti_data's flagship API (DataGateway/DataHelper) is dataframe-shaped and
6
- bulk-oriented, pulling in the dask/pandas/polars dependency chain even for a
7
- consumer that only ever touches one row at a time (e.g. an OLTP request
8
- handler at p95 <300ms). SqlRepository/AsyncSqlRepository sit directly on
9
- SqlDatabaseResource/AsyncSqlDatabaseResource + SqlModelRegistry — both already
10
- free of that dependency chain — so this module can be imported and used
11
- without dask/pandas/polars installed at all.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- from typing import Any
17
-
18
- from boti.core.lifecycle import LifecycleCore
19
- from boti.core.logger import Logger
20
- from sqlalchemy import inspect as sa_inspect
21
-
22
- from boti_data.db.sql_config import SqlDatabaseConfig
23
- from boti_data.db.sql_model_registry import get_global_registry
24
- from boti_data.db.sql_resource import AsyncSqlDatabaseResource, SqlDatabaseResource
25
-
26
- __all__ = ["AsyncSqlRepository", "SqlRepository"]
27
-
28
-
29
- def _row_to_dict(instance: Any) -> dict[str, Any]:
30
- mapper = sa_inspect(instance).mapper
31
- return {attr.key: getattr(instance, attr.key) for attr in mapper.column_attrs}
32
-
33
-
34
- class SqlRepository(LifecycleCore):
35
- """Single-row get/insert/update/delete against one table, by primary key.
36
-
37
- A thin repository layer over SqlDatabaseResource + SqlModelRegistry for
38
- consumers that want cheap transactional CRUD without pulling in
39
- DataGateway/DataHelper's dataframe-oriented machinery. Rows are plain
40
- ``dict[str, Any]``, never a DataFrame.
41
-
42
- ``query_only`` defaults to ``True`` here and always wins over whatever
43
- ``config.query_only`` says — insert/update/delete raise via the same
44
- ``ReadOnlySession`` guard SqlDatabaseResource uses until a caller passes
45
- ``query_only=False`` explicitly. This is deliberate: a ``config`` object
46
- may be shared with or reused from a context where writes are already
47
- enabled, and a repository shouldn't silently inherit write access nobody
48
- asked it for.
49
-
50
- ``pk`` accepts a scalar or a tuple for composite keys, mirroring
51
- ``Session.get()``'s own signature.
52
- """
53
-
54
- def __init__(
55
- self,
56
- config: SqlDatabaseConfig,
57
- table_name: str,
58
- *,
59
- schema: str | None = None,
60
- query_only: bool = True,
61
- ) -> None:
62
- self.logger = Logger.default_logger(logger_name=self.__class__.__name__)
63
- super().__init__()
64
- effective_config = config.model_copy(update={"query_only": query_only})
65
- self._resource = SqlDatabaseResource(effective_config)
66
- self._model = get_global_registry().get_model(
67
- self._resource.engine, table_name, schema=schema
68
- )
69
-
70
- def _cleanup(self) -> None:
71
- self._resource.close()
72
-
73
- @property
74
- def engine(self) -> Any:
75
- return self._resource.engine
76
-
77
- def get(self, pk: Any) -> dict[str, Any] | None:
78
- with self._resource.session() as session:
79
- instance = session.get(self._model, pk)
80
- return _row_to_dict(instance) if instance is not None else None
81
-
82
- def insert(self, values: dict[str, Any]) -> dict[str, Any]:
83
- with self._resource.session() as session:
84
- instance = self._model(**values)
85
- session.add(instance)
86
- session.commit()
87
- session.refresh(instance)
88
- return _row_to_dict(instance)
89
-
90
- def update(self, pk: Any, values: dict[str, Any]) -> dict[str, Any] | None:
91
- with self._resource.session() as session:
92
- instance = session.get(self._model, pk)
93
- if instance is None:
94
- return None
95
- for key, value in values.items():
96
- setattr(instance, key, value)
97
- session.commit()
98
- session.refresh(instance)
99
- return _row_to_dict(instance)
100
-
101
- def delete(self, pk: Any) -> bool:
102
- with self._resource.session() as session:
103
- instance = session.get(self._model, pk)
104
- if instance is None:
105
- return False
106
- session.delete(instance)
107
- session.commit()
108
- return True
109
-
110
-
111
- class AsyncSqlRepository(LifecycleCore):
112
- """Async counterpart to :class:`SqlRepository` — see its docstring,
113
- including the ``query_only`` default/override behaviour.
114
-
115
- Async-only by design, mirroring ``AsyncSqlDatabaseResource``: real
116
- resource acquisition (the engine, the reflected model) happens in
117
- ``__aenter__``, not ``__init__``, since resolving the engine requires
118
- awaiting ``EngineRegistry.get_or_create_async()``.
119
- """
120
-
121
- def __init__(
122
- self,
123
- config: SqlDatabaseConfig,
124
- table_name: str,
125
- *,
126
- schema: str | None = None,
127
- query_only: bool = True,
128
- ) -> None:
129
- self.logger = Logger.default_logger(logger_name=self.__class__.__name__)
130
- self._config = config.model_copy(update={"query_only": query_only})
131
- self._table_name = table_name
132
- self._schema = schema
133
- self._resource: AsyncSqlDatabaseResource | None = None
134
- self._model: type[Any] | None = None
135
- super().__init__()
136
-
137
- async def __aenter__(self) -> AsyncSqlRepository:
138
- await super().__aenter__()
139
- self._resource = await AsyncSqlDatabaseResource(self._config).__aenter__()
140
- self._model = await get_global_registry().get_model_async(
141
- self._resource.engine, self._table_name, schema=self._schema
142
- )
143
- return self
144
-
145
- async def _acleanup(self) -> None:
146
- if self._resource is not None:
147
- await self._resource.aclose()
148
-
149
- @property
150
- def engine(self) -> Any:
151
- if self._resource is None:
152
- raise RuntimeError("AsyncSqlRepository is not bound — use 'async with'.")
153
- return self._resource.engine
154
-
155
- async def get(self, pk: Any) -> dict[str, Any] | None:
156
- async with self._resource.session() as session:
157
- instance = await session.get(self._model, pk)
158
- return _row_to_dict(instance) if instance is not None else None
159
-
160
- async def insert(self, values: dict[str, Any]) -> dict[str, Any]:
161
- async with self._resource.session() as session:
162
- instance = self._model(**values)
163
- session.add(instance)
164
- await session.commit()
165
- await session.refresh(instance)
166
- return _row_to_dict(instance)
167
-
168
- async def update(self, pk: Any, values: dict[str, Any]) -> dict[str, Any] | None:
169
- async with self._resource.session() as session:
170
- instance = await session.get(self._model, pk)
171
- if instance is None:
172
- return None
173
- for key, value in values.items():
174
- setattr(instance, key, value)
175
- await session.commit()
176
- await session.refresh(instance)
177
- return _row_to_dict(instance)
178
-
179
- async def delete(self, pk: Any) -> bool:
180
- async with self._resource.session() as session:
181
- instance = await session.get(self._model, pk)
182
- if instance is None:
183
- return False
184
- await session.delete(instance)
185
- await session.commit()
186
- return True
File without changes