sqlspec 0.26.0__py3-none-any.whl → 0.28.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.

Potentially problematic release.


This version of sqlspec might be problematic. Click here for more details.

Files changed (212) hide show
  1. sqlspec/__init__.py +7 -15
  2. sqlspec/_serialization.py +55 -25
  3. sqlspec/_typing.py +155 -52
  4. sqlspec/adapters/adbc/_types.py +1 -1
  5. sqlspec/adapters/adbc/adk/__init__.py +5 -0
  6. sqlspec/adapters/adbc/adk/store.py +880 -0
  7. sqlspec/adapters/adbc/config.py +62 -12
  8. sqlspec/adapters/adbc/data_dictionary.py +74 -2
  9. sqlspec/adapters/adbc/driver.py +226 -58
  10. sqlspec/adapters/adbc/litestar/__init__.py +5 -0
  11. sqlspec/adapters/adbc/litestar/store.py +504 -0
  12. sqlspec/adapters/adbc/type_converter.py +44 -50
  13. sqlspec/adapters/aiosqlite/_types.py +1 -1
  14. sqlspec/adapters/aiosqlite/adk/__init__.py +5 -0
  15. sqlspec/adapters/aiosqlite/adk/store.py +536 -0
  16. sqlspec/adapters/aiosqlite/config.py +86 -16
  17. sqlspec/adapters/aiosqlite/data_dictionary.py +34 -2
  18. sqlspec/adapters/aiosqlite/driver.py +127 -38
  19. sqlspec/adapters/aiosqlite/litestar/__init__.py +5 -0
  20. sqlspec/adapters/aiosqlite/litestar/store.py +281 -0
  21. sqlspec/adapters/aiosqlite/pool.py +7 -7
  22. sqlspec/adapters/asyncmy/__init__.py +7 -1
  23. sqlspec/adapters/asyncmy/_types.py +1 -1
  24. sqlspec/adapters/asyncmy/adk/__init__.py +5 -0
  25. sqlspec/adapters/asyncmy/adk/store.py +503 -0
  26. sqlspec/adapters/asyncmy/config.py +59 -17
  27. sqlspec/adapters/asyncmy/data_dictionary.py +41 -2
  28. sqlspec/adapters/asyncmy/driver.py +293 -62
  29. sqlspec/adapters/asyncmy/litestar/__init__.py +5 -0
  30. sqlspec/adapters/asyncmy/litestar/store.py +296 -0
  31. sqlspec/adapters/asyncpg/__init__.py +2 -1
  32. sqlspec/adapters/asyncpg/_type_handlers.py +71 -0
  33. sqlspec/adapters/asyncpg/_types.py +11 -7
  34. sqlspec/adapters/asyncpg/adk/__init__.py +5 -0
  35. sqlspec/adapters/asyncpg/adk/store.py +460 -0
  36. sqlspec/adapters/asyncpg/config.py +57 -36
  37. sqlspec/adapters/asyncpg/data_dictionary.py +48 -2
  38. sqlspec/adapters/asyncpg/driver.py +153 -23
  39. sqlspec/adapters/asyncpg/litestar/__init__.py +5 -0
  40. sqlspec/adapters/asyncpg/litestar/store.py +253 -0
  41. sqlspec/adapters/bigquery/_types.py +1 -1
  42. sqlspec/adapters/bigquery/adk/__init__.py +5 -0
  43. sqlspec/adapters/bigquery/adk/store.py +585 -0
  44. sqlspec/adapters/bigquery/config.py +36 -11
  45. sqlspec/adapters/bigquery/data_dictionary.py +42 -2
  46. sqlspec/adapters/bigquery/driver.py +489 -144
  47. sqlspec/adapters/bigquery/litestar/__init__.py +5 -0
  48. sqlspec/adapters/bigquery/litestar/store.py +327 -0
  49. sqlspec/adapters/bigquery/type_converter.py +55 -23
  50. sqlspec/adapters/duckdb/_types.py +2 -2
  51. sqlspec/adapters/duckdb/adk/__init__.py +14 -0
  52. sqlspec/adapters/duckdb/adk/store.py +563 -0
  53. sqlspec/adapters/duckdb/config.py +79 -21
  54. sqlspec/adapters/duckdb/data_dictionary.py +41 -2
  55. sqlspec/adapters/duckdb/driver.py +225 -44
  56. sqlspec/adapters/duckdb/litestar/__init__.py +5 -0
  57. sqlspec/adapters/duckdb/litestar/store.py +332 -0
  58. sqlspec/adapters/duckdb/pool.py +5 -5
  59. sqlspec/adapters/duckdb/type_converter.py +51 -21
  60. sqlspec/adapters/oracledb/_numpy_handlers.py +133 -0
  61. sqlspec/adapters/oracledb/_types.py +20 -2
  62. sqlspec/adapters/oracledb/adk/__init__.py +5 -0
  63. sqlspec/adapters/oracledb/adk/store.py +1628 -0
  64. sqlspec/adapters/oracledb/config.py +120 -36
  65. sqlspec/adapters/oracledb/data_dictionary.py +87 -20
  66. sqlspec/adapters/oracledb/driver.py +475 -86
  67. sqlspec/adapters/oracledb/litestar/__init__.py +5 -0
  68. sqlspec/adapters/oracledb/litestar/store.py +765 -0
  69. sqlspec/adapters/oracledb/migrations.py +316 -25
  70. sqlspec/adapters/oracledb/type_converter.py +91 -16
  71. sqlspec/adapters/psqlpy/_type_handlers.py +44 -0
  72. sqlspec/adapters/psqlpy/_types.py +2 -1
  73. sqlspec/adapters/psqlpy/adk/__init__.py +5 -0
  74. sqlspec/adapters/psqlpy/adk/store.py +483 -0
  75. sqlspec/adapters/psqlpy/config.py +45 -19
  76. sqlspec/adapters/psqlpy/data_dictionary.py +48 -2
  77. sqlspec/adapters/psqlpy/driver.py +108 -41
  78. sqlspec/adapters/psqlpy/litestar/__init__.py +5 -0
  79. sqlspec/adapters/psqlpy/litestar/store.py +272 -0
  80. sqlspec/adapters/psqlpy/type_converter.py +40 -11
  81. sqlspec/adapters/psycopg/_type_handlers.py +80 -0
  82. sqlspec/adapters/psycopg/_types.py +2 -1
  83. sqlspec/adapters/psycopg/adk/__init__.py +5 -0
  84. sqlspec/adapters/psycopg/adk/store.py +962 -0
  85. sqlspec/adapters/psycopg/config.py +65 -37
  86. sqlspec/adapters/psycopg/data_dictionary.py +91 -3
  87. sqlspec/adapters/psycopg/driver.py +200 -78
  88. sqlspec/adapters/psycopg/litestar/__init__.py +5 -0
  89. sqlspec/adapters/psycopg/litestar/store.py +554 -0
  90. sqlspec/adapters/sqlite/__init__.py +2 -1
  91. sqlspec/adapters/sqlite/_type_handlers.py +86 -0
  92. sqlspec/adapters/sqlite/_types.py +1 -1
  93. sqlspec/adapters/sqlite/adk/__init__.py +5 -0
  94. sqlspec/adapters/sqlite/adk/store.py +582 -0
  95. sqlspec/adapters/sqlite/config.py +85 -16
  96. sqlspec/adapters/sqlite/data_dictionary.py +34 -2
  97. sqlspec/adapters/sqlite/driver.py +120 -52
  98. sqlspec/adapters/sqlite/litestar/__init__.py +5 -0
  99. sqlspec/adapters/sqlite/litestar/store.py +318 -0
  100. sqlspec/adapters/sqlite/pool.py +5 -5
  101. sqlspec/base.py +45 -26
  102. sqlspec/builder/__init__.py +73 -4
  103. sqlspec/builder/_base.py +91 -58
  104. sqlspec/builder/_column.py +5 -5
  105. sqlspec/builder/_ddl.py +98 -89
  106. sqlspec/builder/_delete.py +5 -4
  107. sqlspec/builder/_dml.py +388 -0
  108. sqlspec/{_sql.py → builder/_factory.py} +41 -44
  109. sqlspec/builder/_insert.py +5 -82
  110. sqlspec/builder/{mixins/_join_operations.py → _join.py} +145 -143
  111. sqlspec/builder/_merge.py +446 -11
  112. sqlspec/builder/_parsing_utils.py +9 -11
  113. sqlspec/builder/_select.py +1313 -25
  114. sqlspec/builder/_update.py +11 -42
  115. sqlspec/cli.py +76 -69
  116. sqlspec/config.py +331 -62
  117. sqlspec/core/__init__.py +5 -4
  118. sqlspec/core/cache.py +18 -18
  119. sqlspec/core/compiler.py +6 -8
  120. sqlspec/core/filters.py +55 -47
  121. sqlspec/core/hashing.py +9 -9
  122. sqlspec/core/parameters.py +76 -45
  123. sqlspec/core/result.py +234 -47
  124. sqlspec/core/splitter.py +16 -17
  125. sqlspec/core/statement.py +32 -31
  126. sqlspec/core/type_conversion.py +3 -2
  127. sqlspec/driver/__init__.py +1 -3
  128. sqlspec/driver/_async.py +183 -160
  129. sqlspec/driver/_common.py +197 -109
  130. sqlspec/driver/_sync.py +189 -161
  131. sqlspec/driver/mixins/_result_tools.py +20 -236
  132. sqlspec/driver/mixins/_sql_translator.py +4 -4
  133. sqlspec/exceptions.py +70 -7
  134. sqlspec/extensions/adk/__init__.py +53 -0
  135. sqlspec/extensions/adk/_types.py +51 -0
  136. sqlspec/extensions/adk/converters.py +172 -0
  137. sqlspec/extensions/adk/migrations/0001_create_adk_tables.py +144 -0
  138. sqlspec/extensions/adk/migrations/__init__.py +0 -0
  139. sqlspec/extensions/adk/service.py +181 -0
  140. sqlspec/extensions/adk/store.py +536 -0
  141. sqlspec/extensions/aiosql/adapter.py +69 -61
  142. sqlspec/extensions/fastapi/__init__.py +21 -0
  143. sqlspec/extensions/fastapi/extension.py +331 -0
  144. sqlspec/extensions/fastapi/providers.py +543 -0
  145. sqlspec/extensions/flask/__init__.py +36 -0
  146. sqlspec/extensions/flask/_state.py +71 -0
  147. sqlspec/extensions/flask/_utils.py +40 -0
  148. sqlspec/extensions/flask/extension.py +389 -0
  149. sqlspec/extensions/litestar/__init__.py +21 -4
  150. sqlspec/extensions/litestar/cli.py +54 -10
  151. sqlspec/extensions/litestar/config.py +56 -266
  152. sqlspec/extensions/litestar/handlers.py +46 -17
  153. sqlspec/extensions/litestar/migrations/0001_create_session_table.py +137 -0
  154. sqlspec/extensions/litestar/migrations/__init__.py +3 -0
  155. sqlspec/extensions/litestar/plugin.py +349 -224
  156. sqlspec/extensions/litestar/providers.py +25 -25
  157. sqlspec/extensions/litestar/store.py +265 -0
  158. sqlspec/extensions/starlette/__init__.py +10 -0
  159. sqlspec/extensions/starlette/_state.py +25 -0
  160. sqlspec/extensions/starlette/_utils.py +52 -0
  161. sqlspec/extensions/starlette/extension.py +254 -0
  162. sqlspec/extensions/starlette/middleware.py +154 -0
  163. sqlspec/loader.py +30 -49
  164. sqlspec/migrations/base.py +200 -76
  165. sqlspec/migrations/commands.py +591 -62
  166. sqlspec/migrations/context.py +6 -9
  167. sqlspec/migrations/fix.py +199 -0
  168. sqlspec/migrations/loaders.py +47 -19
  169. sqlspec/migrations/runner.py +241 -75
  170. sqlspec/migrations/tracker.py +237 -21
  171. sqlspec/migrations/utils.py +51 -3
  172. sqlspec/migrations/validation.py +177 -0
  173. sqlspec/protocols.py +106 -36
  174. sqlspec/storage/_utils.py +85 -0
  175. sqlspec/storage/backends/fsspec.py +133 -107
  176. sqlspec/storage/backends/local.py +78 -51
  177. sqlspec/storage/backends/obstore.py +276 -168
  178. sqlspec/storage/registry.py +75 -39
  179. sqlspec/typing.py +30 -84
  180. sqlspec/utils/__init__.py +25 -4
  181. sqlspec/utils/arrow_helpers.py +81 -0
  182. sqlspec/utils/config_resolver.py +6 -6
  183. sqlspec/utils/correlation.py +4 -5
  184. sqlspec/utils/data_transformation.py +3 -2
  185. sqlspec/utils/deprecation.py +9 -8
  186. sqlspec/utils/fixtures.py +4 -4
  187. sqlspec/utils/logging.py +46 -6
  188. sqlspec/utils/module_loader.py +205 -5
  189. sqlspec/utils/portal.py +311 -0
  190. sqlspec/utils/schema.py +288 -0
  191. sqlspec/utils/serializers.py +113 -4
  192. sqlspec/utils/sync_tools.py +36 -22
  193. sqlspec/utils/text.py +1 -2
  194. sqlspec/utils/type_guards.py +136 -20
  195. sqlspec/utils/version.py +433 -0
  196. {sqlspec-0.26.0.dist-info → sqlspec-0.28.0.dist-info}/METADATA +41 -22
  197. sqlspec-0.28.0.dist-info/RECORD +221 -0
  198. sqlspec/builder/mixins/__init__.py +0 -55
  199. sqlspec/builder/mixins/_cte_and_set_ops.py +0 -253
  200. sqlspec/builder/mixins/_delete_operations.py +0 -50
  201. sqlspec/builder/mixins/_insert_operations.py +0 -282
  202. sqlspec/builder/mixins/_merge_operations.py +0 -698
  203. sqlspec/builder/mixins/_order_limit_operations.py +0 -145
  204. sqlspec/builder/mixins/_pivot_operations.py +0 -157
  205. sqlspec/builder/mixins/_select_operations.py +0 -930
  206. sqlspec/builder/mixins/_update_operations.py +0 -199
  207. sqlspec/builder/mixins/_where_clause.py +0 -1298
  208. sqlspec-0.26.0.dist-info/RECORD +0 -157
  209. sqlspec-0.26.0.dist-info/licenses/NOTICE +0 -29
  210. {sqlspec-0.26.0.dist-info → sqlspec-0.28.0.dist-info}/WHEEL +0 -0
  211. {sqlspec-0.26.0.dist-info → sqlspec-0.28.0.dist-info}/entry_points.txt +0 -0
  212. {sqlspec-0.26.0.dist-info → sqlspec-0.28.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1628 @@
1
+ """Oracle ADK store for Google Agent Development Kit session/event storage."""
2
+
3
+ from enum import Enum
4
+ from typing import TYPE_CHECKING, Any, Final
5
+
6
+ import oracledb
7
+
8
+ from sqlspec import SQL
9
+ from sqlspec.extensions.adk import BaseAsyncADKStore, BaseSyncADKStore, EventRecord, SessionRecord
10
+ from sqlspec.utils.logging import get_logger
11
+ from sqlspec.utils.serializers import from_json, to_json
12
+
13
+ if TYPE_CHECKING:
14
+ from datetime import datetime
15
+
16
+ from sqlspec.adapters.oracledb.config import OracleAsyncConfig, OracleSyncConfig
17
+
18
+ logger = get_logger("adapters.oracledb.adk.store")
19
+
20
+ __all__ = ("OracleAsyncADKStore", "OracleSyncADKStore")
21
+
22
+ ORACLE_TABLE_NOT_FOUND_ERROR: Final = 942
23
+ ORACLE_MIN_JSON_NATIVE_VERSION: Final = 21
24
+ ORACLE_MIN_JSON_NATIVE_COMPATIBLE: Final = 20
25
+ ORACLE_MIN_JSON_BLOB_VERSION: Final = 12
26
+
27
+
28
+ class JSONStorageType(str, Enum):
29
+ """JSON storage type based on Oracle version."""
30
+
31
+ JSON_NATIVE = "json"
32
+ BLOB_JSON = "blob_json"
33
+ BLOB_PLAIN = "blob_plain"
34
+
35
+
36
+ def _to_oracle_bool(value: "bool | None") -> "int | None":
37
+ """Convert Python boolean to Oracle NUMBER(1).
38
+
39
+ Args:
40
+ value: Python boolean value or None.
41
+
42
+ Returns:
43
+ 1 for True, 0 for False, None for None.
44
+ """
45
+ if value is None:
46
+ return None
47
+ return 1 if value else 0
48
+
49
+
50
+ def _from_oracle_bool(value: "int | None") -> "bool | None":
51
+ """Convert Oracle NUMBER(1) to Python boolean.
52
+
53
+ Args:
54
+ value: Oracle NUMBER value (0, 1, or None).
55
+
56
+ Returns:
57
+ Python boolean or None.
58
+ """
59
+ if value is None:
60
+ return None
61
+ return bool(value)
62
+
63
+
64
+ class OracleAsyncADKStore(BaseAsyncADKStore["OracleAsyncConfig"]):
65
+ """Oracle async ADK store using oracledb async driver.
66
+
67
+ Implements session and event storage for Google Agent Development Kit
68
+ using Oracle Database via the python-oracledb async driver. Provides:
69
+ - Session state management with version-specific JSON storage
70
+ - Event history tracking with BLOB-serialized actions
71
+ - TIMESTAMP WITH TIME ZONE for timezone-aware timestamps
72
+ - Foreign key constraints with cascade delete
73
+ - Efficient upserts using MERGE statement
74
+
75
+ Args:
76
+ config: OracleAsyncConfig with extension_config["adk"] settings.
77
+
78
+ Example:
79
+ from sqlspec.adapters.oracledb import OracleAsyncConfig
80
+ from sqlspec.adapters.oracledb.adk import OracleAsyncADKStore
81
+
82
+ config = OracleAsyncConfig(
83
+ pool_config={"dsn": "oracle://..."},
84
+ extension_config={
85
+ "adk": {
86
+ "session_table": "my_sessions",
87
+ "events_table": "my_events",
88
+ "owner_id_column": "tenant_id NUMBER(10) REFERENCES tenants(id)"
89
+ }
90
+ }
91
+ )
92
+ store = OracleAsyncADKStore(config)
93
+ await store.create_tables()
94
+
95
+ Notes:
96
+ - JSON storage type detected based on Oracle version (21c+, 12c+, legacy)
97
+ - BLOB for pre-serialized actions from Google ADK
98
+ - TIMESTAMP WITH TIME ZONE for timezone-aware timestamps
99
+ - NUMBER(1) for booleans (0/1/NULL)
100
+ - Named parameters using :param_name
101
+ - State merging handled at application level
102
+ - owner_id_column supports NUMBER, VARCHAR2, RAW for Oracle FK types
103
+ - Configuration is read from config.extension_config["adk"]
104
+ """
105
+
106
+ __slots__ = ("_in_memory", "_json_storage_type")
107
+
108
+ def __init__(self, config: "OracleAsyncConfig") -> None:
109
+ """Initialize Oracle ADK store.
110
+
111
+ Args:
112
+ config: OracleAsyncConfig instance.
113
+
114
+ Notes:
115
+ Configuration is read from config.extension_config["adk"]:
116
+ - session_table: Sessions table name (default: "adk_sessions")
117
+ - events_table: Events table name (default: "adk_events")
118
+ - owner_id_column: Optional owner FK column DDL (default: None)
119
+ - in_memory: Enable INMEMORY PRIORITY HIGH clause (default: False)
120
+ """
121
+ super().__init__(config)
122
+ self._json_storage_type: JSONStorageType | None = None
123
+
124
+ adk_config = config.extension_config.get("adk", {})
125
+ self._in_memory: bool = bool(adk_config.get("in_memory", False))
126
+
127
+ async def _get_create_sessions_table_sql(self) -> str:
128
+ """Get Oracle CREATE TABLE SQL for sessions table.
129
+
130
+ Auto-detects optimal JSON storage type based on Oracle version.
131
+ Result is cached to minimize database queries.
132
+ """
133
+ storage_type = await self._detect_json_storage_type()
134
+ return self._get_create_sessions_table_sql_for_type(storage_type)
135
+
136
+ async def _get_create_events_table_sql(self) -> str:
137
+ """Get Oracle CREATE TABLE SQL for events table.
138
+
139
+ Auto-detects optimal JSON storage type based on Oracle version.
140
+ Result is cached to minimize database queries.
141
+ """
142
+ storage_type = await self._detect_json_storage_type()
143
+ return self._get_create_events_table_sql_for_type(storage_type)
144
+
145
+ async def _detect_json_storage_type(self) -> JSONStorageType:
146
+ """Detect the appropriate JSON storage type based on Oracle version.
147
+
148
+ Returns:
149
+ Appropriate JSONStorageType for this Oracle version.
150
+
151
+ Notes:
152
+ Queries product_component_version to determine Oracle version.
153
+ - Oracle 21c+ with compatible >= 20: Native JSON type
154
+ - Oracle 12c+: BLOB with IS JSON constraint (preferred)
155
+ - Oracle 11g and earlier: BLOB without constraint
156
+
157
+ BLOB is preferred over CLOB for 12c+ as per Oracle recommendations.
158
+ Result is cached in self._json_storage_type.
159
+ """
160
+ if self._json_storage_type is not None:
161
+ return self._json_storage_type
162
+
163
+ async with self._config.provide_connection() as conn:
164
+ cursor = conn.cursor()
165
+ await cursor.execute(
166
+ """
167
+ SELECT version FROM product_component_version
168
+ WHERE product LIKE 'Oracle%Database%'
169
+ """
170
+ )
171
+ row = await cursor.fetchone()
172
+
173
+ if row is None:
174
+ logger.warning("Could not detect Oracle version, defaulting to BLOB_JSON")
175
+ self._json_storage_type = JSONStorageType.BLOB_JSON
176
+ return self._json_storage_type
177
+
178
+ version_str = str(row[0])
179
+ version_parts = version_str.split(".")
180
+ major_version = int(version_parts[0]) if version_parts else 0
181
+
182
+ if major_version >= ORACLE_MIN_JSON_NATIVE_VERSION:
183
+ await cursor.execute("SELECT value FROM v$parameter WHERE name = 'compatible'")
184
+ compatible_row = await cursor.fetchone()
185
+ if compatible_row:
186
+ compatible_parts = str(compatible_row[0]).split(".")
187
+ compatible_major = int(compatible_parts[0]) if compatible_parts else 0
188
+ if compatible_major >= ORACLE_MIN_JSON_NATIVE_COMPATIBLE:
189
+ logger.info("Detected Oracle %s with compatible >= 20, using JSON_NATIVE", version_str)
190
+ self._json_storage_type = JSONStorageType.JSON_NATIVE
191
+ return self._json_storage_type
192
+
193
+ if major_version >= ORACLE_MIN_JSON_BLOB_VERSION:
194
+ logger.info("Detected Oracle %s, using BLOB_JSON (recommended)", version_str)
195
+ self._json_storage_type = JSONStorageType.BLOB_JSON
196
+ return self._json_storage_type
197
+
198
+ logger.info("Detected Oracle %s (pre-12c), using BLOB_PLAIN", version_str)
199
+ self._json_storage_type = JSONStorageType.BLOB_PLAIN
200
+ return self._json_storage_type
201
+
202
+ async def _serialize_state(self, state: "dict[str, Any]") -> "str | bytes":
203
+ """Serialize state dictionary to appropriate format based on storage type.
204
+
205
+ Args:
206
+ state: State dictionary to serialize.
207
+
208
+ Returns:
209
+ JSON string for JSON_NATIVE, bytes for BLOB types.
210
+ """
211
+ storage_type = await self._detect_json_storage_type()
212
+
213
+ if storage_type == JSONStorageType.JSON_NATIVE:
214
+ return to_json(state)
215
+
216
+ return to_json(state, as_bytes=True)
217
+
218
+ async def _deserialize_state(self, data: Any) -> "dict[str, Any]":
219
+ """Deserialize state data from database format.
220
+
221
+ Args:
222
+ data: Data from database (may be LOB, str, bytes, or dict).
223
+
224
+ Returns:
225
+ Deserialized state dictionary.
226
+
227
+ Notes:
228
+ Handles LOB reading if data has read() method.
229
+ Oracle JSON type may return dict directly.
230
+ """
231
+ if hasattr(data, "read"):
232
+ data = await data.read()
233
+
234
+ if isinstance(data, dict):
235
+ return data
236
+
237
+ if isinstance(data, bytes):
238
+ return from_json(data) # type: ignore[no-any-return]
239
+
240
+ if isinstance(data, str):
241
+ return from_json(data) # type: ignore[no-any-return]
242
+
243
+ return from_json(str(data)) # type: ignore[no-any-return]
244
+
245
+ async def _serialize_json_field(self, value: Any) -> "str | bytes | None":
246
+ """Serialize optional JSON field for event storage.
247
+
248
+ Args:
249
+ value: Value to serialize (dict or None).
250
+
251
+ Returns:
252
+ Serialized JSON or None.
253
+ """
254
+ if value is None:
255
+ return None
256
+
257
+ storage_type = await self._detect_json_storage_type()
258
+
259
+ if storage_type == JSONStorageType.JSON_NATIVE:
260
+ return to_json(value)
261
+
262
+ return to_json(value, as_bytes=True)
263
+
264
+ async def _deserialize_json_field(self, data: Any) -> "dict[str, Any] | None":
265
+ """Deserialize optional JSON field from database.
266
+
267
+ Args:
268
+ data: Data from database (may be LOB, str, bytes, dict, or None).
269
+
270
+ Returns:
271
+ Deserialized dictionary or None.
272
+
273
+ Notes:
274
+ Oracle JSON type may return dict directly.
275
+ """
276
+ if data is None:
277
+ return None
278
+
279
+ if hasattr(data, "read"):
280
+ data = await data.read()
281
+
282
+ if isinstance(data, dict):
283
+ return data
284
+
285
+ if isinstance(data, bytes):
286
+ return from_json(data) # type: ignore[no-any-return]
287
+
288
+ if isinstance(data, str):
289
+ return from_json(data) # type: ignore[no-any-return]
290
+
291
+ return from_json(str(data)) # type: ignore[no-any-return]
292
+
293
+ def _get_create_sessions_table_sql_for_type(self, storage_type: JSONStorageType) -> str:
294
+ """Get Oracle CREATE TABLE SQL for sessions with specified storage type.
295
+
296
+ Args:
297
+ storage_type: JSON storage type to use.
298
+
299
+ Returns:
300
+ SQL statement to create adk_sessions table.
301
+ """
302
+ if storage_type == JSONStorageType.JSON_NATIVE:
303
+ state_column = "state JSON NOT NULL"
304
+ elif storage_type == JSONStorageType.BLOB_JSON:
305
+ state_column = "state BLOB CHECK (state IS JSON) NOT NULL"
306
+ else:
307
+ state_column = "state BLOB NOT NULL"
308
+
309
+ owner_id_column_sql = f", {self._owner_id_column_ddl}" if self._owner_id_column_ddl else ""
310
+ inmemory_clause = " INMEMORY PRIORITY HIGH" if self._in_memory else ""
311
+
312
+ return f"""
313
+ BEGIN
314
+ EXECUTE IMMEDIATE 'CREATE TABLE {self._session_table} (
315
+ id VARCHAR2(128) PRIMARY KEY,
316
+ app_name VARCHAR2(128) NOT NULL,
317
+ user_id VARCHAR2(128) NOT NULL,
318
+ {state_column},
319
+ create_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
320
+ update_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL{owner_id_column_sql}
321
+ ){inmemory_clause}';
322
+ EXCEPTION
323
+ WHEN OTHERS THEN
324
+ IF SQLCODE != -955 THEN
325
+ RAISE;
326
+ END IF;
327
+ END;
328
+
329
+ BEGIN
330
+ EXECUTE IMMEDIATE 'CREATE INDEX idx_{self._session_table}_app_user
331
+ ON {self._session_table}(app_name, user_id)';
332
+ EXCEPTION
333
+ WHEN OTHERS THEN
334
+ IF SQLCODE != -955 THEN
335
+ RAISE;
336
+ END IF;
337
+ END;
338
+
339
+ BEGIN
340
+ EXECUTE IMMEDIATE 'CREATE INDEX idx_{self._session_table}_update_time
341
+ ON {self._session_table}(update_time DESC)';
342
+ EXCEPTION
343
+ WHEN OTHERS THEN
344
+ IF SQLCODE != -955 THEN
345
+ RAISE;
346
+ END IF;
347
+ END;
348
+ """
349
+
350
+ def _get_create_events_table_sql_for_type(self, storage_type: JSONStorageType) -> str:
351
+ """Get Oracle CREATE TABLE SQL for events with specified storage type.
352
+
353
+ Args:
354
+ storage_type: JSON storage type to use.
355
+
356
+ Returns:
357
+ SQL statement to create adk_events table.
358
+ """
359
+ if storage_type == JSONStorageType.JSON_NATIVE:
360
+ json_columns = """
361
+ content JSON,
362
+ grounding_metadata JSON,
363
+ custom_metadata JSON,
364
+ long_running_tool_ids_json JSON
365
+ """
366
+ elif storage_type == JSONStorageType.BLOB_JSON:
367
+ json_columns = """
368
+ content BLOB CHECK (content IS JSON),
369
+ grounding_metadata BLOB CHECK (grounding_metadata IS JSON),
370
+ custom_metadata BLOB CHECK (custom_metadata IS JSON),
371
+ long_running_tool_ids_json BLOB CHECK (long_running_tool_ids_json IS JSON)
372
+ """
373
+ else:
374
+ json_columns = """
375
+ content BLOB,
376
+ grounding_metadata BLOB,
377
+ custom_metadata BLOB,
378
+ long_running_tool_ids_json BLOB
379
+ """
380
+
381
+ inmemory_clause = " INMEMORY PRIORITY HIGH" if self._in_memory else ""
382
+
383
+ return f"""
384
+ BEGIN
385
+ EXECUTE IMMEDIATE 'CREATE TABLE {self._events_table} (
386
+ id VARCHAR2(128) PRIMARY KEY,
387
+ session_id VARCHAR2(128) NOT NULL,
388
+ app_name VARCHAR2(128) NOT NULL,
389
+ user_id VARCHAR2(128) NOT NULL,
390
+ invocation_id VARCHAR2(256),
391
+ author VARCHAR2(256),
392
+ actions BLOB,
393
+ branch VARCHAR2(256),
394
+ timestamp TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
395
+ {json_columns},
396
+ partial NUMBER(1),
397
+ turn_complete NUMBER(1),
398
+ interrupted NUMBER(1),
399
+ error_code VARCHAR2(256),
400
+ error_message VARCHAR2(1024),
401
+ CONSTRAINT fk_{self._events_table}_session FOREIGN KEY (session_id)
402
+ REFERENCES {self._session_table}(id) ON DELETE CASCADE
403
+ ){inmemory_clause}';
404
+ EXCEPTION
405
+ WHEN OTHERS THEN
406
+ IF SQLCODE != -955 THEN
407
+ RAISE;
408
+ END IF;
409
+ END;
410
+
411
+ BEGIN
412
+ EXECUTE IMMEDIATE 'CREATE INDEX idx_{self._events_table}_session
413
+ ON {self._events_table}(session_id, timestamp ASC)';
414
+ EXCEPTION
415
+ WHEN OTHERS THEN
416
+ IF SQLCODE != -955 THEN
417
+ RAISE;
418
+ END IF;
419
+ END;
420
+ """
421
+
422
+ def _get_drop_tables_sql(self) -> "list[str]":
423
+ """Get Oracle DROP TABLE SQL statements.
424
+
425
+ Returns:
426
+ List of SQL statements to drop tables and indexes.
427
+
428
+ Notes:
429
+ Order matters: drop events table (child) before sessions (parent).
430
+ Oracle automatically drops indexes when dropping tables.
431
+ """
432
+ return [
433
+ f"""
434
+ BEGIN
435
+ EXECUTE IMMEDIATE 'DROP INDEX idx_{self._events_table}_session';
436
+ EXCEPTION
437
+ WHEN OTHERS THEN
438
+ IF SQLCODE != -1418 THEN
439
+ RAISE;
440
+ END IF;
441
+ END;
442
+ """,
443
+ f"""
444
+ BEGIN
445
+ EXECUTE IMMEDIATE 'DROP INDEX idx_{self._session_table}_update_time';
446
+ EXCEPTION
447
+ WHEN OTHERS THEN
448
+ IF SQLCODE != -1418 THEN
449
+ RAISE;
450
+ END IF;
451
+ END;
452
+ """,
453
+ f"""
454
+ BEGIN
455
+ EXECUTE IMMEDIATE 'DROP INDEX idx_{self._session_table}_app_user';
456
+ EXCEPTION
457
+ WHEN OTHERS THEN
458
+ IF SQLCODE != -1418 THEN
459
+ RAISE;
460
+ END IF;
461
+ END;
462
+ """,
463
+ f"""
464
+ BEGIN
465
+ EXECUTE IMMEDIATE 'DROP TABLE {self._events_table}';
466
+ EXCEPTION
467
+ WHEN OTHERS THEN
468
+ IF SQLCODE != -942 THEN
469
+ RAISE;
470
+ END IF;
471
+ END;
472
+ """,
473
+ f"""
474
+ BEGIN
475
+ EXECUTE IMMEDIATE 'DROP TABLE {self._session_table}';
476
+ EXCEPTION
477
+ WHEN OTHERS THEN
478
+ IF SQLCODE != -942 THEN
479
+ RAISE;
480
+ END IF;
481
+ END;
482
+ """,
483
+ ]
484
+
485
+ async def create_tables(self) -> None:
486
+ """Create both sessions and events tables if they don't exist.
487
+
488
+ Notes:
489
+ Detects Oracle version to determine optimal JSON storage type.
490
+ Uses version-appropriate table schema.
491
+ """
492
+ storage_type = await self._detect_json_storage_type()
493
+ logger.info("Creating ADK tables with storage type: %s", storage_type)
494
+
495
+ async with self._config.provide_session() as driver:
496
+ await driver.execute_script(self._get_create_sessions_table_sql_for_type(storage_type))
497
+
498
+ await driver.execute_script(self._get_create_events_table_sql_for_type(storage_type))
499
+
500
+ logger.debug("Created ADK tables: %s, %s", self._session_table, self._events_table)
501
+
502
+ async def create_session(
503
+ self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None
504
+ ) -> SessionRecord:
505
+ """Create a new session.
506
+
507
+ Args:
508
+ session_id: Unique session identifier.
509
+ app_name: Application name.
510
+ user_id: User identifier.
511
+ state: Initial session state.
512
+ owner_id: Optional owner ID value for owner_id_column (if configured).
513
+
514
+ Returns:
515
+ Created session record.
516
+
517
+ Notes:
518
+ Uses SYSTIMESTAMP for create_time and update_time.
519
+ State is serialized using version-appropriate format.
520
+ owner_id is ignored if owner_id_column not configured.
521
+ """
522
+ state_data = await self._serialize_state(state)
523
+
524
+ if self._owner_id_column_name:
525
+ sql = f"""
526
+ INSERT INTO {self._session_table} (id, app_name, user_id, state, create_time, update_time, {self._owner_id_column_name})
527
+ VALUES (:id, :app_name, :user_id, :state, SYSTIMESTAMP, SYSTIMESTAMP, :owner_id)
528
+ """
529
+ params = {
530
+ "id": session_id,
531
+ "app_name": app_name,
532
+ "user_id": user_id,
533
+ "state": state_data,
534
+ "owner_id": owner_id,
535
+ }
536
+ else:
537
+ sql = f"""
538
+ INSERT INTO {self._session_table} (id, app_name, user_id, state, create_time, update_time)
539
+ VALUES (:id, :app_name, :user_id, :state, SYSTIMESTAMP, SYSTIMESTAMP)
540
+ """
541
+ params = {"id": session_id, "app_name": app_name, "user_id": user_id, "state": state_data}
542
+
543
+ async with self._config.provide_connection() as conn:
544
+ cursor = conn.cursor()
545
+ await cursor.execute(sql, params)
546
+ await conn.commit()
547
+
548
+ return await self.get_session(session_id) # type: ignore[return-value]
549
+
550
+ async def get_session(self, session_id: str) -> "SessionRecord | None":
551
+ """Get session by ID.
552
+
553
+ Args:
554
+ session_id: Session identifier.
555
+
556
+ Returns:
557
+ Session record or None if not found.
558
+
559
+ Notes:
560
+ Oracle returns datetime objects for TIMESTAMP columns.
561
+ State is deserialized using version-appropriate format.
562
+ """
563
+
564
+ sql = f"""
565
+ SELECT id, app_name, user_id, state, create_time, update_time
566
+ FROM {self._session_table}
567
+ WHERE id = :id
568
+ """
569
+
570
+ try:
571
+ async with self._config.provide_connection() as conn:
572
+ cursor = conn.cursor()
573
+ await cursor.execute(sql, {"id": session_id})
574
+ row = await cursor.fetchone()
575
+
576
+ if row is None:
577
+ return None
578
+
579
+ session_id_val, app_name, user_id, state_data, create_time, update_time = row
580
+
581
+ state = await self._deserialize_state(state_data)
582
+
583
+ return SessionRecord(
584
+ id=session_id_val,
585
+ app_name=app_name,
586
+ user_id=user_id,
587
+ state=state,
588
+ create_time=create_time,
589
+ update_time=update_time,
590
+ )
591
+ except oracledb.DatabaseError as e:
592
+ error_obj = e.args[0] if e.args else None
593
+ if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR:
594
+ return None
595
+ raise
596
+
597
+ async def update_session_state(self, session_id: str, state: "dict[str, Any]") -> None:
598
+ """Update session state.
599
+
600
+ Args:
601
+ session_id: Session identifier.
602
+ state: New state dictionary (replaces existing state).
603
+
604
+ Notes:
605
+ This replaces the entire state dictionary.
606
+ Updates update_time to current timestamp.
607
+ State is serialized using version-appropriate format.
608
+ """
609
+ state_data = await self._serialize_state(state)
610
+
611
+ sql = f"""
612
+ UPDATE {self._session_table}
613
+ SET state = :state, update_time = SYSTIMESTAMP
614
+ WHERE id = :id
615
+ """
616
+
617
+ async with self._config.provide_connection() as conn:
618
+ cursor = conn.cursor()
619
+ await cursor.execute(sql, {"state": state_data, "id": session_id})
620
+ await conn.commit()
621
+
622
+ async def delete_session(self, session_id: str) -> None:
623
+ """Delete session and all associated events (cascade).
624
+
625
+ Args:
626
+ session_id: Session identifier.
627
+
628
+ Notes:
629
+ Foreign key constraint ensures events are cascade-deleted.
630
+ """
631
+ sql = f"DELETE FROM {self._session_table} WHERE id = :id"
632
+
633
+ async with self._config.provide_connection() as conn:
634
+ cursor = conn.cursor()
635
+ await cursor.execute(sql, {"id": session_id})
636
+ await conn.commit()
637
+
638
+ async def list_sessions(self, app_name: str, user_id: str | None = None) -> "list[SessionRecord]":
639
+ """List sessions for an app, optionally filtered by user.
640
+
641
+ Args:
642
+ app_name: Application name.
643
+ user_id: User identifier. If None, lists all sessions for the app.
644
+
645
+ Returns:
646
+ List of session records ordered by update_time DESC.
647
+
648
+ Notes:
649
+ Uses composite index on (app_name, user_id) when user_id is provided.
650
+ State is deserialized using version-appropriate format.
651
+ """
652
+
653
+ if user_id is None:
654
+ sql = f"""
655
+ SELECT id, app_name, user_id, state, create_time, update_time
656
+ FROM {self._session_table}
657
+ WHERE app_name = :app_name
658
+ ORDER BY update_time DESC
659
+ """
660
+ params = {"app_name": app_name}
661
+ else:
662
+ sql = f"""
663
+ SELECT id, app_name, user_id, state, create_time, update_time
664
+ FROM {self._session_table}
665
+ WHERE app_name = :app_name AND user_id = :user_id
666
+ ORDER BY update_time DESC
667
+ """
668
+ params = {"app_name": app_name, "user_id": user_id}
669
+
670
+ try:
671
+ async with self._config.provide_connection() as conn:
672
+ cursor = conn.cursor()
673
+ await cursor.execute(sql, params)
674
+ rows = await cursor.fetchall()
675
+
676
+ results = []
677
+ for row in rows:
678
+ state = await self._deserialize_state(row[3])
679
+
680
+ results.append(
681
+ SessionRecord(
682
+ id=row[0],
683
+ app_name=row[1],
684
+ user_id=row[2],
685
+ state=state,
686
+ create_time=row[4],
687
+ update_time=row[5],
688
+ )
689
+ )
690
+ return results
691
+ except oracledb.DatabaseError as e:
692
+ error_obj = e.args[0] if e.args else None
693
+ if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR:
694
+ return []
695
+ raise
696
+
697
+ async def append_event(self, event_record: EventRecord) -> None:
698
+ """Append an event to a session.
699
+
700
+ Args:
701
+ event_record: Event record to store.
702
+
703
+ Notes:
704
+ Uses SYSTIMESTAMP for timestamp if not provided.
705
+ JSON fields are serialized using version-appropriate format.
706
+ Boolean fields are converted to NUMBER(1).
707
+ """
708
+ content_data = await self._serialize_json_field(event_record.get("content"))
709
+ grounding_metadata_data = await self._serialize_json_field(event_record.get("grounding_metadata"))
710
+ custom_metadata_data = await self._serialize_json_field(event_record.get("custom_metadata"))
711
+
712
+ sql = f"""
713
+ INSERT INTO {self._events_table} (
714
+ id, session_id, app_name, user_id, invocation_id, author, actions,
715
+ long_running_tool_ids_json, branch, timestamp, content,
716
+ grounding_metadata, custom_metadata, partial, turn_complete,
717
+ interrupted, error_code, error_message
718
+ ) VALUES (
719
+ :id, :session_id, :app_name, :user_id, :invocation_id, :author, :actions,
720
+ :long_running_tool_ids_json, :branch, :timestamp, :content,
721
+ :grounding_metadata, :custom_metadata, :partial, :turn_complete,
722
+ :interrupted, :error_code, :error_message
723
+ )
724
+ """
725
+
726
+ async with self._config.provide_connection() as conn:
727
+ cursor = conn.cursor()
728
+ await cursor.execute(
729
+ sql,
730
+ {
731
+ "id": event_record["id"],
732
+ "session_id": event_record["session_id"],
733
+ "app_name": event_record["app_name"],
734
+ "user_id": event_record["user_id"],
735
+ "invocation_id": event_record["invocation_id"],
736
+ "author": event_record["author"],
737
+ "actions": event_record["actions"],
738
+ "long_running_tool_ids_json": event_record.get("long_running_tool_ids_json"),
739
+ "branch": event_record.get("branch"),
740
+ "timestamp": event_record["timestamp"],
741
+ "content": content_data,
742
+ "grounding_metadata": grounding_metadata_data,
743
+ "custom_metadata": custom_metadata_data,
744
+ "partial": _to_oracle_bool(event_record.get("partial")),
745
+ "turn_complete": _to_oracle_bool(event_record.get("turn_complete")),
746
+ "interrupted": _to_oracle_bool(event_record.get("interrupted")),
747
+ "error_code": event_record.get("error_code"),
748
+ "error_message": event_record.get("error_message"),
749
+ },
750
+ )
751
+ await conn.commit()
752
+
753
+ async def get_events(
754
+ self, session_id: str, after_timestamp: "datetime | None" = None, limit: "int | None" = None
755
+ ) -> "list[EventRecord]":
756
+ """Get events for a session.
757
+
758
+ Args:
759
+ session_id: Session identifier.
760
+ after_timestamp: Only return events after this time.
761
+ limit: Maximum number of events to return.
762
+
763
+ Returns:
764
+ List of event records ordered by timestamp ASC.
765
+
766
+ Notes:
767
+ Uses index on (session_id, timestamp ASC).
768
+ JSON fields deserialized using version-appropriate format.
769
+ Converts BLOB actions to bytes and NUMBER(1) booleans to Python bool.
770
+ """
771
+
772
+ where_clauses = ["session_id = :session_id"]
773
+ params: dict[str, Any] = {"session_id": session_id}
774
+
775
+ if after_timestamp is not None:
776
+ where_clauses.append("timestamp > :after_timestamp")
777
+ params["after_timestamp"] = after_timestamp
778
+
779
+ where_clause = " AND ".join(where_clauses)
780
+ limit_clause = ""
781
+ if limit:
782
+ limit_clause = f" FETCH FIRST {limit} ROWS ONLY"
783
+
784
+ sql = f"""
785
+ SELECT id, session_id, app_name, user_id, invocation_id, author, actions,
786
+ long_running_tool_ids_json, branch, timestamp, content,
787
+ grounding_metadata, custom_metadata, partial, turn_complete,
788
+ interrupted, error_code, error_message
789
+ FROM {self._events_table}
790
+ WHERE {where_clause}
791
+ ORDER BY timestamp ASC{limit_clause}
792
+ """
793
+
794
+ try:
795
+ async with self._config.provide_connection() as conn:
796
+ cursor = conn.cursor()
797
+ await cursor.execute(sql, params)
798
+ rows = await cursor.fetchall()
799
+
800
+ results = []
801
+ for row in rows:
802
+ actions_blob = row[6]
803
+ if hasattr(actions_blob, "read"):
804
+ actions_data = await actions_blob.read()
805
+ else:
806
+ actions_data = actions_blob
807
+
808
+ content = await self._deserialize_json_field(row[10])
809
+ grounding_metadata = await self._deserialize_json_field(row[11])
810
+ custom_metadata = await self._deserialize_json_field(row[12])
811
+
812
+ results.append(
813
+ EventRecord(
814
+ id=row[0],
815
+ session_id=row[1],
816
+ app_name=row[2],
817
+ user_id=row[3],
818
+ invocation_id=row[4],
819
+ author=row[5],
820
+ actions=bytes(actions_data) if actions_data is not None else b"",
821
+ long_running_tool_ids_json=row[7],
822
+ branch=row[8],
823
+ timestamp=row[9],
824
+ content=content,
825
+ grounding_metadata=grounding_metadata,
826
+ custom_metadata=custom_metadata,
827
+ partial=_from_oracle_bool(row[13]),
828
+ turn_complete=_from_oracle_bool(row[14]),
829
+ interrupted=_from_oracle_bool(row[15]),
830
+ error_code=row[16],
831
+ error_message=row[17],
832
+ )
833
+ )
834
+ return results
835
+ except oracledb.DatabaseError as e:
836
+ error_obj = e.args[0] if e.args else None
837
+ if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR:
838
+ return []
839
+ raise
840
+
841
+
842
+ class OracleSyncADKStore(BaseSyncADKStore["OracleSyncConfig"]):
843
+ """Oracle synchronous ADK store using oracledb sync driver.
844
+
845
+ Implements session and event storage for Google Agent Development Kit
846
+ using Oracle Database via the python-oracledb synchronous driver. Provides:
847
+ - Session state management with version-specific JSON storage
848
+ - Event history tracking with BLOB-serialized actions
849
+ - TIMESTAMP WITH TIME ZONE for timezone-aware timestamps
850
+ - Foreign key constraints with cascade delete
851
+ - Efficient upserts using MERGE statement
852
+
853
+ Args:
854
+ config: OracleSyncConfig with extension_config["adk"] settings.
855
+
856
+ Example:
857
+ from sqlspec.adapters.oracledb import OracleSyncConfig
858
+ from sqlspec.adapters.oracledb.adk import OracleSyncADKStore
859
+
860
+ config = OracleSyncConfig(
861
+ pool_config={"dsn": "oracle://..."},
862
+ extension_config={
863
+ "adk": {
864
+ "session_table": "my_sessions",
865
+ "events_table": "my_events",
866
+ "owner_id_column": "account_id NUMBER(19) REFERENCES accounts(id)"
867
+ }
868
+ }
869
+ )
870
+ store = OracleSyncADKStore(config)
871
+ store.create_tables()
872
+
873
+ Notes:
874
+ - JSON storage type detected based on Oracle version (21c+, 12c+, legacy)
875
+ - BLOB for pre-serialized actions from Google ADK
876
+ - TIMESTAMP WITH TIME ZONE for timezone-aware timestamps
877
+ - NUMBER(1) for booleans (0/1/NULL)
878
+ - Named parameters using :param_name
879
+ - State merging handled at application level
880
+ - owner_id_column supports NUMBER, VARCHAR2, RAW for Oracle FK types
881
+ - Configuration is read from config.extension_config["adk"]
882
+ """
883
+
884
+ __slots__ = ("_in_memory", "_json_storage_type")
885
+
886
+ def __init__(self, config: "OracleSyncConfig") -> None:
887
+ """Initialize Oracle synchronous ADK store.
888
+
889
+ Args:
890
+ config: OracleSyncConfig instance.
891
+
892
+ Notes:
893
+ Configuration is read from config.extension_config["adk"]:
894
+ - session_table: Sessions table name (default: "adk_sessions")
895
+ - events_table: Events table name (default: "adk_events")
896
+ - owner_id_column: Optional owner FK column DDL (default: None)
897
+ - in_memory: Enable INMEMORY PRIORITY HIGH clause (default: False)
898
+ """
899
+ super().__init__(config)
900
+ self._json_storage_type: JSONStorageType | None = None
901
+
902
+ adk_config = config.extension_config.get("adk", {})
903
+ self._in_memory: bool = bool(adk_config.get("in_memory", False))
904
+
905
+ def _get_create_sessions_table_sql(self) -> str:
906
+ """Get Oracle CREATE TABLE SQL for sessions table.
907
+
908
+ Auto-detects optimal JSON storage type based on Oracle version.
909
+ Result is cached to minimize database queries.
910
+ """
911
+ storage_type = self._detect_json_storage_type()
912
+ return self._get_create_sessions_table_sql_for_type(storage_type)
913
+
914
+ def _get_create_events_table_sql(self) -> str:
915
+ """Get Oracle CREATE TABLE SQL for events table.
916
+
917
+ Auto-detects optimal JSON storage type based on Oracle version.
918
+ Result is cached to minimize database queries.
919
+ """
920
+ storage_type = self._detect_json_storage_type()
921
+ return self._get_create_events_table_sql_for_type(storage_type)
922
+
923
+ def _detect_json_storage_type(self) -> JSONStorageType:
924
+ """Detect the appropriate JSON storage type based on Oracle version.
925
+
926
+ Returns:
927
+ Appropriate JSONStorageType for this Oracle version.
928
+
929
+ Notes:
930
+ Queries product_component_version to determine Oracle version.
931
+ - Oracle 21c+ with compatible >= 20: Native JSON type
932
+ - Oracle 12c+: BLOB with IS JSON constraint (preferred)
933
+ - Oracle 11g and earlier: BLOB without constraint
934
+
935
+ BLOB is preferred over CLOB for 12c+ as per Oracle recommendations.
936
+ Result is cached in self._json_storage_type.
937
+ """
938
+ if self._json_storage_type is not None:
939
+ return self._json_storage_type
940
+
941
+ with self._config.provide_connection() as conn:
942
+ cursor = conn.cursor()
943
+ cursor.execute(
944
+ """
945
+ SELECT version FROM product_component_version
946
+ WHERE product LIKE 'Oracle%Database%'
947
+ """
948
+ )
949
+ row = cursor.fetchone()
950
+
951
+ if row is None:
952
+ logger.warning("Could not detect Oracle version, defaulting to BLOB_JSON")
953
+ self._json_storage_type = JSONStorageType.BLOB_JSON
954
+ return self._json_storage_type
955
+
956
+ version_str = str(row[0])
957
+ version_parts = version_str.split(".")
958
+ major_version = int(version_parts[0]) if version_parts else 0
959
+
960
+ if major_version >= ORACLE_MIN_JSON_NATIVE_VERSION:
961
+ cursor.execute("SELECT value FROM v$parameter WHERE name = 'compatible'")
962
+ compatible_row = cursor.fetchone()
963
+ if compatible_row:
964
+ compatible_parts = str(compatible_row[0]).split(".")
965
+ compatible_major = int(compatible_parts[0]) if compatible_parts else 0
966
+ if compatible_major >= ORACLE_MIN_JSON_NATIVE_COMPATIBLE:
967
+ logger.info("Detected Oracle %s with compatible >= 20, using JSON_NATIVE", version_str)
968
+ self._json_storage_type = JSONStorageType.JSON_NATIVE
969
+ return self._json_storage_type
970
+
971
+ if major_version >= ORACLE_MIN_JSON_BLOB_VERSION:
972
+ logger.info("Detected Oracle %s, using BLOB_JSON (recommended)", version_str)
973
+ self._json_storage_type = JSONStorageType.BLOB_JSON
974
+ return self._json_storage_type
975
+
976
+ logger.info("Detected Oracle %s (pre-12c), using BLOB_PLAIN", version_str)
977
+ self._json_storage_type = JSONStorageType.BLOB_PLAIN
978
+ return self._json_storage_type
979
+
980
+ def _serialize_state(self, state: "dict[str, Any]") -> "str | bytes":
981
+ """Serialize state dictionary to appropriate format based on storage type.
982
+
983
+ Args:
984
+ state: State dictionary to serialize.
985
+
986
+ Returns:
987
+ JSON string for JSON_NATIVE, bytes for BLOB types.
988
+ """
989
+ storage_type = self._detect_json_storage_type()
990
+
991
+ if storage_type == JSONStorageType.JSON_NATIVE:
992
+ return to_json(state)
993
+
994
+ return to_json(state, as_bytes=True)
995
+
996
+ def _deserialize_state(self, data: Any) -> "dict[str, Any]":
997
+ """Deserialize state data from database format.
998
+
999
+ Args:
1000
+ data: Data from database (may be LOB, str, bytes, or dict).
1001
+
1002
+ Returns:
1003
+ Deserialized state dictionary.
1004
+
1005
+ Notes:
1006
+ Handles LOB reading if data has read() method.
1007
+ Oracle JSON type may return dict directly.
1008
+ """
1009
+ if hasattr(data, "read"):
1010
+ data = data.read()
1011
+
1012
+ if isinstance(data, dict):
1013
+ return data
1014
+
1015
+ if isinstance(data, bytes):
1016
+ return from_json(data) # type: ignore[no-any-return]
1017
+
1018
+ if isinstance(data, str):
1019
+ return from_json(data) # type: ignore[no-any-return]
1020
+
1021
+ return from_json(str(data)) # type: ignore[no-any-return]
1022
+
1023
+ def _serialize_json_field(self, value: Any) -> "str | bytes | None":
1024
+ """Serialize optional JSON field for event storage.
1025
+
1026
+ Args:
1027
+ value: Value to serialize (dict or None).
1028
+
1029
+ Returns:
1030
+ Serialized JSON or None.
1031
+ """
1032
+ if value is None:
1033
+ return None
1034
+
1035
+ storage_type = self._detect_json_storage_type()
1036
+
1037
+ if storage_type == JSONStorageType.JSON_NATIVE:
1038
+ return to_json(value)
1039
+
1040
+ return to_json(value, as_bytes=True)
1041
+
1042
+ def _deserialize_json_field(self, data: Any) -> "dict[str, Any] | None":
1043
+ """Deserialize optional JSON field from database.
1044
+
1045
+ Args:
1046
+ data: Data from database (may be LOB, str, bytes, dict, or None).
1047
+
1048
+ Returns:
1049
+ Deserialized dictionary or None.
1050
+
1051
+ Notes:
1052
+ Oracle JSON type may return dict directly.
1053
+ """
1054
+ if data is None:
1055
+ return None
1056
+
1057
+ if hasattr(data, "read"):
1058
+ data = data.read()
1059
+
1060
+ if isinstance(data, dict):
1061
+ return data
1062
+
1063
+ if isinstance(data, bytes):
1064
+ return from_json(data) # type: ignore[no-any-return]
1065
+
1066
+ if isinstance(data, str):
1067
+ return from_json(data) # type: ignore[no-any-return]
1068
+
1069
+ return from_json(str(data)) # type: ignore[no-any-return]
1070
+
1071
+ def _get_create_sessions_table_sql_for_type(self, storage_type: JSONStorageType) -> str:
1072
+ """Get Oracle CREATE TABLE SQL for sessions with specified storage type.
1073
+
1074
+ Args:
1075
+ storage_type: JSON storage type to use.
1076
+
1077
+ Returns:
1078
+ SQL statement to create adk_sessions table.
1079
+ """
1080
+ if storage_type == JSONStorageType.JSON_NATIVE:
1081
+ state_column = "state JSON NOT NULL"
1082
+ elif storage_type == JSONStorageType.BLOB_JSON:
1083
+ state_column = "state BLOB CHECK (state IS JSON) NOT NULL"
1084
+ else:
1085
+ state_column = "state BLOB NOT NULL"
1086
+
1087
+ owner_id_column_sql = f", {self._owner_id_column_ddl}" if self._owner_id_column_ddl else ""
1088
+ inmemory_clause = " INMEMORY PRIORITY HIGH" if self._in_memory else ""
1089
+
1090
+ return f"""
1091
+ BEGIN
1092
+ EXECUTE IMMEDIATE 'CREATE TABLE {self._session_table} (
1093
+ id VARCHAR2(128) PRIMARY KEY,
1094
+ app_name VARCHAR2(128) NOT NULL,
1095
+ user_id VARCHAR2(128) NOT NULL,
1096
+ {state_column},
1097
+ create_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
1098
+ update_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL{owner_id_column_sql}
1099
+ ){inmemory_clause}';
1100
+ EXCEPTION
1101
+ WHEN OTHERS THEN
1102
+ IF SQLCODE != -955 THEN
1103
+ RAISE;
1104
+ END IF;
1105
+ END;
1106
+
1107
+ BEGIN
1108
+ EXECUTE IMMEDIATE 'CREATE INDEX idx_{self._session_table}_app_user
1109
+ ON {self._session_table}(app_name, user_id)';
1110
+ EXCEPTION
1111
+ WHEN OTHERS THEN
1112
+ IF SQLCODE != -955 THEN
1113
+ RAISE;
1114
+ END IF;
1115
+ END;
1116
+
1117
+ BEGIN
1118
+ EXECUTE IMMEDIATE 'CREATE INDEX idx_{self._session_table}_update_time
1119
+ ON {self._session_table}(update_time DESC)';
1120
+ EXCEPTION
1121
+ WHEN OTHERS THEN
1122
+ IF SQLCODE != -955 THEN
1123
+ RAISE;
1124
+ END IF;
1125
+ END;
1126
+ """
1127
+
1128
+ def _get_create_events_table_sql_for_type(self, storage_type: JSONStorageType) -> str:
1129
+ """Get Oracle CREATE TABLE SQL for events with specified storage type.
1130
+
1131
+ Args:
1132
+ storage_type: JSON storage type to use.
1133
+
1134
+ Returns:
1135
+ SQL statement to create adk_events table.
1136
+ """
1137
+ if storage_type == JSONStorageType.JSON_NATIVE:
1138
+ json_columns = """
1139
+ content JSON,
1140
+ grounding_metadata JSON,
1141
+ custom_metadata JSON,
1142
+ long_running_tool_ids_json JSON
1143
+ """
1144
+ elif storage_type == JSONStorageType.BLOB_JSON:
1145
+ json_columns = """
1146
+ content BLOB CHECK (content IS JSON),
1147
+ grounding_metadata BLOB CHECK (grounding_metadata IS JSON),
1148
+ custom_metadata BLOB CHECK (custom_metadata IS JSON),
1149
+ long_running_tool_ids_json BLOB CHECK (long_running_tool_ids_json IS JSON)
1150
+ """
1151
+ else:
1152
+ json_columns = """
1153
+ content BLOB,
1154
+ grounding_metadata BLOB,
1155
+ custom_metadata BLOB,
1156
+ long_running_tool_ids_json BLOB
1157
+ """
1158
+
1159
+ inmemory_clause = " INMEMORY PRIORITY HIGH" if self._in_memory else ""
1160
+
1161
+ return f"""
1162
+ BEGIN
1163
+ EXECUTE IMMEDIATE 'CREATE TABLE {self._events_table} (
1164
+ id VARCHAR2(128) PRIMARY KEY,
1165
+ session_id VARCHAR2(128) NOT NULL,
1166
+ app_name VARCHAR2(128) NOT NULL,
1167
+ user_id VARCHAR2(128) NOT NULL,
1168
+ invocation_id VARCHAR2(256),
1169
+ author VARCHAR2(256),
1170
+ actions BLOB,
1171
+ branch VARCHAR2(256),
1172
+ timestamp TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
1173
+ {json_columns},
1174
+ partial NUMBER(1),
1175
+ turn_complete NUMBER(1),
1176
+ interrupted NUMBER(1),
1177
+ error_code VARCHAR2(256),
1178
+ error_message VARCHAR2(1024),
1179
+ CONSTRAINT fk_{self._events_table}_session FOREIGN KEY (session_id)
1180
+ REFERENCES {self._session_table}(id) ON DELETE CASCADE
1181
+ ){inmemory_clause}';
1182
+ EXCEPTION
1183
+ WHEN OTHERS THEN
1184
+ IF SQLCODE != -955 THEN
1185
+ RAISE;
1186
+ END IF;
1187
+ END;
1188
+
1189
+ BEGIN
1190
+ EXECUTE IMMEDIATE 'CREATE INDEX idx_{self._events_table}_session
1191
+ ON {self._events_table}(session_id, timestamp ASC)';
1192
+ EXCEPTION
1193
+ WHEN OTHERS THEN
1194
+ IF SQLCODE != -955 THEN
1195
+ RAISE;
1196
+ END IF;
1197
+ END;
1198
+ """
1199
+
1200
+ def _get_drop_tables_sql(self) -> "list[str]":
1201
+ """Get Oracle DROP TABLE SQL statements.
1202
+
1203
+ Returns:
1204
+ List of SQL statements to drop tables and indexes.
1205
+
1206
+ Notes:
1207
+ Order matters: drop events table (child) before sessions (parent).
1208
+ Oracle automatically drops indexes when dropping tables.
1209
+ """
1210
+ return [
1211
+ f"""
1212
+ BEGIN
1213
+ EXECUTE IMMEDIATE 'DROP INDEX idx_{self._events_table}_session';
1214
+ EXCEPTION
1215
+ WHEN OTHERS THEN
1216
+ IF SQLCODE != -1418 THEN
1217
+ RAISE;
1218
+ END IF;
1219
+ END;
1220
+ """,
1221
+ f"""
1222
+ BEGIN
1223
+ EXECUTE IMMEDIATE 'DROP INDEX idx_{self._session_table}_update_time';
1224
+ EXCEPTION
1225
+ WHEN OTHERS THEN
1226
+ IF SQLCODE != -1418 THEN
1227
+ RAISE;
1228
+ END IF;
1229
+ END;
1230
+ """,
1231
+ f"""
1232
+ BEGIN
1233
+ EXECUTE IMMEDIATE 'DROP INDEX idx_{self._session_table}_app_user';
1234
+ EXCEPTION
1235
+ WHEN OTHERS THEN
1236
+ IF SQLCODE != -1418 THEN
1237
+ RAISE;
1238
+ END IF;
1239
+ END;
1240
+ """,
1241
+ f"""
1242
+ BEGIN
1243
+ EXECUTE IMMEDIATE 'DROP TABLE {self._events_table}';
1244
+ EXCEPTION
1245
+ WHEN OTHERS THEN
1246
+ IF SQLCODE != -942 THEN
1247
+ RAISE;
1248
+ END IF;
1249
+ END;
1250
+ """,
1251
+ f"""
1252
+ BEGIN
1253
+ EXECUTE IMMEDIATE 'DROP TABLE {self._session_table}';
1254
+ EXCEPTION
1255
+ WHEN OTHERS THEN
1256
+ IF SQLCODE != -942 THEN
1257
+ RAISE;
1258
+ END IF;
1259
+ END;
1260
+ """,
1261
+ ]
1262
+
1263
+ def create_tables(self) -> None:
1264
+ """Create both sessions and events tables if they don't exist.
1265
+
1266
+ Notes:
1267
+ Detects Oracle version to determine optimal JSON storage type.
1268
+ Uses version-appropriate table schema.
1269
+ """
1270
+ storage_type = self._detect_json_storage_type()
1271
+ logger.info("Creating ADK tables with storage type: %s", storage_type)
1272
+
1273
+ with self._config.provide_session() as driver:
1274
+ sessions_sql = SQL(self._get_create_sessions_table_sql_for_type(storage_type))
1275
+ driver.execute_script(sessions_sql)
1276
+
1277
+ events_sql = SQL(self._get_create_events_table_sql_for_type(storage_type))
1278
+ driver.execute_script(events_sql)
1279
+
1280
+ logger.debug("Created ADK tables: %s, %s", self._session_table, self._events_table)
1281
+
1282
+ def create_session(
1283
+ self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None
1284
+ ) -> SessionRecord:
1285
+ """Create a new session.
1286
+
1287
+ Args:
1288
+ session_id: Unique session identifier.
1289
+ app_name: Application name.
1290
+ user_id: User identifier.
1291
+ state: Initial session state.
1292
+ owner_id: Optional owner ID value for owner_id_column (if configured).
1293
+
1294
+ Returns:
1295
+ Created session record.
1296
+
1297
+ Notes:
1298
+ Uses SYSTIMESTAMP for create_time and update_time.
1299
+ State is serialized using version-appropriate format.
1300
+ owner_id is ignored if owner_id_column not configured.
1301
+ """
1302
+ state_data = self._serialize_state(state)
1303
+
1304
+ if self._owner_id_column_name:
1305
+ sql = f"""
1306
+ INSERT INTO {self._session_table} (id, app_name, user_id, state, create_time, update_time, {self._owner_id_column_name})
1307
+ VALUES (:id, :app_name, :user_id, :state, SYSTIMESTAMP, SYSTIMESTAMP, :owner_id)
1308
+ """
1309
+ params = {
1310
+ "id": session_id,
1311
+ "app_name": app_name,
1312
+ "user_id": user_id,
1313
+ "state": state_data,
1314
+ "owner_id": owner_id,
1315
+ }
1316
+ else:
1317
+ sql = f"""
1318
+ INSERT INTO {self._session_table} (id, app_name, user_id, state, create_time, update_time)
1319
+ VALUES (:id, :app_name, :user_id, :state, SYSTIMESTAMP, SYSTIMESTAMP)
1320
+ """
1321
+ params = {"id": session_id, "app_name": app_name, "user_id": user_id, "state": state_data}
1322
+
1323
+ with self._config.provide_connection() as conn:
1324
+ cursor = conn.cursor()
1325
+ cursor.execute(sql, params)
1326
+ conn.commit()
1327
+
1328
+ return self.get_session(session_id) # type: ignore[return-value]
1329
+
1330
+ def get_session(self, session_id: str) -> "SessionRecord | None":
1331
+ """Get session by ID.
1332
+
1333
+ Args:
1334
+ session_id: Session identifier.
1335
+
1336
+ Returns:
1337
+ Session record or None if not found.
1338
+
1339
+ Notes:
1340
+ Oracle returns datetime objects for TIMESTAMP columns.
1341
+ State is deserialized using version-appropriate format.
1342
+ """
1343
+
1344
+ sql = f"""
1345
+ SELECT id, app_name, user_id, state, create_time, update_time
1346
+ FROM {self._session_table}
1347
+ WHERE id = :id
1348
+ """
1349
+
1350
+ try:
1351
+ with self._config.provide_connection() as conn:
1352
+ cursor = conn.cursor()
1353
+ cursor.execute(sql, {"id": session_id})
1354
+ row = cursor.fetchone()
1355
+
1356
+ if row is None:
1357
+ return None
1358
+
1359
+ session_id_val, app_name, user_id, state_data, create_time, update_time = row
1360
+
1361
+ state = self._deserialize_state(state_data)
1362
+
1363
+ return SessionRecord(
1364
+ id=session_id_val,
1365
+ app_name=app_name,
1366
+ user_id=user_id,
1367
+ state=state,
1368
+ create_time=create_time,
1369
+ update_time=update_time,
1370
+ )
1371
+ except oracledb.DatabaseError as e:
1372
+ error_obj = e.args[0] if e.args else None
1373
+ if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR:
1374
+ return None
1375
+ raise
1376
+
1377
+ def update_session_state(self, session_id: str, state: "dict[str, Any]") -> None:
1378
+ """Update session state.
1379
+
1380
+ Args:
1381
+ session_id: Session identifier.
1382
+ state: New state dictionary (replaces existing state).
1383
+
1384
+ Notes:
1385
+ This replaces the entire state dictionary.
1386
+ Updates update_time to current timestamp.
1387
+ State is serialized using version-appropriate format.
1388
+ """
1389
+ state_data = self._serialize_state(state)
1390
+
1391
+ sql = f"""
1392
+ UPDATE {self._session_table}
1393
+ SET state = :state, update_time = SYSTIMESTAMP
1394
+ WHERE id = :id
1395
+ """
1396
+
1397
+ with self._config.provide_connection() as conn:
1398
+ cursor = conn.cursor()
1399
+ cursor.execute(sql, {"state": state_data, "id": session_id})
1400
+ conn.commit()
1401
+
1402
+ def delete_session(self, session_id: str) -> None:
1403
+ """Delete session and all associated events (cascade).
1404
+
1405
+ Args:
1406
+ session_id: Session identifier.
1407
+
1408
+ Notes:
1409
+ Foreign key constraint ensures events are cascade-deleted.
1410
+ """
1411
+ sql = f"DELETE FROM {self._session_table} WHERE id = :id"
1412
+
1413
+ with self._config.provide_connection() as conn:
1414
+ cursor = conn.cursor()
1415
+ cursor.execute(sql, {"id": session_id})
1416
+ conn.commit()
1417
+
1418
+ def list_sessions(self, app_name: str, user_id: str | None = None) -> "list[SessionRecord]":
1419
+ """List sessions for an app, optionally filtered by user.
1420
+
1421
+ Args:
1422
+ app_name: Application name.
1423
+ user_id: User identifier. If None, lists all sessions for the app.
1424
+
1425
+ Returns:
1426
+ List of session records ordered by update_time DESC.
1427
+
1428
+ Notes:
1429
+ Uses composite index on (app_name, user_id) when user_id is provided.
1430
+ State is deserialized using version-appropriate format.
1431
+ """
1432
+
1433
+ if user_id is None:
1434
+ sql = f"""
1435
+ SELECT id, app_name, user_id, state, create_time, update_time
1436
+ FROM {self._session_table}
1437
+ WHERE app_name = :app_name
1438
+ ORDER BY update_time DESC
1439
+ """
1440
+ params = {"app_name": app_name}
1441
+ else:
1442
+ sql = f"""
1443
+ SELECT id, app_name, user_id, state, create_time, update_time
1444
+ FROM {self._session_table}
1445
+ WHERE app_name = :app_name AND user_id = :user_id
1446
+ ORDER BY update_time DESC
1447
+ """
1448
+ params = {"app_name": app_name, "user_id": user_id}
1449
+
1450
+ try:
1451
+ with self._config.provide_connection() as conn:
1452
+ cursor = conn.cursor()
1453
+ cursor.execute(sql, params)
1454
+ rows = cursor.fetchall()
1455
+
1456
+ results = []
1457
+ for row in rows:
1458
+ state = self._deserialize_state(row[3])
1459
+
1460
+ results.append(
1461
+ SessionRecord(
1462
+ id=row[0],
1463
+ app_name=row[1],
1464
+ user_id=row[2],
1465
+ state=state,
1466
+ create_time=row[4],
1467
+ update_time=row[5],
1468
+ )
1469
+ )
1470
+ return results
1471
+ except oracledb.DatabaseError as e:
1472
+ error_obj = e.args[0] if e.args else None
1473
+ if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR:
1474
+ return []
1475
+ raise
1476
+
1477
+ def create_event(
1478
+ self,
1479
+ event_id: str,
1480
+ session_id: str,
1481
+ app_name: str,
1482
+ user_id: str,
1483
+ author: "str | None" = None,
1484
+ actions: "bytes | None" = None,
1485
+ content: "dict[str, Any] | None" = None,
1486
+ **kwargs: Any,
1487
+ ) -> "EventRecord":
1488
+ """Create a new event.
1489
+
1490
+ Args:
1491
+ event_id: Unique event identifier.
1492
+ session_id: Session identifier.
1493
+ app_name: Application name.
1494
+ user_id: User identifier.
1495
+ author: Event author (user/assistant/system).
1496
+ actions: Pickled actions object.
1497
+ content: Event content (JSONB/JSON).
1498
+ **kwargs: Additional optional fields.
1499
+
1500
+ Returns:
1501
+ Created event record.
1502
+
1503
+ Notes:
1504
+ Uses SYSTIMESTAMP for timestamp if not provided.
1505
+ JSON fields are serialized using version-appropriate format.
1506
+ Boolean fields are converted to NUMBER(1).
1507
+ """
1508
+ content_data = self._serialize_json_field(content)
1509
+ grounding_metadata_data = self._serialize_json_field(kwargs.get("grounding_metadata"))
1510
+ custom_metadata_data = self._serialize_json_field(kwargs.get("custom_metadata"))
1511
+
1512
+ sql = f"""
1513
+ INSERT INTO {self._events_table} (
1514
+ id, session_id, app_name, user_id, invocation_id, author, actions,
1515
+ long_running_tool_ids_json, branch, timestamp, content,
1516
+ grounding_metadata, custom_metadata, partial, turn_complete,
1517
+ interrupted, error_code, error_message
1518
+ ) VALUES (
1519
+ :id, :session_id, :app_name, :user_id, :invocation_id, :author, :actions,
1520
+ :long_running_tool_ids_json, :branch, :timestamp, :content,
1521
+ :grounding_metadata, :custom_metadata, :partial, :turn_complete,
1522
+ :interrupted, :error_code, :error_message
1523
+ )
1524
+ """
1525
+
1526
+ with self._config.provide_connection() as conn:
1527
+ cursor = conn.cursor()
1528
+ cursor.execute(
1529
+ sql,
1530
+ {
1531
+ "id": event_id,
1532
+ "session_id": session_id,
1533
+ "app_name": app_name,
1534
+ "user_id": user_id,
1535
+ "invocation_id": kwargs.get("invocation_id"),
1536
+ "author": author,
1537
+ "actions": actions,
1538
+ "long_running_tool_ids_json": kwargs.get("long_running_tool_ids_json"),
1539
+ "branch": kwargs.get("branch"),
1540
+ "timestamp": kwargs.get("timestamp"),
1541
+ "content": content_data,
1542
+ "grounding_metadata": grounding_metadata_data,
1543
+ "custom_metadata": custom_metadata_data,
1544
+ "partial": _to_oracle_bool(kwargs.get("partial")),
1545
+ "turn_complete": _to_oracle_bool(kwargs.get("turn_complete")),
1546
+ "interrupted": _to_oracle_bool(kwargs.get("interrupted")),
1547
+ "error_code": kwargs.get("error_code"),
1548
+ "error_message": kwargs.get("error_message"),
1549
+ },
1550
+ )
1551
+ conn.commit()
1552
+
1553
+ events = self.list_events(session_id)
1554
+ for event in events:
1555
+ if event["id"] == event_id:
1556
+ return event
1557
+
1558
+ msg = f"Failed to retrieve created event {event_id}"
1559
+ raise RuntimeError(msg)
1560
+
1561
+ def list_events(self, session_id: str) -> "list[EventRecord]":
1562
+ """List events for a session ordered by timestamp.
1563
+
1564
+ Args:
1565
+ session_id: Session identifier.
1566
+
1567
+ Returns:
1568
+ List of event records ordered by timestamp ASC.
1569
+
1570
+ Notes:
1571
+ Uses index on (session_id, timestamp ASC).
1572
+ JSON fields deserialized using version-appropriate format.
1573
+ Converts BLOB actions to bytes and NUMBER(1) booleans to Python bool.
1574
+ """
1575
+
1576
+ sql = f"""
1577
+ SELECT id, session_id, app_name, user_id, invocation_id, author, actions,
1578
+ long_running_tool_ids_json, branch, timestamp, content,
1579
+ grounding_metadata, custom_metadata, partial, turn_complete,
1580
+ interrupted, error_code, error_message
1581
+ FROM {self._events_table}
1582
+ WHERE session_id = :session_id
1583
+ ORDER BY timestamp ASC
1584
+ """
1585
+
1586
+ try:
1587
+ with self._config.provide_connection() as conn:
1588
+ cursor = conn.cursor()
1589
+ cursor.execute(sql, {"session_id": session_id})
1590
+ rows = cursor.fetchall()
1591
+
1592
+ results = []
1593
+ for row in rows:
1594
+ actions_blob = row[6]
1595
+ actions_data = actions_blob.read() if hasattr(actions_blob, "read") else actions_blob
1596
+
1597
+ content = self._deserialize_json_field(row[10])
1598
+ grounding_metadata = self._deserialize_json_field(row[11])
1599
+ custom_metadata = self._deserialize_json_field(row[12])
1600
+
1601
+ results.append(
1602
+ EventRecord(
1603
+ id=row[0],
1604
+ session_id=row[1],
1605
+ app_name=row[2],
1606
+ user_id=row[3],
1607
+ invocation_id=row[4],
1608
+ author=row[5],
1609
+ actions=bytes(actions_data) if actions_data is not None else b"",
1610
+ long_running_tool_ids_json=row[7],
1611
+ branch=row[8],
1612
+ timestamp=row[9],
1613
+ content=content,
1614
+ grounding_metadata=grounding_metadata,
1615
+ custom_metadata=custom_metadata,
1616
+ partial=_from_oracle_bool(row[13]),
1617
+ turn_complete=_from_oracle_bool(row[14]),
1618
+ interrupted=_from_oracle_bool(row[15]),
1619
+ error_code=row[16],
1620
+ error_message=row[17],
1621
+ )
1622
+ )
1623
+ return results
1624
+ except oracledb.DatabaseError as e:
1625
+ error_obj = e.args[0] if e.args else None
1626
+ if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR:
1627
+ return []
1628
+ raise