kiba-core 0.5.3.dev76__tar.gz → 0.5.3.dev78__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 (143) hide show
  1. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/CHANGELOG.md +5 -0
  2. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/PKG-INFO +1 -1
  3. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/store/database.py +11 -8
  4. kiba_core-0.5.3.dev78/core/store/entity_repository.py +205 -0
  5. kiba_core-0.5.3.dev78/core/store/retriever.py +298 -0
  6. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/store/saver.py +1 -1
  7. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/kiba_core.egg-info/PKG-INFO +1 -1
  8. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/kiba_core.egg-info/SOURCES.txt +2 -0
  9. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/pyproject.toml +1 -1
  10. kiba_core-0.5.3.dev78/tests/test_entity_repository.py +266 -0
  11. kiba_core-0.5.3.dev76/core/store/retriever.py +0 -206
  12. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/.github/pull_request_template.md +0 -0
  13. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/.github/workflows/deploy.yml +0 -0
  14. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/.github/workflows/pull-request.yml +0 -0
  15. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/.github/workflows/release.yml +0 -0
  16. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/.gitignore +0 -0
  17. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/Dockerfile +0 -0
  18. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/README.md +0 -0
  19. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/__init__.py +0 -0
  20. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/__init__.py +0 -0
  21. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/api_request.py +0 -0
  22. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/api_response.py +0 -0
  23. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/authorizer.py +0 -0
  24. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/default_routes.py +0 -0
  25. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/health.py +0 -0
  26. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/json_route.py +0 -0
  27. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/middleware/__init__.py +0 -0
  28. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/middleware/database_connection_middleware.py +0 -0
  29. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/middleware/exception_handling_middleware.py +0 -0
  30. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/middleware/logging_middleware.py +0 -0
  31. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/middleware/origin_ip_middleware.py +0 -0
  32. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/middleware/server_headers_middleware.py +0 -0
  33. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/openapi.py +0 -0
  34. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/rate_limit.py +0 -0
  35. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/request_context.py +0 -0
  36. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/route.py +0 -0
  37. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/route_auth.py +0 -0
  38. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/route_metadata.py +0 -0
  39. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/api/streaming_json_route.py +0 -0
  40. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/aws_requester.py +0 -0
  41. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/caching/__init__.py +0 -0
  42. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/caching/cache.py +0 -0
  43. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/caching/dict_cache.py +0 -0
  44. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/caching/file_cache.py +0 -0
  45. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/cron/__init__.py +0 -0
  46. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/cron/api_request_cron_job_processor.py +0 -0
  47. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/cron/cron_executor.py +0 -0
  48. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/cron/cron_job_processor.py +0 -0
  49. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/cron/model.py +0 -0
  50. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/exceptions.py +0 -0
  51. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/http/__init__.py +0 -0
  52. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/http/basic_authentication.py +0 -0
  53. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/http/jwt.py +0 -0
  54. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/http/rest_method.py +0 -0
  55. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/locks/__init__.py +0 -0
  56. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/locks/lock.py +0 -0
  57. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/locks/model.py +0 -0
  58. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/locks/sql.py +0 -0
  59. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/logging.py +0 -0
  60. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/notifications/__init__.py +0 -0
  61. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/notifications/discord_client.py +0 -0
  62. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/notifications/notification_client.py +0 -0
  63. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/notifications/slack_client.py +0 -0
  64. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/py.typed +0 -0
  65. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/queues/__init__.py +0 -0
  66. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/queues/aqs.py +0 -0
  67. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/queues/cosmos.py +0 -0
  68. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/queues/message_queue.py +0 -0
  69. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/queues/message_queue_processor.py +0 -0
  70. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/queues/model.py +0 -0
  71. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/queues/sql.py +0 -0
  72. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/queues/sqs.py +0 -0
  73. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/requester/__init__.py +0 -0
  74. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/requester/requester.py +0 -0
  75. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/s3_manager.py +0 -0
  76. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/service_client.py +0 -0
  77. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/store/__init__.py +0 -0
  78. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/util/__init__.py +0 -0
  79. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/util/async_util.py +0 -0
  80. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/util/chain_util.py +0 -0
  81. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/util/date_util.py +0 -0
  82. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/util/dict_util.py +0 -0
  83. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/util/file_util.py +0 -0
  84. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/util/hashing_util.py +0 -0
  85. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/util/http_util.py +0 -0
  86. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/util/json_util.py +0 -0
  87. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/util/list_util.py +0 -0
  88. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/util/process_util.py +0 -0
  89. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/util/string_util.py +0 -0
  90. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/util/typing_util.py +0 -0
  91. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/util/url_util.py +0 -0
  92. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/util/value_holder.py +0 -0
  93. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/web3/__init__.py +0 -0
  94. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/web3/eth_client.py +0 -0
  95. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/web3/eth_client_registry.py +0 -0
  96. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/core/web3/multicall3.py +0 -0
  97. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/kiba_core.egg-info/dependency_links.txt +0 -0
  98. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/kiba_core.egg-info/requires.txt +0 -0
  99. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/kiba_core.egg-info/top_level.txt +0 -0
  100. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/makefile +0 -0
  101. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/setup.cfg +0 -0
  102. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/__init__.py +0 -0
  103. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/api/test_api_response.py +0 -0
  104. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/api/test_authorizer.py +0 -0
  105. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/api/test_exception_handling_middleware.py +0 -0
  106. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/api/test_json_route.py +0 -0
  107. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/api/test_openapi.py +0 -0
  108. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/api/test_origin_ip_middleware.py +0 -0
  109. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/api/test_rate_limit.py +0 -0
  110. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/api/test_request_context.py +0 -0
  111. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/api/test_route.py +0 -0
  112. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/api/test_streaming_json_route.py +0 -0
  113. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/caching/test_dict_cache.py +0 -0
  114. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/caching/test_file_cache.py +0 -0
  115. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/cron/__init__.py +0 -0
  116. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/cron/test_cron_executor.py +0 -0
  117. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/locks/test_sql_lock.py +0 -0
  118. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/queues/conftest.py +0 -0
  119. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/queues/test_aqs_queue.py +0 -0
  120. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/queues/test_cosmos_queue.py +0 -0
  121. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/queues/test_message_queue_processor.py +0 -0
  122. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/queues/test_sql_queue.py +0 -0
  123. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/queues/test_sqs_queue.py +0 -0
  124. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/requester/__init__.py +0 -0
  125. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/requester/test_requester.py +0 -0
  126. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/test_database.py +0 -0
  127. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/test_exceptions.py +0 -0
  128. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/test_logging.py +0 -0
  129. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/util/__init__.py +0 -0
  130. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/util/test_async_util.py +0 -0
  131. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/util/test_chain_util.py +0 -0
  132. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/util/test_date_util.py +0 -0
  133. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/util/test_dict_util.py +0 -0
  134. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/util/test_file_util.py +0 -0
  135. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/util/test_json_util.py +0 -0
  136. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/util/test_list_util.py +0 -0
  137. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/util/test_process_util.py +0 -0
  138. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/util/test_string_util.py +0 -0
  139. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/util/test_url_util.py +0 -0
  140. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/web3/__init__.py +0 -0
  141. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/web3/test_eth_client_registry.py +0 -0
  142. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/tests/web3/test_rest_eth_client.py +0 -0
  143. {kiba_core-0.5.3.dev76 → kiba_core-0.5.3.dev78}/uv.lock +0 -0
@@ -8,6 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
8
8
  ## [Unreleased]
9
9
 
10
10
  ### Added
11
+ - [MINOR] Added `core.store.entity_repository.EntityRepository`, which reads and writes rows of a table as pydantic models: `create`, `update`, `upsert`, `upsert_many` (postgres and sqlite), `delete`, `list_many`, `get_first`, `get`, `get_one` and `get_one_or_none`. It exposes UUID columns as strings and datetimes as aware utc datetimes, dumps pydantic values in JSON columns, generates ids for UUID primary keys and stamps `createdDate`/`updatedDate` when the table has them; subclasses can override `_convert_value_from_db`/`_convert_value_to_db` for other columns
12
+ - [MINOR] Added `UUIDFieldFilter` (accepts `uuid.UUID` or `str` values) and module-level `apply_orders`, `apply_field_filters` and `get_field_filters_conditions` (conditions usable on selects, updates and deletes) to `core.store.retriever`; `Retriever` now uses them
13
+ - [PATCH] Updated `DateFieldFilter` to convert values to utc for the column (naive for columns without a timezone, aware otherwise) so aware datetimes in any timezone compare correctly
14
+ - [PATCH] Updated `IntegerFieldFilter` to bind values with the column's type so values outside the int64 range work with `Numeric` columns on asyncpg
15
+ - [MINOR] Added `Database.get_dialect_name`
11
16
  - [PATCH] Fixed `json_route` and `streaming_json_route` returning a 500 when the JSON body isn't an object (e.g. `true` or `[1]`); they now raise `BadRequestException` (400)
12
17
  - [MINOR] Added `shouldNotifyRetriedFailures` (default `True`) to `MessageQueueProcessor`: when `False`, failure notifications are only sent once the queue gives up on a message, not for failures it will retry. `MessageQueue.fail_message` now returns whether the queue scheduled another attempt: `SqlMessageQueue` and `CosmosMessageQueue` return `True` until `maxAttempts` is reached, while `SqsMessageQueue` and `AqsMessageQueue` always return `False` as the platform decides on redelivery, so they keep notifying every failure
13
18
  - [MAJOR] Updated `CosmosMessageQueue` to retry failed messages like `SqlMessageQueue`: added `maxAttempts` (default 3) and `failureRetryDelaySeconds` (default 60). `fail_message` reschedules the message with linear backoff and stores `lastError`, and a visible message whose lease expired counts as an attempt when it is claimed again. Once `postCount` reaches `maxAttempts` the message is removed, along with its deduplication item. Previously failed or abandoned messages reappeared once their lease expired and were retried forever
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev76
3
+ Version: 0.5.3.dev78
4
4
  Summary: Kiba Labs' python utilities
5
5
  Project-URL: repository, https://github.com/kibalabs/core-py
6
6
  Requires-Python: <4,>=3.12
@@ -4,7 +4,7 @@ import datetime
4
4
  import typing
5
5
  import weakref
6
6
  from collections.abc import AsyncIterator
7
- from typing import TypeVar
7
+ from typing import TypeVarTuple
8
8
 
9
9
  import sqlalchemy
10
10
  import sqlalchemy.exc
@@ -20,7 +20,7 @@ from core.exceptions import InternalServerErrorException
20
20
  from core.util import json_util
21
21
 
22
22
  DatabaseConnection = AsyncConnection
23
- ResultType = TypeVar('ResultType', bound=tuple) # type: ignore[type-arg]
23
+ ResultType = TypeVarTuple('ResultType')
24
24
 
25
25
 
26
26
  class Database:
@@ -61,10 +61,13 @@ class Database:
61
61
  await self._engine.dispose()
62
62
  self._engine = None
63
63
 
64
- def now(self, seconds: float = 0) -> sqlalchemy.ColumnElement[datetime.datetime]:
64
+ def get_dialect_name(self) -> str:
65
65
  if not self._engine:
66
66
  raise InternalServerErrorException(message='Engine has not been established. Please called collect() first.')
67
- dialectName = self._engine.dialect.name
67
+ return self._engine.dialect.name
68
+
69
+ def now(self, seconds: float = 0) -> sqlalchemy.ColumnElement[datetime.datetime]:
70
+ dialectName = self.get_dialect_name()
68
71
  if dialectName == 'postgresql':
69
72
  return sqlalchemy.func.now() + datetime.timedelta(seconds=seconds)
70
73
  if dialectName == 'sqlite':
@@ -137,10 +140,10 @@ class Database:
137
140
  self._connectionContext.reset(token)
138
141
 
139
142
  @typing.overload
140
- async def execute(self, query: TypedReturnsRows[ResultType], connection: DatabaseConnection | None = None) -> Result[ResultType]: ...
143
+ async def execute(self, query: TypedReturnsRows[*ResultType], connection: DatabaseConnection | None = None) -> Result[*ResultType]: ...
141
144
  @typing.overload
142
- async def execute(self, query: sqlalchemy.sql.Executable, connection: DatabaseConnection | None = None) -> Result[typing.Any]: ... # type: ignore[explicit-any]
143
- async def execute(self, query: sqlalchemy.sql.Executable, connection: DatabaseConnection | None = None) -> Result[typing.Any]: # type: ignore[explicit-any]
145
+ async def execute(self, query: sqlalchemy.sql.Executable, connection: DatabaseConnection | None = None) -> Result[*tuple[typing.Any, ...]]: ... # type: ignore[explicit-any]
146
+ async def execute(self, query: sqlalchemy.sql.Executable, connection: DatabaseConnection | None = None) -> Result[*tuple[typing.Any, ...]]: # type: ignore[explicit-any]
144
147
  if not self._engine:
145
148
  raise InternalServerErrorException(message='Connection has not been established. Please called collect() first.')
146
149
  if not connection:
@@ -149,4 +152,4 @@ class Database:
149
152
  raise InternalServerErrorException(message='No connection found. Please provide a connection or call create_context_connection() for the context.')
150
153
  if isinstance(query, UpdateBase):
151
154
  self._connectionsWithWrites.add(connection)
152
- return typing.cast(Result[typing.Any], await connection.execute(statement=query)) # type: ignore[explicit-any]
155
+ return typing.cast(Result[*tuple[typing.Any, ...]], await connection.execute(statement=query)) # type: ignore[explicit-any]
@@ -0,0 +1,205 @@
1
+ import typing
2
+ import uuid
3
+ from collections.abc import Sequence
4
+
5
+ import sqlalchemy
6
+ from pydantic import BaseModel
7
+ from sqlalchemy.dialects import postgresql as sqlalchemy_psql
8
+ from sqlalchemy.dialects import sqlite as sqlalchemy_sqlite
9
+ from sqlalchemy.engine import Result
10
+ from sqlalchemy.engine import RowMapping
11
+ from sqlalchemy.sql import Select
12
+
13
+ from core.exceptions import KibaException
14
+ from core.exceptions import NotFoundException
15
+ from core.store.database import Database
16
+ from core.store.database import DatabaseConnection
17
+ from core.store.retriever import FieldFilter
18
+ from core.store.retriever import Order
19
+ from core.store.retriever import apply_field_filters
20
+ from core.store.retriever import apply_orders
21
+ from core.store.retriever import datetime_to_column_value
22
+ from core.store.retriever import get_field_filters_conditions
23
+ from core.store.retriever import uuid_from_value
24
+ from core.util import date_util
25
+
26
+ EntityType = typing.TypeVar('EntityType', bound=BaseModel)
27
+
28
+
29
+ class EntityRepository(typing.Generic[EntityType]): # noqa: UP046
30
+ """Reads and writes rows of a table as pydantic models.
31
+
32
+ UUID columns are exposed as strings, datetimes as aware utc datetimes and pydantic values in JSON columns are dumped
33
+ on write. `createdDate` and `updatedDate` columns are stamped automatically when the table has them. Override
34
+ `_convert_value_from_db` and `_convert_value_to_db` to add conversions for other columns, and `list_select_columns`
35
+ with `_get_field_values` to read extra (e.g. computed) columns into the model.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ table: sqlalchemy.Table,
41
+ modelClass: type[EntityType],
42
+ ) -> None:
43
+ self.table = table
44
+ self.modelClass = modelClass
45
+ try:
46
+ self.idColumn = next(column for column in table.columns if column.primary_key)
47
+ except StopIteration:
48
+ raise KibaException(f'Failed to find id column for table: {table.name}')
49
+
50
+ def _convert_value_from_db(self, column: sqlalchemy.Column[typing.Any], value: typing.Any | None) -> typing.Any | None: # type: ignore[explicit-any]
51
+ if value is None:
52
+ return None
53
+ if isinstance(column.type, sqlalchemy.Uuid):
54
+ return str(value)
55
+ if isinstance(column.type, sqlalchemy.DateTime):
56
+ return date_util.datetime_to_utc(dt=value)
57
+ return value
58
+
59
+ def _convert_value_to_db(self, column: sqlalchemy.Column[typing.Any], value: typing.Any | None) -> typing.Any | None: # type: ignore[explicit-any]
60
+ if value is None:
61
+ return None
62
+ if isinstance(column.type, sqlalchemy.Uuid):
63
+ return uuid_from_value(value=value)
64
+ if isinstance(column.type, sqlalchemy.DateTime):
65
+ return datetime_to_column_value(column=column, dt=value)
66
+ if isinstance(column.type, sqlalchemy.JSON) and isinstance(value, BaseModel):
67
+ return value.model_dump()
68
+ return value
69
+
70
+ def list_select_columns(self) -> list[sqlalchemy.ColumnElement[typing.Any]]: # type: ignore[explicit-any]
71
+ return list(self.table.columns)
72
+
73
+ def build_select(self) -> Select[*tuple[typing.Any, ...]]: # type: ignore[explicit-any]
74
+ return sqlalchemy.select(*self.list_select_columns())
75
+
76
+ def _get_field_values(self, row: RowMapping) -> dict[str, typing.Any]: # type: ignore[explicit-any]
77
+ return {column.key: self._convert_value_from_db(column=column, value=row[column]) for column in self.table.columns}
78
+
79
+ def from_row(self, row: RowMapping) -> EntityType:
80
+ return self.modelClass.model_validate(self._get_field_values(row=row))
81
+
82
+ def force_from_result(self, result: Result[*tuple[typing.Any, ...]]) -> EntityType: # type: ignore[explicit-any]
83
+ row = result.mappings().first()
84
+ if row is None:
85
+ raise NotFoundException
86
+ return self.from_row(row=row)
87
+
88
+ def _get_column_names(self) -> str:
89
+ return ', '.join(column.key for column in self.table.columns)
90
+
91
+ def _create_values(self, kwargs: dict[str, typing.Any], shouldAddCreatedDate: bool = False, shouldAddUpdatedDate: bool = False) -> dict[sqlalchemy.Column[typing.Any], typing.Any]: # type: ignore[explicit-any]
92
+ currentDate = date_util.datetime_from_now()
93
+ values: dict[sqlalchemy.Column[typing.Any], typing.Any] = {} # type: ignore[explicit-any]
94
+ # NOTE(krishan711): immutable tables (e.g. event logs) may have no `updatedDate` column, so this only stamps the
95
+ # columns the table actually defines.
96
+ createdDateColumn = self.table.c.get('createdDate')
97
+ if shouldAddCreatedDate and createdDateColumn is not None:
98
+ values[createdDateColumn] = self._convert_value_to_db(column=createdDateColumn, value=currentDate)
99
+ updatedDateColumn = self.table.c.get('updatedDate')
100
+ if shouldAddUpdatedDate and updatedDateColumn is not None:
101
+ values[updatedDateColumn] = self._convert_value_to_db(column=updatedDateColumn, value=currentDate)
102
+ for key, value in kwargs.items():
103
+ column = self.table.c.get(key)
104
+ if column is None:
105
+ raise KibaException(f'Unknown column: {key}. Valid columns are: {self._get_column_names()}')
106
+ values[column] = self._convert_value_to_db(column=column, value=value)
107
+ return values
108
+
109
+ def _add_generated_id(self, values: dict[sqlalchemy.Column[typing.Any], typing.Any]) -> None: # type: ignore[explicit-any]
110
+ if isinstance(self.idColumn.type, sqlalchemy.Uuid) and values.get(self.idColumn) is None:
111
+ values[self.idColumn] = uuid.uuid4()
112
+
113
+ def _create_upsert_insert(self, database: Database) -> sqlalchemy_psql.Insert | sqlalchemy_sqlite.Insert:
114
+ dialectName = database.get_dialect_name()
115
+ if dialectName == 'postgresql':
116
+ return sqlalchemy_psql.insert(self.table)
117
+ if dialectName == 'sqlite':
118
+ return sqlalchemy_sqlite.insert(self.table)
119
+ raise KibaException(f'EntityRepository upserts do not support dialect: {dialectName}')
120
+
121
+ # Writing
122
+
123
+ async def create(self, database: Database, connection: DatabaseConnection | None = None, **kwargs) -> EntityType: # type: ignore[no-untyped-def] # noqa: ANN003
124
+ createValues = self._create_values(kwargs=kwargs, shouldAddCreatedDate=True, shouldAddUpdatedDate=True)
125
+ self._add_generated_id(values=createValues)
126
+ result = await database.execute(query=self.table.insert().values(createValues).returning(*self.list_select_columns()), connection=connection)
127
+ return self.force_from_result(result=result)
128
+
129
+ async def update(self, database: Database, connection: DatabaseConnection | None = None, **kwargs) -> EntityType: # type: ignore[no-untyped-def] # noqa: ANN003
130
+ updateValues = self._create_values(kwargs=kwargs, shouldAddUpdatedDate=True)
131
+ idValue: typing.Any | None = updateValues.pop(self.idColumn, None) # type: ignore[explicit-any]
132
+ if idValue is None:
133
+ raise KibaException(f'Failed to find id value for update to {self.table.name}')
134
+ result = await database.execute(query=self.table.update().where(self.idColumn == idValue).values(updateValues).returning(*self.list_select_columns()), connection=connection)
135
+ return self.force_from_result(result=result)
136
+
137
+ async def upsert(self, database: Database, constraintColumnNames: list[str], connection: DatabaseConnection | None = None, **kwargs) -> EntityType: # type: ignore[no-untyped-def] # noqa: ANN003
138
+ updateValues = self._create_values(kwargs=kwargs, shouldAddUpdatedDate=True, shouldAddCreatedDate=False)
139
+ insertValues = self._create_values(kwargs=kwargs, shouldAddUpdatedDate=True, shouldAddCreatedDate=True)
140
+ self._add_generated_id(values=insertValues)
141
+ insertStatement = self._create_upsert_insert(database=database).values(insertValues)
142
+ constraintColumns = [self.table.c[columnName] for columnName in constraintColumnNames]
143
+ doUpdateStatement = insertStatement.on_conflict_do_update(
144
+ index_elements=constraintColumns,
145
+ set_=updateValues,
146
+ )
147
+ result = await database.execute(query=doUpdateStatement.returning(*self.list_select_columns()), connection=connection)
148
+ return self.force_from_result(result=result)
149
+
150
+ async def upsert_many(self, database: Database, constraintColumnNames: list[str], rowDicts: list[dict[str, typing.Any]], connection: DatabaseConnection | None = None) -> list[EntityType]: # type: ignore[explicit-any]
151
+ if not rowDicts:
152
+ return []
153
+ insertValuesList = [self._create_values(kwargs=row, shouldAddUpdatedDate=True, shouldAddCreatedDate=True) for row in rowDicts]
154
+ for insertValues in insertValuesList:
155
+ self._add_generated_id(values=insertValues)
156
+ constraintColumns = [self.table.c[columnName] for columnName in constraintColumnNames]
157
+ insertStatement = self._create_upsert_insert(database=database).values(insertValuesList)
158
+ excludedColumnKeys = {self.idColumn.key, 'createdDate'}
159
+ updateColumnKeys = {column.key for insertValues in insertValuesList for column in insertValues} - excludedColumnKeys
160
+ doUpdateStatement = insertStatement.on_conflict_do_update(
161
+ index_elements=constraintColumns,
162
+ set_={key: insertStatement.excluded[key] for key in updateColumnKeys},
163
+ )
164
+ result = await database.execute(query=doUpdateStatement.returning(*self.list_select_columns()), connection=connection)
165
+ return [self.from_row(row=row) for row in result.mappings().all()]
166
+
167
+ async def delete(self, database: Database, fieldFilters: Sequence[FieldFilter], connection: DatabaseConnection | None = None) -> None:
168
+ query = self.table.delete().where(*get_field_filters_conditions(table=self.table, fieldFilters=fieldFilters))
169
+ await database.execute(query=query, connection=connection)
170
+
171
+ # Reading
172
+
173
+ async def list_many(self, database: Database, fieldFilters: Sequence[FieldFilter] | None = None, orders: Sequence[Order] | None = None, limit: int | None = None, offset: int | None = None, connection: DatabaseConnection | None = None) -> list[EntityType]:
174
+ query = self.build_select()
175
+ if fieldFilters is not None:
176
+ query = apply_field_filters(query=query, table=self.table, fieldFilters=fieldFilters)
177
+ if orders is not None:
178
+ query = apply_orders(query=query, table=self.table, orders=orders)
179
+ if limit is not None:
180
+ query = query.limit(limit)
181
+ if offset is not None:
182
+ query = query.offset(offset)
183
+ result = await database.execute(query=query, connection=connection)
184
+ return [self.from_row(row=row) for row in result.mappings()]
185
+
186
+ async def get_first(self, database: Database, fieldFilters: Sequence[FieldFilter] | None = None, orders: Sequence[Order] | None = None, connection: DatabaseConnection | None = None) -> EntityType | None:
187
+ entities = await self.list_many(database=database, fieldFilters=fieldFilters, orders=orders, limit=1, connection=connection)
188
+ return next(iter(entities), None)
189
+
190
+ async def get(self, database: Database, idValue: typing.Any, connection: DatabaseConnection | None = None) -> EntityType: # type: ignore[explicit-any]
191
+ query = self.build_select().where(self.idColumn == self._convert_value_to_db(column=self.idColumn, value=idValue))
192
+ result = await database.execute(query=query, connection=connection)
193
+ return self.force_from_result(result=result)
194
+
195
+ async def get_one(self, database: Database, fieldFilters: Sequence[FieldFilter], connection: DatabaseConnection | None = None) -> EntityType:
196
+ query = apply_field_filters(query=self.build_select(), table=self.table, fieldFilters=fieldFilters)
197
+ result = await database.execute(query=query, connection=connection)
198
+ # TODO(krishan711): raise an exception if there is more than one result
199
+ return self.force_from_result(result=result)
200
+
201
+ async def get_one_or_none(self, database: Database, fieldFilters: Sequence[FieldFilter], connection: DatabaseConnection | None = None) -> EntityType | None:
202
+ try:
203
+ return await self.get_one(database=database, fieldFilters=fieldFilters, connection=connection)
204
+ except NotFoundException:
205
+ return None
@@ -0,0 +1,298 @@
1
+ import dataclasses
2
+ import datetime
3
+ import typing
4
+ import uuid
5
+ from collections.abc import Sequence
6
+ from enum import Enum
7
+
8
+ import sqlalchemy
9
+ import sqlalchemy.sql.functions
10
+ from sqlalchemy import Table
11
+ from sqlalchemy.sql import ColumnElement
12
+ from sqlalchemy.sql import Select
13
+
14
+ from core.store.database import Database
15
+ from core.store.database import ResultType
16
+ from core.util import date_util
17
+
18
+
19
+ class Direction(Enum):
20
+ ASCENDING = 'ascending'
21
+ DESCENDING = 'descending'
22
+
23
+
24
+ @dataclasses.dataclass
25
+ class Order:
26
+ fieldName: str
27
+ direction: Direction = Direction.DESCENDING
28
+
29
+
30
+ @dataclasses.dataclass
31
+ class RandomOrder(Order):
32
+ fieldName: str = '__KIBA_RANDOM'
33
+
34
+
35
+ @dataclasses.dataclass
36
+ class FieldFilter:
37
+ fieldName: str
38
+ isNull: bool | None = None
39
+ isNotNull: bool | None = None
40
+
41
+
42
+ @dataclasses.dataclass
43
+ class StringFieldFilter(FieldFilter):
44
+ eq: str | None = None
45
+ ne: str | None = None
46
+ containedIn: Sequence[str] | None = None
47
+ notContainedIn: Sequence[str] | None = None
48
+
49
+
50
+ @dataclasses.dataclass
51
+ class UUIDFieldFilter(FieldFilter):
52
+ eq: uuid.UUID | str | None = None
53
+ ne: uuid.UUID | str | None = None
54
+ containedIn: Sequence[uuid.UUID | str] | None = None
55
+ notContainedIn: Sequence[uuid.UUID | str] | None = None
56
+
57
+
58
+ @dataclasses.dataclass
59
+ class DateFieldFilter(FieldFilter):
60
+ eq: datetime.datetime | None = None
61
+ ne: datetime.datetime | None = None
62
+ lte: datetime.datetime | None = None
63
+ lt: datetime.datetime | None = None
64
+ gte: datetime.datetime | None = None
65
+ gt: datetime.datetime | None = None
66
+ containedIn: Sequence[datetime.datetime] | None = None
67
+ notContainedIn: Sequence[datetime.datetime] | None = None
68
+
69
+
70
+ @dataclasses.dataclass
71
+ class IntegerFieldFilter(FieldFilter):
72
+ eq: int | None = None
73
+ ne: int | None = None
74
+ lte: int | None = None
75
+ lt: int | None = None
76
+ gte: int | None = None
77
+ gt: int | None = None
78
+ containedIn: Sequence[int] | None = None
79
+ notContainedIn: Sequence[int] | None = None
80
+
81
+
82
+ @dataclasses.dataclass
83
+ class FloatFieldFilter(FieldFilter):
84
+ eq: float | None = None
85
+ ne: float | None = None
86
+ lte: float | None = None
87
+ lt: float | None = None
88
+ gte: float | None = None
89
+ gt: float | None = None
90
+ containedIn: Sequence[float] | None = None
91
+ notContainedIn: Sequence[float] | None = None
92
+
93
+
94
+ @dataclasses.dataclass
95
+ class BooleanFieldFilter(FieldFilter):
96
+ eq: bool | None = None
97
+ ne: bool | None = None
98
+
99
+
100
+ def uuid_from_value(value: uuid.UUID | str) -> uuid.UUID:
101
+ if isinstance(value, uuid.UUID):
102
+ return value
103
+ return uuid.UUID(hex=value)
104
+
105
+
106
+ def datetime_to_column_value(column: sqlalchemy.ColumnElement[typing.Any], dt: datetime.datetime) -> datetime.datetime: # type: ignore[explicit-any]
107
+ # NOTE(krishan711): columns without a timezone store naive utc datetimes, columns with one store aware utc datetimes
108
+ if getattr(column.type, 'timezone', False):
109
+ return date_util.datetime_to_utc(dt=dt)
110
+ return date_util.datetime_to_utc_naive_datetime(dt=dt)
111
+
112
+
113
+ def apply_order(query: Select[*ResultType], table: Table, order: Order) -> Select[*ResultType]:
114
+ if isinstance(order, RandomOrder):
115
+ query = query.order_by(sqlalchemy.sql.functions.random())
116
+ else:
117
+ field = table.c[order.fieldName]
118
+ query = query.order_by(field.asc() if order.direction == Direction.ASCENDING else field.desc())
119
+ return query
120
+
121
+
122
+ def apply_orders(query: Select[*ResultType], table: Table, orders: Sequence[Order]) -> Select[*ResultType]:
123
+ for order in orders:
124
+ query = apply_order(query=query, table=table, order=order)
125
+ return query
126
+
127
+
128
+ def get_string_field_filter_conditions(table: Table, fieldFilter: StringFieldFilter) -> list[ColumnElement[bool]]:
129
+ field = table.c[fieldFilter.fieldName]
130
+ conditions: list[ColumnElement[bool]] = []
131
+ if fieldFilter.eq is not None:
132
+ conditions.append(field == fieldFilter.eq)
133
+ if fieldFilter.ne is not None:
134
+ conditions.append(field != fieldFilter.ne)
135
+ if fieldFilter.containedIn is not None:
136
+ conditions.append(field.in_(fieldFilter.containedIn))
137
+ if fieldFilter.notContainedIn is not None:
138
+ conditions.append(field.not_in(fieldFilter.notContainedIn))
139
+ return conditions
140
+
141
+
142
+ def get_uuid_field_filter_conditions(table: Table, fieldFilter: UUIDFieldFilter) -> list[ColumnElement[bool]]:
143
+ field = table.c[fieldFilter.fieldName]
144
+ conditions: list[ColumnElement[bool]] = []
145
+ if fieldFilter.eq is not None:
146
+ conditions.append(field == uuid_from_value(value=fieldFilter.eq))
147
+ if fieldFilter.ne is not None:
148
+ conditions.append(field != uuid_from_value(value=fieldFilter.ne))
149
+ if fieldFilter.containedIn is not None:
150
+ conditions.append(field.in_([uuid_from_value(value=value) for value in fieldFilter.containedIn]))
151
+ if fieldFilter.notContainedIn is not None:
152
+ conditions.append(field.not_in([uuid_from_value(value=value) for value in fieldFilter.notContainedIn]))
153
+ return conditions
154
+
155
+
156
+ def get_date_field_filter_conditions(table: Table, fieldFilter: DateFieldFilter) -> list[ColumnElement[bool]]:
157
+ field = table.c[fieldFilter.fieldName]
158
+ conditions: list[ColumnElement[bool]] = []
159
+ if fieldFilter.eq is not None:
160
+ conditions.append(field == datetime_to_column_value(column=field, dt=fieldFilter.eq))
161
+ if fieldFilter.ne is not None:
162
+ conditions.append(field != datetime_to_column_value(column=field, dt=fieldFilter.ne))
163
+ if fieldFilter.lte is not None:
164
+ conditions.append(field <= datetime_to_column_value(column=field, dt=fieldFilter.lte))
165
+ if fieldFilter.lt is not None:
166
+ conditions.append(field < datetime_to_column_value(column=field, dt=fieldFilter.lt))
167
+ if fieldFilter.gte is not None:
168
+ conditions.append(field >= datetime_to_column_value(column=field, dt=fieldFilter.gte))
169
+ if fieldFilter.gt is not None:
170
+ conditions.append(field > datetime_to_column_value(column=field, dt=fieldFilter.gt))
171
+ if fieldFilter.containedIn is not None:
172
+ conditions.append(field.in_([datetime_to_column_value(column=field, dt=value) for value in fieldFilter.containedIn]))
173
+ if fieldFilter.notContainedIn is not None:
174
+ conditions.append(field.not_in([datetime_to_column_value(column=field, dt=value) for value in fieldFilter.notContainedIn]))
175
+ return conditions
176
+
177
+
178
+ def get_integer_field_filter_conditions(table: Table, fieldFilter: IntegerFieldFilter) -> list[ColumnElement[bool]]:
179
+ # NOTE(krishan711): `field == value` / `field.in_(values)` let SQLAlchemy's `Numeric.coerce_compared_value`
180
+ # re-infer a bind param type from the compared Python `int` (typically `BigInteger`, i.e. int64) instead of
181
+ # keeping `field.type` (e.g. `Numeric(78, 0)` for uint256 columns). Binding explicitly with `type_=field.type`
182
+ # avoids an asyncpg `OverflowError` for any value outside the int64 range.
183
+ field = table.c[fieldFilter.fieldName]
184
+ conditions: list[ColumnElement[bool]] = []
185
+ if fieldFilter.eq is not None:
186
+ conditions.append(field == sqlalchemy.bindparam(None, fieldFilter.eq, type_=field.type))
187
+ if fieldFilter.ne is not None:
188
+ conditions.append(field != sqlalchemy.bindparam(None, fieldFilter.ne, type_=field.type))
189
+ if fieldFilter.lte is not None:
190
+ conditions.append(field <= sqlalchemy.bindparam(None, fieldFilter.lte, type_=field.type))
191
+ if fieldFilter.lt is not None:
192
+ conditions.append(field < sqlalchemy.bindparam(None, fieldFilter.lt, type_=field.type))
193
+ if fieldFilter.gte is not None:
194
+ conditions.append(field >= sqlalchemy.bindparam(None, fieldFilter.gte, type_=field.type))
195
+ if fieldFilter.gt is not None:
196
+ conditions.append(field > sqlalchemy.bindparam(None, fieldFilter.gt, type_=field.type))
197
+ if fieldFilter.containedIn is not None:
198
+ conditions.append(field.in_(sqlalchemy.bindparam(None, fieldFilter.containedIn, type_=field.type, expanding=True)))
199
+ if fieldFilter.notContainedIn is not None:
200
+ conditions.append(field.not_in(sqlalchemy.bindparam(None, fieldFilter.notContainedIn, type_=field.type, expanding=True)))
201
+ return conditions
202
+
203
+
204
+ def get_float_field_filter_conditions(table: Table, fieldFilter: FloatFieldFilter) -> list[ColumnElement[bool]]:
205
+ field = table.c[fieldFilter.fieldName]
206
+ conditions: list[ColumnElement[bool]] = []
207
+ if fieldFilter.eq is not None:
208
+ conditions.append(field == fieldFilter.eq)
209
+ if fieldFilter.ne is not None:
210
+ conditions.append(field != fieldFilter.ne)
211
+ if fieldFilter.lte is not None:
212
+ conditions.append(field <= fieldFilter.lte)
213
+ if fieldFilter.lt is not None:
214
+ conditions.append(field < fieldFilter.lt)
215
+ if fieldFilter.gte is not None:
216
+ conditions.append(field >= fieldFilter.gte)
217
+ if fieldFilter.gt is not None:
218
+ conditions.append(field > fieldFilter.gt)
219
+ if fieldFilter.containedIn is not None:
220
+ conditions.append(field.in_(fieldFilter.containedIn))
221
+ if fieldFilter.notContainedIn is not None:
222
+ conditions.append(field.not_in(fieldFilter.notContainedIn))
223
+ return conditions
224
+
225
+
226
+ def get_boolean_field_filter_conditions(table: Table, fieldFilter: BooleanFieldFilter) -> list[ColumnElement[bool]]:
227
+ field = table.c[fieldFilter.fieldName]
228
+ conditions: list[ColumnElement[bool]] = []
229
+ if fieldFilter.eq is not None:
230
+ conditions.append(field == fieldFilter.eq)
231
+ if fieldFilter.ne is not None:
232
+ conditions.append(field != fieldFilter.ne)
233
+ return conditions
234
+
235
+
236
+ def get_field_filter_conditions(table: Table, fieldFilter: FieldFilter) -> list[ColumnElement[bool]]:
237
+ field = table.c[fieldFilter.fieldName]
238
+ conditions: list[ColumnElement[bool]] = []
239
+ if fieldFilter.isNull:
240
+ conditions.append(field.is_(None))
241
+ if fieldFilter.isNotNull:
242
+ conditions.append(field.is_not(None))
243
+ if isinstance(fieldFilter, StringFieldFilter):
244
+ conditions += get_string_field_filter_conditions(table=table, fieldFilter=fieldFilter)
245
+ if isinstance(fieldFilter, UUIDFieldFilter):
246
+ conditions += get_uuid_field_filter_conditions(table=table, fieldFilter=fieldFilter)
247
+ if isinstance(fieldFilter, DateFieldFilter):
248
+ conditions += get_date_field_filter_conditions(table=table, fieldFilter=fieldFilter)
249
+ if isinstance(fieldFilter, IntegerFieldFilter):
250
+ conditions += get_integer_field_filter_conditions(table=table, fieldFilter=fieldFilter)
251
+ if isinstance(fieldFilter, FloatFieldFilter):
252
+ conditions += get_float_field_filter_conditions(table=table, fieldFilter=fieldFilter)
253
+ if isinstance(fieldFilter, BooleanFieldFilter):
254
+ conditions += get_boolean_field_filter_conditions(table=table, fieldFilter=fieldFilter)
255
+ return conditions
256
+
257
+
258
+ def get_field_filters_conditions(table: Table, fieldFilters: Sequence[FieldFilter]) -> list[ColumnElement[bool]]:
259
+ return [condition for fieldFilter in fieldFilters for condition in get_field_filter_conditions(table=table, fieldFilter=fieldFilter)]
260
+
261
+
262
+ def apply_field_filters(query: Select[*ResultType], table: Table, fieldFilters: Sequence[FieldFilter]) -> Select[*ResultType]:
263
+ return query.where(*get_field_filters_conditions(table=table, fieldFilters=fieldFilters))
264
+
265
+
266
+ class Retriever:
267
+ def __init__(self, database: Database) -> None:
268
+ self.database = database
269
+
270
+ def _apply_order(self, query: Select[*ResultType], table: Table, order: Order) -> Select[*ResultType]:
271
+ return apply_order(query=query, table=table, order=order)
272
+
273
+ def _apply_orders(self, query: Select[*ResultType], table: Table, orders: Sequence[Order]) -> Select[*ResultType]:
274
+ return apply_orders(query=query, table=table, orders=orders)
275
+
276
+ def _apply_string_field_filter(self, query: Select[*ResultType], table: Table, fieldFilter: StringFieldFilter) -> Select[*ResultType]:
277
+ return query.where(*get_string_field_filter_conditions(table=table, fieldFilter=fieldFilter))
278
+
279
+ def _apply_uuid_field_filter(self, query: Select[*ResultType], table: Table, fieldFilter: UUIDFieldFilter) -> Select[*ResultType]:
280
+ return query.where(*get_uuid_field_filter_conditions(table=table, fieldFilter=fieldFilter))
281
+
282
+ def _apply_date_field_filter(self, query: Select[*ResultType], table: Table, fieldFilter: DateFieldFilter) -> Select[*ResultType]:
283
+ return query.where(*get_date_field_filter_conditions(table=table, fieldFilter=fieldFilter))
284
+
285
+ def _apply_integer_field_filter(self, query: Select[*ResultType], table: Table, fieldFilter: IntegerFieldFilter) -> Select[*ResultType]:
286
+ return query.where(*get_integer_field_filter_conditions(table=table, fieldFilter=fieldFilter))
287
+
288
+ def _apply_float_field_filter(self, query: Select[*ResultType], table: Table, fieldFilter: FloatFieldFilter) -> Select[*ResultType]:
289
+ return query.where(*get_float_field_filter_conditions(table=table, fieldFilter=fieldFilter))
290
+
291
+ def _apply_boolean_field_filter(self, query: Select[*ResultType], table: Table, fieldFilter: BooleanFieldFilter) -> Select[*ResultType]:
292
+ return query.where(*get_boolean_field_filter_conditions(table=table, fieldFilter=fieldFilter))
293
+
294
+ def _apply_field_filter(self, query: Select[*ResultType], table: Table, fieldFilter: FieldFilter) -> Select[*ResultType]:
295
+ return query.where(*get_field_filter_conditions(table=table, fieldFilter=fieldFilter))
296
+
297
+ def _apply_field_filters(self, query: Select[*ResultType], table: Table, fieldFilters: Sequence[FieldFilter]) -> Select[*ResultType]:
298
+ return apply_field_filters(query=query, table=table, fieldFilters=fieldFilters)
@@ -39,7 +39,7 @@ class Saver:
39
39
  async with self.database.create_transaction() as connection:
40
40
  yield connection
41
41
 
42
- async def _execute(self, query: TypedReturnsRows[ResultType], connection: DatabaseConnection | None = None) -> Result[ResultType]:
42
+ async def _execute(self, query: TypedReturnsRows[*ResultType], connection: DatabaseConnection | None = None) -> Result[*ResultType]:
43
43
  try:
44
44
  if connection:
45
45
  return await self.database.execute(query=query, connection=connection)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev76
3
+ Version: 0.5.3.dev78
4
4
  Summary: Kiba Labs' python utilities
5
5
  Project-URL: repository, https://github.com/kibalabs/core-py
6
6
  Requires-Python: <4,>=3.12
@@ -69,6 +69,7 @@ core/requester/__init__.py
69
69
  core/requester/requester.py
70
70
  core/store/__init__.py
71
71
  core/store/database.py
72
+ core/store/entity_repository.py
72
73
  core/store/retriever.py
73
74
  core/store/saver.py
74
75
  core/util/__init__.py
@@ -97,6 +98,7 @@ kiba_core.egg-info/requires.txt
97
98
  kiba_core.egg-info/top_level.txt
98
99
  tests/__init__.py
99
100
  tests/test_database.py
101
+ tests/test_entity_repository.py
100
102
  tests/test_exceptions.py
101
103
  tests/test_logging.py
102
104
  tests/api/test_api_response.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiba-core"
3
- version = "0.5.3.dev76"
3
+ version = "0.5.3.dev78"
4
4
  description = "Kiba Labs' python utilities"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12, <4"