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