sqlspec 0.26.0__py3-none-any.whl → 0.27.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 (197) hide show
  1. sqlspec/__init__.py +7 -15
  2. sqlspec/_serialization.py +55 -25
  3. sqlspec/_typing.py +62 -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 +870 -0
  7. sqlspec/adapters/adbc/config.py +62 -12
  8. sqlspec/adapters/adbc/data_dictionary.py +52 -2
  9. sqlspec/adapters/adbc/driver.py +144 -45
  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 +527 -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 +493 -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 +450 -0
  36. sqlspec/adapters/asyncpg/config.py +57 -36
  37. sqlspec/adapters/asyncpg/data_dictionary.py +41 -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 +576 -0
  44. sqlspec/adapters/bigquery/config.py +25 -11
  45. sqlspec/adapters/bigquery/data_dictionary.py +42 -2
  46. sqlspec/adapters/bigquery/driver.py +352 -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 +553 -0
  53. sqlspec/adapters/duckdb/config.py +79 -21
  54. sqlspec/adapters/duckdb/data_dictionary.py +41 -2
  55. sqlspec/adapters/duckdb/driver.py +138 -43
  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 +1745 -0
  64. sqlspec/adapters/oracledb/config.py +120 -36
  65. sqlspec/adapters/oracledb/data_dictionary.py +87 -20
  66. sqlspec/adapters/oracledb/driver.py +292 -84
  67. sqlspec/adapters/oracledb/litestar/__init__.py +5 -0
  68. sqlspec/adapters/oracledb/litestar/store.py +767 -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 +482 -0
  75. sqlspec/adapters/psqlpy/config.py +45 -19
  76. sqlspec/adapters/psqlpy/data_dictionary.py +41 -2
  77. sqlspec/adapters/psqlpy/driver.py +101 -31
  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 +944 -0
  85. sqlspec/adapters/psycopg/config.py +65 -37
  86. sqlspec/adapters/psycopg/data_dictionary.py +77 -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 +572 -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 +231 -60
  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 +37 -37
  121. sqlspec/core/hashing.py +9 -9
  122. sqlspec/core/parameters.py +76 -45
  123. sqlspec/core/result.py +102 -46
  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 +95 -161
  129. sqlspec/driver/_common.py +133 -80
  130. sqlspec/driver/_sync.py +95 -162
  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 +73 -53
  142. sqlspec/extensions/litestar/__init__.py +21 -4
  143. sqlspec/extensions/litestar/cli.py +54 -10
  144. sqlspec/extensions/litestar/config.py +59 -266
  145. sqlspec/extensions/litestar/handlers.py +46 -17
  146. sqlspec/extensions/litestar/migrations/0001_create_session_table.py +137 -0
  147. sqlspec/extensions/litestar/migrations/__init__.py +3 -0
  148. sqlspec/extensions/litestar/plugin.py +324 -223
  149. sqlspec/extensions/litestar/providers.py +25 -25
  150. sqlspec/extensions/litestar/store.py +265 -0
  151. sqlspec/loader.py +30 -49
  152. sqlspec/migrations/base.py +200 -76
  153. sqlspec/migrations/commands.py +591 -62
  154. sqlspec/migrations/context.py +6 -9
  155. sqlspec/migrations/fix.py +199 -0
  156. sqlspec/migrations/loaders.py +47 -19
  157. sqlspec/migrations/runner.py +241 -75
  158. sqlspec/migrations/tracker.py +237 -21
  159. sqlspec/migrations/utils.py +51 -3
  160. sqlspec/migrations/validation.py +177 -0
  161. sqlspec/protocols.py +66 -36
  162. sqlspec/storage/_utils.py +98 -0
  163. sqlspec/storage/backends/fsspec.py +134 -106
  164. sqlspec/storage/backends/local.py +78 -51
  165. sqlspec/storage/backends/obstore.py +278 -162
  166. sqlspec/storage/registry.py +75 -39
  167. sqlspec/typing.py +14 -84
  168. sqlspec/utils/config_resolver.py +6 -6
  169. sqlspec/utils/correlation.py +4 -5
  170. sqlspec/utils/data_transformation.py +3 -2
  171. sqlspec/utils/deprecation.py +9 -8
  172. sqlspec/utils/fixtures.py +4 -4
  173. sqlspec/utils/logging.py +46 -6
  174. sqlspec/utils/module_loader.py +2 -2
  175. sqlspec/utils/schema.py +288 -0
  176. sqlspec/utils/serializers.py +3 -3
  177. sqlspec/utils/sync_tools.py +21 -17
  178. sqlspec/utils/text.py +1 -2
  179. sqlspec/utils/type_guards.py +111 -20
  180. sqlspec/utils/version.py +433 -0
  181. {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/METADATA +40 -21
  182. sqlspec-0.27.0.dist-info/RECORD +207 -0
  183. sqlspec/builder/mixins/__init__.py +0 -55
  184. sqlspec/builder/mixins/_cte_and_set_ops.py +0 -253
  185. sqlspec/builder/mixins/_delete_operations.py +0 -50
  186. sqlspec/builder/mixins/_insert_operations.py +0 -282
  187. sqlspec/builder/mixins/_merge_operations.py +0 -698
  188. sqlspec/builder/mixins/_order_limit_operations.py +0 -145
  189. sqlspec/builder/mixins/_pivot_operations.py +0 -157
  190. sqlspec/builder/mixins/_select_operations.py +0 -930
  191. sqlspec/builder/mixins/_update_operations.py +0 -199
  192. sqlspec/builder/mixins/_where_clause.py +0 -1298
  193. sqlspec-0.26.0.dist-info/RECORD +0 -157
  194. sqlspec-0.26.0.dist-info/licenses/NOTICE +0 -29
  195. {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/WHEEL +0 -0
  196. {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/entry_points.txt +0 -0
  197. {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,767 @@
1
+ """Oracle session store for Litestar integration."""
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ from typing import TYPE_CHECKING
5
+
6
+ from sqlspec.extensions.litestar.store import BaseSQLSpecStore
7
+ from sqlspec.utils.logging import get_logger
8
+ from sqlspec.utils.sync_tools import async_
9
+
10
+ if TYPE_CHECKING:
11
+ from sqlspec.adapters.oracledb.config import OracleAsyncConfig, OracleSyncConfig
12
+
13
+ logger = get_logger("adapters.oracledb.litestar.store")
14
+
15
+ ORACLE_SMALL_BLOB_LIMIT = 32000
16
+
17
+ __all__ = ("OracleAsyncStore", "OracleSyncStore")
18
+
19
+
20
+ class OracleAsyncStore(BaseSQLSpecStore["OracleAsyncConfig"]):
21
+ """Oracle session store using async OracleDB driver.
22
+
23
+ Implements server-side session storage for Litestar using Oracle Database
24
+ via the async python-oracledb driver. Provides efficient session management with:
25
+ - Native async Oracle operations
26
+ - MERGE statement for atomic UPSERT
27
+ - Automatic expiration handling
28
+ - Efficient cleanup of expired sessions
29
+ - Optional In-Memory Column Store support (requires Oracle Database In-Memory license)
30
+
31
+ Args:
32
+ config: OracleAsyncConfig with extension_config["litestar"] settings.
33
+
34
+ Example:
35
+ from sqlspec.adapters.oracledb import OracleAsyncConfig
36
+ from sqlspec.adapters.oracledb.litestar.store import OracleAsyncStore
37
+
38
+ config = OracleAsyncConfig(
39
+ pool_config={"dsn": "oracle://..."},
40
+ extension_config={
41
+ "litestar": {
42
+ "session_table": "my_sessions",
43
+ "in_memory": True
44
+ }
45
+ }
46
+ )
47
+ store = OracleAsyncStore(config)
48
+ await store.create_table()
49
+
50
+ Notes:
51
+ Configuration is read from config.extension_config["litestar"]:
52
+ - session_table: Session table name (default: "litestar_session")
53
+ - in_memory: Enable INMEMORY clause (default: False, Oracle-specific)
54
+
55
+ When in_memory=True, the table is created with INMEMORY clause for
56
+ faster read operations. This requires Oracle Database 12.1.0.2+ with the
57
+ Database In-Memory option licensed. If In-Memory is not available, the
58
+ table creation will fail with ORA-00439 or ORA-62142.
59
+ """
60
+
61
+ __slots__ = ("_in_memory",)
62
+
63
+ def __init__(self, config: "OracleAsyncConfig") -> None:
64
+ """Initialize Oracle session store.
65
+
66
+ Args:
67
+ config: OracleAsyncConfig instance.
68
+
69
+ Notes:
70
+ Configuration is read from config.extension_config["litestar"]:
71
+ - session_table: Session table name (default: "litestar_session")
72
+ - in_memory: Enable INMEMORY clause (default: False)
73
+ """
74
+ super().__init__(config)
75
+
76
+ if hasattr(config, "extension_config") and config.extension_config:
77
+ litestar_config = config.extension_config.get("litestar", {})
78
+ self._in_memory: bool = bool(litestar_config.get("in_memory", False))
79
+ else:
80
+ self._in_memory = False
81
+
82
+ def _get_create_table_sql(self) -> str:
83
+ """Get Oracle CREATE TABLE SQL with optimized schema.
84
+
85
+ Returns:
86
+ SQL statement to create the sessions table with proper indexes.
87
+
88
+ Notes:
89
+ - Uses TIMESTAMP WITH TIME ZONE for timezone-aware expiration timestamps
90
+ - Index on expires_at for efficient cleanup queries
91
+ - BLOB type for data storage (Oracle native binary type)
92
+ - Audit columns (created_at, updated_at) help with debugging
93
+ - Table name is internally controlled, not user input (S608 suppressed)
94
+ - INMEMORY clause added when in_memory=True for faster reads
95
+ """
96
+ inmemory_clause = "INMEMORY" if self._in_memory else ""
97
+ return f"""
98
+ BEGIN
99
+ EXECUTE IMMEDIATE 'CREATE TABLE {self._table_name} (
100
+ session_id VARCHAR2(255) PRIMARY KEY,
101
+ data BLOB NOT NULL,
102
+ expires_at TIMESTAMP WITH TIME ZONE,
103
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
104
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
105
+ ) {inmemory_clause}';
106
+ EXCEPTION
107
+ WHEN OTHERS THEN
108
+ IF SQLCODE != -955 THEN
109
+ RAISE;
110
+ END IF;
111
+ END;
112
+
113
+ BEGIN
114
+ EXECUTE IMMEDIATE 'CREATE INDEX idx_{self._table_name}_expires_at
115
+ ON {self._table_name}(expires_at)';
116
+ EXCEPTION
117
+ WHEN OTHERS THEN
118
+ IF SQLCODE != -955 THEN
119
+ RAISE;
120
+ END IF;
121
+ END;
122
+ """
123
+
124
+ def _get_drop_table_sql(self) -> "list[str]":
125
+ """Get Oracle DROP TABLE SQL with PL/SQL error handling.
126
+
127
+ Returns:
128
+ List of SQL statements with exception handling for non-existent objects.
129
+ """
130
+ return [
131
+ f"""
132
+ BEGIN
133
+ EXECUTE IMMEDIATE 'DROP INDEX idx_{self._table_name}_expires_at';
134
+ EXCEPTION
135
+ WHEN OTHERS THEN
136
+ IF SQLCODE != -1418 THEN
137
+ RAISE;
138
+ END IF;
139
+ END;
140
+ """,
141
+ f"""
142
+ BEGIN
143
+ EXECUTE IMMEDIATE 'DROP TABLE {self._table_name}';
144
+ EXCEPTION
145
+ WHEN OTHERS THEN
146
+ IF SQLCODE != -942 THEN
147
+ RAISE;
148
+ END IF;
149
+ END;
150
+ """,
151
+ ]
152
+
153
+ async def create_table(self) -> None:
154
+ """Create the session table if it doesn't exist."""
155
+ sql = self._get_create_table_sql()
156
+ async with self._config.provide_session() as driver:
157
+ await driver.execute_script(sql)
158
+
159
+ logger.debug("Created session table: %s", self._table_name)
160
+
161
+ async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None":
162
+ """Get a session value by key.
163
+
164
+ Args:
165
+ key: Session ID to retrieve.
166
+ renew_for: If given, renew the expiry time for this duration.
167
+
168
+ Returns:
169
+ Session data as bytes if found and not expired, None otherwise.
170
+
171
+ Notes:
172
+ Uses SYSTIMESTAMP for Oracle current timestamp.
173
+ The query uses the index for expires_at > SYSTIMESTAMP.
174
+ """
175
+ sql = f"""
176
+ SELECT data, expires_at FROM {self._table_name}
177
+ WHERE session_id = :session_id
178
+ AND (expires_at IS NULL OR expires_at > SYSTIMESTAMP)
179
+ """
180
+
181
+ conn_context = self._config.provide_connection()
182
+ async with conn_context as conn:
183
+ cursor = conn.cursor()
184
+ await cursor.execute(sql, {"session_id": key})
185
+ row = await cursor.fetchone()
186
+
187
+ if row is None:
188
+ return None
189
+
190
+ data_blob, expires_at = row
191
+
192
+ if renew_for is not None and expires_at is not None:
193
+ new_expires_at = self._calculate_expires_at(renew_for)
194
+ if new_expires_at is not None:
195
+ update_sql = f"""
196
+ UPDATE {self._table_name}
197
+ SET expires_at = :expires_at, updated_at = SYSTIMESTAMP
198
+ WHERE session_id = :session_id
199
+ """
200
+ await cursor.execute(update_sql, {"expires_at": new_expires_at, "session_id": key})
201
+ await conn.commit()
202
+
203
+ try:
204
+ blob_data = await data_blob.read()
205
+ return bytes(blob_data) if blob_data is not None else bytes(data_blob)
206
+ except AttributeError:
207
+ return bytes(data_blob)
208
+
209
+ async def set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | None" = None) -> None:
210
+ """Store a session value.
211
+
212
+ Args:
213
+ key: Session ID.
214
+ value: Session data.
215
+ expires_in: Time until expiration.
216
+
217
+ Notes:
218
+ Uses MERGE for atomic UPSERT operation in Oracle.
219
+ Updates updated_at timestamp on every write for audit trail.
220
+ For large BLOBs, uses empty_blob() and then writes data separately.
221
+ """
222
+ data = self._value_to_bytes(value)
223
+ expires_at = self._calculate_expires_at(expires_in)
224
+
225
+ conn_context = self._config.provide_connection()
226
+ async with conn_context as conn:
227
+ cursor = conn.cursor()
228
+
229
+ if len(data) > ORACLE_SMALL_BLOB_LIMIT:
230
+ merge_sql = f"""
231
+ MERGE INTO {self._table_name} t
232
+ USING (SELECT :session_id AS session_id FROM DUAL) s
233
+ ON (t.session_id = s.session_id)
234
+ WHEN MATCHED THEN
235
+ UPDATE SET
236
+ data = EMPTY_BLOB(),
237
+ expires_at = :expires_at,
238
+ updated_at = SYSTIMESTAMP
239
+ WHEN NOT MATCHED THEN
240
+ INSERT (session_id, data, expires_at, created_at, updated_at)
241
+ VALUES (:session_id, EMPTY_BLOB(), :expires_at, SYSTIMESTAMP, SYSTIMESTAMP)
242
+ """
243
+ await cursor.execute(merge_sql, {"session_id": key, "expires_at": expires_at})
244
+
245
+ select_sql = f"""
246
+ SELECT data FROM {self._table_name}
247
+ WHERE session_id = :session_id FOR UPDATE
248
+ """
249
+ await cursor.execute(select_sql, {"session_id": key})
250
+ row = await cursor.fetchone()
251
+ if row:
252
+ blob = row[0]
253
+ await blob.write(data)
254
+
255
+ await conn.commit()
256
+ else:
257
+ sql = f"""
258
+ MERGE INTO {self._table_name} t
259
+ USING (SELECT :session_id AS session_id FROM DUAL) s
260
+ ON (t.session_id = s.session_id)
261
+ WHEN MATCHED THEN
262
+ UPDATE SET
263
+ data = :data,
264
+ expires_at = :expires_at,
265
+ updated_at = SYSTIMESTAMP
266
+ WHEN NOT MATCHED THEN
267
+ INSERT (session_id, data, expires_at, created_at, updated_at)
268
+ VALUES (:session_id, :data, :expires_at, SYSTIMESTAMP, SYSTIMESTAMP)
269
+ """
270
+ await cursor.execute(sql, {"session_id": key, "data": data, "expires_at": expires_at})
271
+ await conn.commit()
272
+
273
+ async def delete(self, key: str) -> None:
274
+ """Delete a session by key.
275
+
276
+ Args:
277
+ key: Session ID to delete.
278
+ """
279
+ sql = f"DELETE FROM {self._table_name} WHERE session_id = :session_id"
280
+
281
+ conn_context = self._config.provide_connection()
282
+ async with conn_context as conn:
283
+ cursor = conn.cursor()
284
+ await cursor.execute(sql, {"session_id": key})
285
+ await conn.commit()
286
+
287
+ async def delete_all(self) -> None:
288
+ """Delete all sessions from the store."""
289
+ sql = f"DELETE FROM {self._table_name}"
290
+
291
+ conn_context = self._config.provide_connection()
292
+ async with conn_context as conn:
293
+ cursor = conn.cursor()
294
+ await cursor.execute(sql)
295
+ await conn.commit()
296
+ logger.debug("Deleted all sessions from table: %s", self._table_name)
297
+
298
+ async def exists(self, key: str) -> bool:
299
+ """Check if a session key exists and is not expired.
300
+
301
+ Args:
302
+ key: Session ID to check.
303
+
304
+ Returns:
305
+ True if the session exists and is not expired.
306
+
307
+ Notes:
308
+ Uses SYSTIMESTAMP for consistency with get() method.
309
+ """
310
+ sql = f"""
311
+ SELECT 1 FROM {self._table_name}
312
+ WHERE session_id = :session_id
313
+ AND (expires_at IS NULL OR expires_at > SYSTIMESTAMP)
314
+ """
315
+
316
+ conn_context = self._config.provide_connection()
317
+ async with conn_context as conn:
318
+ cursor = conn.cursor()
319
+ await cursor.execute(sql, {"session_id": key})
320
+ result = await cursor.fetchone()
321
+ return result is not None
322
+
323
+ async def expires_in(self, key: str) -> "int | None":
324
+ """Get the time in seconds until the session expires.
325
+
326
+ Args:
327
+ key: Session ID to check.
328
+
329
+ Returns:
330
+ Seconds until expiration, or None if no expiry or key doesn't exist.
331
+ """
332
+ sql = f"""
333
+ SELECT expires_at FROM {self._table_name}
334
+ WHERE session_id = :session_id
335
+ """
336
+
337
+ conn_context = self._config.provide_connection()
338
+ async with conn_context as conn:
339
+ cursor = conn.cursor()
340
+ await cursor.execute(sql, {"session_id": key})
341
+ row = await cursor.fetchone()
342
+
343
+ if row is None or row[0] is None:
344
+ return None
345
+
346
+ expires_at = row[0]
347
+
348
+ if expires_at.tzinfo is None:
349
+ expires_at = expires_at.replace(tzinfo=timezone.utc)
350
+
351
+ now = datetime.now(timezone.utc)
352
+
353
+ if expires_at <= now:
354
+ return 0
355
+
356
+ delta = expires_at - now
357
+ return int(delta.total_seconds())
358
+
359
+ async def delete_expired(self) -> int:
360
+ """Delete all expired sessions.
361
+
362
+ Returns:
363
+ Number of sessions deleted.
364
+
365
+ Notes:
366
+ Uses SYSTIMESTAMP for consistency.
367
+ Oracle automatically commits DDL, so we explicitly commit for DML.
368
+ """
369
+ sql = f"DELETE FROM {self._table_name} WHERE expires_at <= SYSTIMESTAMP"
370
+
371
+ conn_context = self._config.provide_connection()
372
+ async with conn_context as conn:
373
+ cursor = conn.cursor()
374
+ await cursor.execute(sql)
375
+ count = cursor.rowcount if cursor.rowcount is not None else 0
376
+ await conn.commit()
377
+ if count > 0:
378
+ logger.debug("Cleaned up %d expired sessions", count)
379
+ return count
380
+
381
+
382
+ class OracleSyncStore(BaseSQLSpecStore["OracleSyncConfig"]):
383
+ """Oracle session store using sync OracleDB driver.
384
+
385
+ Implements server-side session storage for Litestar using Oracle Database
386
+ via the synchronous python-oracledb driver. Uses async_() wrapper to provide
387
+ an async interface compatible with the Store protocol.
388
+
389
+ Provides efficient session management with:
390
+ - Sync operations wrapped for async compatibility
391
+ - MERGE statement for atomic UPSERT
392
+ - Automatic expiration handling
393
+ - Efficient cleanup of expired sessions
394
+ - Optional In-Memory Column Store support (requires Oracle Database In-Memory license)
395
+
396
+ Note:
397
+ For high-concurrency applications, consider using OracleAsyncStore instead,
398
+ as it provides native async operations without threading overhead.
399
+
400
+ Args:
401
+ config: OracleSyncConfig with extension_config["litestar"] settings.
402
+
403
+ Example:
404
+ from sqlspec.adapters.oracledb import OracleSyncConfig
405
+ from sqlspec.adapters.oracledb.litestar.store import OracleSyncStore
406
+
407
+ config = OracleSyncConfig(
408
+ pool_config={"dsn": "oracle://..."},
409
+ extension_config={
410
+ "litestar": {
411
+ "session_table": "my_sessions",
412
+ "in_memory": True
413
+ }
414
+ }
415
+ )
416
+ store = OracleSyncStore(config)
417
+ await store.create_table()
418
+
419
+ Notes:
420
+ Configuration is read from config.extension_config["litestar"]:
421
+ - session_table: Session table name (default: "litestar_session")
422
+ - in_memory: Enable INMEMORY clause (default: False, Oracle-specific)
423
+
424
+ When in_memory=True, the table is created with INMEMORY clause for
425
+ faster read operations. This requires Oracle Database 12.1.0.2+ with the
426
+ Database In-Memory option licensed. If In-Memory is not available, the
427
+ table creation will fail with ORA-00439 or ORA-62142.
428
+ """
429
+
430
+ __slots__ = ("_in_memory",)
431
+
432
+ def __init__(self, config: "OracleSyncConfig") -> None:
433
+ """Initialize Oracle sync session store.
434
+
435
+ Args:
436
+ config: OracleSyncConfig instance.
437
+
438
+ Notes:
439
+ Configuration is read from config.extension_config["litestar"]:
440
+ - session_table: Session table name (default: "litestar_session")
441
+ - in_memory: Enable INMEMORY clause (default: False)
442
+ """
443
+ super().__init__(config)
444
+
445
+ if hasattr(config, "extension_config") and config.extension_config:
446
+ litestar_config = config.extension_config.get("litestar", {})
447
+ self._in_memory: bool = bool(litestar_config.get("in_memory", False))
448
+ else:
449
+ self._in_memory = False
450
+
451
+ def _get_create_table_sql(self) -> str:
452
+ """Get Oracle CREATE TABLE SQL with optimized schema.
453
+
454
+ Returns:
455
+ SQL statement to create the sessions table with proper indexes.
456
+
457
+ Notes:
458
+ - Uses TIMESTAMP WITH TIME ZONE for timezone-aware expiration timestamps
459
+ - Index on expires_at for efficient cleanup queries
460
+ - BLOB type for data storage (Oracle native binary type)
461
+ - Audit columns (created_at, updated_at) help with debugging
462
+ - Table name is internally controlled, not user input (S608 suppressed)
463
+ - INMEMORY clause added when in_memory=True for faster reads
464
+ """
465
+ inmemory_clause = "INMEMORY" if self._in_memory else ""
466
+ return f"""
467
+ BEGIN
468
+ EXECUTE IMMEDIATE 'CREATE TABLE {self._table_name} (
469
+ session_id VARCHAR2(255) PRIMARY KEY,
470
+ data BLOB NOT NULL,
471
+ expires_at TIMESTAMP WITH TIME ZONE,
472
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
473
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
474
+ ) {inmemory_clause}';
475
+ EXCEPTION
476
+ WHEN OTHERS THEN
477
+ IF SQLCODE != -955 THEN
478
+ RAISE;
479
+ END IF;
480
+ END;
481
+
482
+ BEGIN
483
+ EXECUTE IMMEDIATE 'CREATE INDEX idx_{self._table_name}_expires_at
484
+ ON {self._table_name}(expires_at)';
485
+ EXCEPTION
486
+ WHEN OTHERS THEN
487
+ IF SQLCODE != -955 THEN
488
+ RAISE;
489
+ END IF;
490
+ END;
491
+ """
492
+
493
+ def _get_drop_table_sql(self) -> "list[str]":
494
+ """Get Oracle DROP TABLE SQL with PL/SQL error handling.
495
+
496
+ Returns:
497
+ List of SQL statements with exception handling for non-existent objects.
498
+ """
499
+ return [
500
+ f"""
501
+ BEGIN
502
+ EXECUTE IMMEDIATE 'DROP INDEX idx_{self._table_name}_expires_at';
503
+ EXCEPTION
504
+ WHEN OTHERS THEN
505
+ IF SQLCODE != -1418 THEN
506
+ RAISE;
507
+ END IF;
508
+ END;
509
+ """,
510
+ f"""
511
+ BEGIN
512
+ EXECUTE IMMEDIATE 'DROP TABLE {self._table_name}';
513
+ EXCEPTION
514
+ WHEN OTHERS THEN
515
+ IF SQLCODE != -942 THEN
516
+ RAISE;
517
+ END IF;
518
+ END;
519
+ """,
520
+ ]
521
+
522
+ def _create_table(self) -> None:
523
+ """Synchronous implementation of create_table."""
524
+ sql = self._get_create_table_sql()
525
+ with self._config.provide_session() as driver:
526
+ driver.execute_script(sql)
527
+
528
+ logger.debug("Created session table: %s", self._table_name)
529
+
530
+ async def create_table(self) -> None:
531
+ """Create the session table if it doesn't exist."""
532
+ await async_(self._create_table)()
533
+
534
+ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None":
535
+ """Synchronous implementation of get.
536
+
537
+ Notes:
538
+ Uses SYSTIMESTAMP for Oracle current timestamp.
539
+ """
540
+ sql = f"""
541
+ SELECT data, expires_at FROM {self._table_name}
542
+ WHERE session_id = :session_id
543
+ AND (expires_at IS NULL OR expires_at > SYSTIMESTAMP)
544
+ """
545
+
546
+ with self._config.provide_connection() as conn:
547
+ cursor = conn.cursor()
548
+ cursor.execute(sql, {"session_id": key})
549
+ row = cursor.fetchone()
550
+
551
+ if row is None:
552
+ return None
553
+
554
+ data_blob, expires_at = row
555
+
556
+ if renew_for is not None and expires_at is not None:
557
+ new_expires_at = self._calculate_expires_at(renew_for)
558
+ if new_expires_at is not None:
559
+ update_sql = f"""
560
+ UPDATE {self._table_name}
561
+ SET expires_at = :expires_at, updated_at = SYSTIMESTAMP
562
+ WHERE session_id = :session_id
563
+ """
564
+ cursor.execute(update_sql, {"expires_at": new_expires_at, "session_id": key})
565
+ conn.commit()
566
+
567
+ try:
568
+ if hasattr(data_blob, "read"):
569
+ blob_data = data_blob.read()
570
+ return bytes(blob_data) if blob_data is not None else bytes(data_blob)
571
+ return bytes(data_blob)
572
+ except AttributeError:
573
+ return bytes(data_blob)
574
+
575
+ async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None":
576
+ """Get a session value by key.
577
+
578
+ Args:
579
+ key: Session ID to retrieve.
580
+ renew_for: If given, renew the expiry time for this duration.
581
+
582
+ Returns:
583
+ Session data as bytes if found and not expired, None otherwise.
584
+ """
585
+ return await async_(self._get)(key, renew_for)
586
+
587
+ def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | None" = None) -> None:
588
+ """Synchronous implementation of set.
589
+
590
+ Notes:
591
+ Uses MERGE for atomic UPSERT operation in Oracle.
592
+ """
593
+ data = self._value_to_bytes(value)
594
+ expires_at = self._calculate_expires_at(expires_in)
595
+
596
+ with self._config.provide_connection() as conn:
597
+ cursor = conn.cursor()
598
+
599
+ if len(data) > ORACLE_SMALL_BLOB_LIMIT:
600
+ merge_sql = f"""
601
+ MERGE INTO {self._table_name} t
602
+ USING (SELECT :session_id AS session_id FROM DUAL) s
603
+ ON (t.session_id = s.session_id)
604
+ WHEN MATCHED THEN
605
+ UPDATE SET
606
+ data = EMPTY_BLOB(),
607
+ expires_at = :expires_at,
608
+ updated_at = SYSTIMESTAMP
609
+ WHEN NOT MATCHED THEN
610
+ INSERT (session_id, data, expires_at, created_at, updated_at)
611
+ VALUES (:session_id, EMPTY_BLOB(), :expires_at, SYSTIMESTAMP, SYSTIMESTAMP)
612
+ """
613
+ cursor.execute(merge_sql, {"session_id": key, "expires_at": expires_at})
614
+
615
+ select_sql = f"""
616
+ SELECT data FROM {self._table_name}
617
+ WHERE session_id = :session_id FOR UPDATE
618
+ """
619
+ cursor.execute(select_sql, {"session_id": key})
620
+ row = cursor.fetchone()
621
+ if row:
622
+ blob = row[0]
623
+ blob.write(data)
624
+
625
+ conn.commit()
626
+ else:
627
+ sql = f"""
628
+ MERGE INTO {self._table_name} t
629
+ USING (SELECT :session_id AS session_id FROM DUAL) s
630
+ ON (t.session_id = s.session_id)
631
+ WHEN MATCHED THEN
632
+ UPDATE SET
633
+ data = :data,
634
+ expires_at = :expires_at,
635
+ updated_at = SYSTIMESTAMP
636
+ WHEN NOT MATCHED THEN
637
+ INSERT (session_id, data, expires_at, created_at, updated_at)
638
+ VALUES (:session_id, :data, :expires_at, SYSTIMESTAMP, SYSTIMESTAMP)
639
+ """
640
+ cursor.execute(sql, {"session_id": key, "data": data, "expires_at": expires_at})
641
+ conn.commit()
642
+
643
+ async def set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | None" = None) -> None:
644
+ """Store a session value.
645
+
646
+ Args:
647
+ key: Session ID.
648
+ value: Session data.
649
+ expires_in: Time until expiration.
650
+ """
651
+ await async_(self._set)(key, value, expires_in)
652
+
653
+ def _delete(self, key: str) -> None:
654
+ """Synchronous implementation of delete."""
655
+ sql = f"DELETE FROM {self._table_name} WHERE session_id = :session_id"
656
+
657
+ with self._config.provide_connection() as conn:
658
+ cursor = conn.cursor()
659
+ cursor.execute(sql, {"session_id": key})
660
+ conn.commit()
661
+
662
+ async def delete(self, key: str) -> None:
663
+ """Delete a session by key.
664
+
665
+ Args:
666
+ key: Session ID to delete.
667
+ """
668
+ await async_(self._delete)(key)
669
+
670
+ def _delete_all(self) -> None:
671
+ """Synchronous implementation of delete_all."""
672
+ sql = f"DELETE FROM {self._table_name}"
673
+
674
+ with self._config.provide_connection() as conn:
675
+ cursor = conn.cursor()
676
+ cursor.execute(sql)
677
+ conn.commit()
678
+ logger.debug("Deleted all sessions from table: %s", self._table_name)
679
+
680
+ async def delete_all(self) -> None:
681
+ """Delete all sessions from the store."""
682
+ await async_(self._delete_all)()
683
+
684
+ def _exists(self, key: str) -> bool:
685
+ """Synchronous implementation of exists."""
686
+ sql = f"""
687
+ SELECT 1 FROM {self._table_name}
688
+ WHERE session_id = :session_id
689
+ AND (expires_at IS NULL OR expires_at > SYSTIMESTAMP)
690
+ """
691
+
692
+ with self._config.provide_connection() as conn:
693
+ cursor = conn.cursor()
694
+ cursor.execute(sql, {"session_id": key})
695
+ result = cursor.fetchone()
696
+ return result is not None
697
+
698
+ async def exists(self, key: str) -> bool:
699
+ """Check if a session key exists and is not expired.
700
+
701
+ Args:
702
+ key: Session ID to check.
703
+
704
+ Returns:
705
+ True if the session exists and is not expired.
706
+ """
707
+ return await async_(self._exists)(key)
708
+
709
+ def _expires_in(self, key: str) -> "int | None":
710
+ """Synchronous implementation of expires_in."""
711
+ sql = f"""
712
+ SELECT expires_at FROM {self._table_name}
713
+ WHERE session_id = :session_id
714
+ """
715
+
716
+ with self._config.provide_connection() as conn:
717
+ cursor = conn.cursor()
718
+ cursor.execute(sql, {"session_id": key})
719
+ row = cursor.fetchone()
720
+
721
+ if row is None or row[0] is None:
722
+ return None
723
+
724
+ expires_at = row[0]
725
+
726
+ if expires_at.tzinfo is None:
727
+ expires_at = expires_at.replace(tzinfo=timezone.utc)
728
+
729
+ now = datetime.now(timezone.utc)
730
+
731
+ if expires_at <= now:
732
+ return 0
733
+
734
+ delta = expires_at - now
735
+ return int(delta.total_seconds())
736
+
737
+ async def expires_in(self, key: str) -> "int | None":
738
+ """Get the time in seconds until the session expires.
739
+
740
+ Args:
741
+ key: Session ID to check.
742
+
743
+ Returns:
744
+ Seconds until expiration, or None if no expiry or key doesn't exist.
745
+ """
746
+ return await async_(self._expires_in)(key)
747
+
748
+ def _delete_expired(self) -> int:
749
+ """Synchronous implementation of delete_expired."""
750
+ sql = f"DELETE FROM {self._table_name} WHERE expires_at <= SYSTIMESTAMP"
751
+
752
+ with self._config.provide_connection() as conn:
753
+ cursor = conn.cursor()
754
+ cursor.execute(sql)
755
+ count = cursor.rowcount if cursor.rowcount is not None else 0
756
+ conn.commit()
757
+ if count > 0:
758
+ logger.debug("Cleaned up %d expired sessions", count)
759
+ return count
760
+
761
+ async def delete_expired(self) -> int:
762
+ """Delete all expired sessions.
763
+
764
+ Returns:
765
+ Number of sessions deleted.
766
+ """
767
+ return await async_(self._delete_expired)()