audex 1.0.7a3__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.
Files changed (192) hide show
  1. audex/__init__.py +9 -0
  2. audex/__main__.py +7 -0
  3. audex/cli/__init__.py +189 -0
  4. audex/cli/apis/__init__.py +12 -0
  5. audex/cli/apis/init/__init__.py +34 -0
  6. audex/cli/apis/init/gencfg.py +130 -0
  7. audex/cli/apis/init/setup.py +330 -0
  8. audex/cli/apis/init/vprgroup.py +125 -0
  9. audex/cli/apis/serve.py +141 -0
  10. audex/cli/args.py +356 -0
  11. audex/cli/exceptions.py +44 -0
  12. audex/cli/helper/__init__.py +0 -0
  13. audex/cli/helper/ansi.py +193 -0
  14. audex/cli/helper/display.py +288 -0
  15. audex/config/__init__.py +64 -0
  16. audex/config/core/__init__.py +30 -0
  17. audex/config/core/app.py +29 -0
  18. audex/config/core/audio.py +45 -0
  19. audex/config/core/logging.py +163 -0
  20. audex/config/core/session.py +11 -0
  21. audex/config/helper/__init__.py +1 -0
  22. audex/config/helper/client/__init__.py +1 -0
  23. audex/config/helper/client/http.py +28 -0
  24. audex/config/helper/client/websocket.py +21 -0
  25. audex/config/helper/provider/__init__.py +1 -0
  26. audex/config/helper/provider/dashscope.py +13 -0
  27. audex/config/helper/provider/unisound.py +18 -0
  28. audex/config/helper/provider/xfyun.py +23 -0
  29. audex/config/infrastructure/__init__.py +31 -0
  30. audex/config/infrastructure/cache.py +51 -0
  31. audex/config/infrastructure/database.py +48 -0
  32. audex/config/infrastructure/recorder.py +32 -0
  33. audex/config/infrastructure/store.py +19 -0
  34. audex/config/provider/__init__.py +18 -0
  35. audex/config/provider/transcription.py +109 -0
  36. audex/config/provider/vpr.py +99 -0
  37. audex/container.py +40 -0
  38. audex/entity/__init__.py +468 -0
  39. audex/entity/doctor.py +109 -0
  40. audex/entity/doctor.pyi +51 -0
  41. audex/entity/fields.py +401 -0
  42. audex/entity/segment.py +115 -0
  43. audex/entity/segment.pyi +38 -0
  44. audex/entity/session.py +133 -0
  45. audex/entity/session.pyi +47 -0
  46. audex/entity/utterance.py +142 -0
  47. audex/entity/utterance.pyi +48 -0
  48. audex/entity/vp.py +68 -0
  49. audex/entity/vp.pyi +35 -0
  50. audex/exceptions.py +157 -0
  51. audex/filters/__init__.py +692 -0
  52. audex/filters/generated/__init__.py +21 -0
  53. audex/filters/generated/doctor.py +987 -0
  54. audex/filters/generated/segment.py +723 -0
  55. audex/filters/generated/session.py +978 -0
  56. audex/filters/generated/utterance.py +939 -0
  57. audex/filters/generated/vp.py +815 -0
  58. audex/helper/__init__.py +1 -0
  59. audex/helper/hash.py +33 -0
  60. audex/helper/mixin.py +65 -0
  61. audex/helper/net.py +19 -0
  62. audex/helper/settings/__init__.py +830 -0
  63. audex/helper/settings/fields.py +317 -0
  64. audex/helper/stream.py +153 -0
  65. audex/injectors/__init__.py +1 -0
  66. audex/injectors/config.py +12 -0
  67. audex/injectors/lifespan.py +7 -0
  68. audex/lib/__init__.py +1 -0
  69. audex/lib/cache/__init__.py +383 -0
  70. audex/lib/cache/inmemory.py +513 -0
  71. audex/lib/database/__init__.py +83 -0
  72. audex/lib/database/sqlite.py +406 -0
  73. audex/lib/exporter.py +189 -0
  74. audex/lib/injectors/__init__.py +1 -0
  75. audex/lib/injectors/cache.py +25 -0
  76. audex/lib/injectors/container.py +47 -0
  77. audex/lib/injectors/exporter.py +26 -0
  78. audex/lib/injectors/recorder.py +33 -0
  79. audex/lib/injectors/server.py +17 -0
  80. audex/lib/injectors/session.py +18 -0
  81. audex/lib/injectors/sqlite.py +24 -0
  82. audex/lib/injectors/store.py +13 -0
  83. audex/lib/injectors/transcription.py +42 -0
  84. audex/lib/injectors/usb.py +12 -0
  85. audex/lib/injectors/vpr.py +65 -0
  86. audex/lib/injectors/wifi.py +7 -0
  87. audex/lib/recorder.py +844 -0
  88. audex/lib/repos/__init__.py +149 -0
  89. audex/lib/repos/container.py +23 -0
  90. audex/lib/repos/database/__init__.py +1 -0
  91. audex/lib/repos/database/sqlite.py +672 -0
  92. audex/lib/repos/decorators.py +74 -0
  93. audex/lib/repos/doctor.py +286 -0
  94. audex/lib/repos/segment.py +302 -0
  95. audex/lib/repos/session.py +285 -0
  96. audex/lib/repos/tables/__init__.py +70 -0
  97. audex/lib/repos/tables/doctor.py +137 -0
  98. audex/lib/repos/tables/segment.py +113 -0
  99. audex/lib/repos/tables/session.py +140 -0
  100. audex/lib/repos/tables/utterance.py +131 -0
  101. audex/lib/repos/tables/vp.py +102 -0
  102. audex/lib/repos/utterance.py +288 -0
  103. audex/lib/repos/vp.py +286 -0
  104. audex/lib/restful.py +251 -0
  105. audex/lib/server/__init__.py +97 -0
  106. audex/lib/server/auth.py +98 -0
  107. audex/lib/server/handlers.py +248 -0
  108. audex/lib/server/templates/index.html.j2 +226 -0
  109. audex/lib/server/templates/login.html.j2 +111 -0
  110. audex/lib/server/templates/static/script.js +68 -0
  111. audex/lib/server/templates/static/style.css +579 -0
  112. audex/lib/server/types.py +123 -0
  113. audex/lib/session.py +503 -0
  114. audex/lib/store/__init__.py +238 -0
  115. audex/lib/store/localfile.py +411 -0
  116. audex/lib/transcription/__init__.py +33 -0
  117. audex/lib/transcription/dashscope.py +525 -0
  118. audex/lib/transcription/events.py +62 -0
  119. audex/lib/usb.py +554 -0
  120. audex/lib/vpr/__init__.py +38 -0
  121. audex/lib/vpr/unisound/__init__.py +185 -0
  122. audex/lib/vpr/unisound/types.py +469 -0
  123. audex/lib/vpr/xfyun/__init__.py +483 -0
  124. audex/lib/vpr/xfyun/types.py +679 -0
  125. audex/lib/websocket/__init__.py +8 -0
  126. audex/lib/websocket/connection.py +485 -0
  127. audex/lib/websocket/pool.py +991 -0
  128. audex/lib/wifi.py +1146 -0
  129. audex/lifespan.py +75 -0
  130. audex/service/__init__.py +27 -0
  131. audex/service/decorators.py +73 -0
  132. audex/service/doctor/__init__.py +652 -0
  133. audex/service/doctor/const.py +36 -0
  134. audex/service/doctor/exceptions.py +96 -0
  135. audex/service/doctor/types.py +54 -0
  136. audex/service/export/__init__.py +236 -0
  137. audex/service/export/const.py +17 -0
  138. audex/service/export/exceptions.py +34 -0
  139. audex/service/export/types.py +21 -0
  140. audex/service/injectors/__init__.py +1 -0
  141. audex/service/injectors/container.py +53 -0
  142. audex/service/injectors/doctor.py +34 -0
  143. audex/service/injectors/export.py +27 -0
  144. audex/service/injectors/session.py +49 -0
  145. audex/service/session/__init__.py +754 -0
  146. audex/service/session/const.py +34 -0
  147. audex/service/session/exceptions.py +67 -0
  148. audex/service/session/types.py +91 -0
  149. audex/types.py +39 -0
  150. audex/utils.py +287 -0
  151. audex/valueobj/__init__.py +81 -0
  152. audex/valueobj/common/__init__.py +1 -0
  153. audex/valueobj/common/auth.py +84 -0
  154. audex/valueobj/common/email.py +16 -0
  155. audex/valueobj/common/ops.py +22 -0
  156. audex/valueobj/common/phone.py +84 -0
  157. audex/valueobj/common/version.py +72 -0
  158. audex/valueobj/session.py +19 -0
  159. audex/valueobj/utterance.py +15 -0
  160. audex/view/__init__.py +51 -0
  161. audex/view/container.py +17 -0
  162. audex/view/decorators.py +303 -0
  163. audex/view/pages/__init__.py +1 -0
  164. audex/view/pages/dashboard/__init__.py +286 -0
  165. audex/view/pages/dashboard/wifi.py +407 -0
  166. audex/view/pages/login.py +110 -0
  167. audex/view/pages/recording.py +348 -0
  168. audex/view/pages/register.py +202 -0
  169. audex/view/pages/sessions/__init__.py +196 -0
  170. audex/view/pages/sessions/details.py +224 -0
  171. audex/view/pages/sessions/export.py +443 -0
  172. audex/view/pages/settings.py +374 -0
  173. audex/view/pages/voiceprint/__init__.py +1 -0
  174. audex/view/pages/voiceprint/enroll.py +195 -0
  175. audex/view/pages/voiceprint/update.py +195 -0
  176. audex/view/static/css/dashboard.css +452 -0
  177. audex/view/static/css/glass.css +22 -0
  178. audex/view/static/css/global.css +541 -0
  179. audex/view/static/css/login.css +386 -0
  180. audex/view/static/css/recording.css +439 -0
  181. audex/view/static/css/register.css +293 -0
  182. audex/view/static/css/sessions/styles.css +501 -0
  183. audex/view/static/css/settings.css +186 -0
  184. audex/view/static/css/voiceprint/enroll.css +43 -0
  185. audex/view/static/css/voiceprint/styles.css +209 -0
  186. audex/view/static/css/voiceprint/update.css +44 -0
  187. audex/view/static/images/logo.svg +95 -0
  188. audex/view/static/js/recording.js +42 -0
  189. audex-1.0.7a3.dist-info/METADATA +361 -0
  190. audex-1.0.7a3.dist-info/RECORD +192 -0
  191. audex-1.0.7a3.dist-info/WHEEL +4 -0
  192. audex-1.0.7a3.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,672 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import typing as t
5
+ import warnings
6
+
7
+ import sqlalchemy as sa
8
+
9
+ from audex.filters import ConditionGroup
10
+ from audex.filters import ConditionSpec
11
+ from audex.filters import Filter
12
+ from audex.filters import SortSpec
13
+ from audex.helper.mixin import LoggingMixin
14
+ from audex.lib.database.sqlite import SQLite
15
+ from audex.lib.repos import BaseRepository
16
+ from audex.lib.repos import E
17
+ from audex.lib.repos.tables import BaseTable
18
+ from audex.valueobj.common.ops import Op
19
+ from audex.valueobj.common.ops import Order
20
+
21
+
22
+ class SQLiteQuerySpec(t.NamedTuple):
23
+ """Container for SQLite query specifications.
24
+
25
+ Attributes:
26
+ where: List of SQLAlchemy where clause expressions.
27
+ order_by: List of SQLAlchemy order by clause expressions.
28
+ """
29
+
30
+ where: list[sa.ColumnElement[bool]]
31
+ order_by: list[sa.UnaryExpression[t.Any]]
32
+
33
+
34
+ class SQLiteRepository(LoggingMixin, BaseRepository[E], abc.ABC):
35
+ """Abstract base repository for SQLite operations with filter
36
+ support.
37
+
38
+ This class provides common functionality for converting type-safe filters
39
+ to SQLAlchemy queries and defines the standard CRUD interface that all
40
+ SQLite repositories must implement.
41
+
42
+ Attributes:
43
+ sqlite: SQLite connection instance.
44
+ logger: Logger instance for this repository.
45
+ __table__: The SQLAlchemy Table/Model class associated with this repository.
46
+ __tablename__: The name of the SQLite table used by this repository.
47
+
48
+ Args:
49
+ sqlite: SQLite connection instance.
50
+
51
+ Example:
52
+ ```python
53
+ class UserRepository(SQLiteRepository[User]):
54
+ __table__ = UserTable
55
+ __tablename__ = "users"
56
+
57
+ async def create(self, data: User) -> str:
58
+ async with self.sqlite.session() as session:
59
+ db_obj = self.entity_to_table(data)
60
+ session.add(db_obj)
61
+ await session.commit()
62
+ await session.refresh(db_obj)
63
+ return str(db_obj.id)
64
+
65
+ async def list(
66
+ self,
67
+ filter: Filter | None = None,
68
+ ) -> list[User]:
69
+ spec = self.build_query_spec(filter)
70
+ async with self.sqlite.session() as session:
71
+ stmt = sa.select(self.__table__)
72
+ for clause in spec.where:
73
+ stmt = stmt.where(clause)
74
+ for order in spec.order_by:
75
+ stmt = stmt.order_by(order)
76
+ result = await session.execute(stmt)
77
+ db_objs = result.scalars().all()
78
+ return [
79
+ self.table_to_entity(obj) for obj in db_objs
80
+ ]
81
+ ```
82
+ """
83
+
84
+ __logtag__ = "audex.lib.repos.sqlite"
85
+ __repotype__ = "sqlite"
86
+ __table__: t.ClassVar[type[BaseTable[t.Any]]]
87
+ __tablename__: t.ClassVar[str]
88
+
89
+ def __init_subclass__(cls, **kwargs: t.Any) -> None:
90
+ super().__init_subclass__(**kwargs)
91
+ if not hasattr(cls, "__table__") or not issubclass(cls.__table__, BaseTable):
92
+ raise NotImplementedError(
93
+ "__table__ must be defined and be a subclass of BaseTable in SQLiteRepository subclasses."
94
+ )
95
+ if not hasattr(cls, "__tablename__") or not isinstance(cls.__tablename__, str):
96
+ cls.__tablename__ = cls.__table__.__tablename__
97
+ warnings.warn(
98
+ f"__tablename__ not defined in {cls.__name__}, defaulting to {cls.__tablename__}",
99
+ UserWarning,
100
+ stacklevel=2,
101
+ )
102
+
103
+ def __init__(self, sqlite: SQLite) -> None:
104
+ super().__init__()
105
+ self.sqlite = sqlite
106
+
107
+ def build_query_spec(self, filter: t.Optional[Filter]) -> SQLiteQuerySpec: # noqa
108
+ """Convert Filter to SQLAlchemy query specifications for SQLite.
109
+
110
+ This method translates the type-safe Filter object into SQLAlchemy
111
+ where clauses and order by clauses that can be used with SQLite
112
+ queries.
113
+
114
+ Args:
115
+ filter: The filter to convert, or None for no filtering/sorting.
116
+
117
+ Returns:
118
+ A SQLiteQuerySpec containing both where clauses and order by
119
+ clauses. Returns empty lists if filter is None.
120
+
121
+ Examples:
122
+ Simple query with sort:
123
+ ```python
124
+ filter = user_filter().username.eq("john").created_at.desc()
125
+ spec = repo.build_query_spec(filter)
126
+ stmt = sa.select(UserTable)
127
+ for clause in spec.where:
128
+ stmt = stmt.where(clause)
129
+ for order in spec.order_by:
130
+ stmt = stmt.order_by(order)
131
+ ```
132
+
133
+ Multiple conditions with multiple sorts:
134
+ ```python
135
+ filter = (
136
+ user_filter()
137
+ .is_active.eq(True)
138
+ .tier.eq(UserTier.PREMIUM)
139
+ .created_at.desc()
140
+ .username.asc()
141
+ )
142
+ spec = repo.build_query_spec(filter)
143
+ # Will generate appropriate WHERE and ORDER BY clauses
144
+ ```
145
+
146
+ OR conditions:
147
+ ```python
148
+ filter = user_filter().username.eq(
149
+ "john"
150
+ ) | user_filter().email.eq("john@example.com")
151
+ spec = repo.build_query_spec(filter)
152
+ # Generates: WHERE username = 'john' OR email = 'john@example.com'
153
+ ```
154
+
155
+ NOT conditions:
156
+ ```python
157
+ # Single field negation
158
+ filter = ~user_filter().username.eq("john")
159
+ spec = repo.build_query_spec(filter)
160
+ # Generates: WHERE NOT (username = 'john')
161
+
162
+ # Multiple fields negation
163
+ filter = ~(
164
+ user_filter().username.eq("john").is_active.eq(True)
165
+ )
166
+ spec = repo.build_query_spec(filter)
167
+ # Generates: WHERE NOT (username = 'john' AND is_active = 1)
168
+
169
+ # NOT with OR
170
+ filter = ~(
171
+ user_filter().username.eq("john")
172
+ | user_filter().email.eq("john@ex.com")
173
+ )
174
+ spec = repo.build_query_spec(filter)
175
+ # Generates: WHERE NOT (username = 'john' OR email = 'john@ex.com')
176
+ ```
177
+
178
+ Complex nested conditions:
179
+ ```python
180
+ filter = (
181
+ user_filter().tier.eq(UserTier.PREMIUM)
182
+ | user_filter().tier.eq(UserTier.VIP)
183
+ ) & user_filter().is_active.eq(True)
184
+ spec = repo.build_query_spec(filter)
185
+ # Generates: WHERE (tier = 'premium' OR tier = 'vip') AND is_active = 1
186
+ ```
187
+
188
+ List has (single element in JSON array):
189
+ ```python
190
+ filter = user_filter().tags.has("premium")
191
+ spec = repo.build_query_spec(filter)
192
+ # Generates: WHERE json_extract(tags, '$') LIKE '%"premium"%'
193
+ ```
194
+
195
+ List contains (subset check - database array contains all specified elements):
196
+ ```python
197
+ filter = user_filter().tags.contains([
198
+ "premium",
199
+ "verified",
200
+ ])
201
+ spec = repo.build_query_spec(filter)
202
+ # Generates multiple json_extract checks for each element
203
+ ```
204
+
205
+ Notes:
206
+ - Where: Supports nested AND/OR/NOT logic
207
+ - Order: Order is preserved as specified in the filter chain
208
+ - Uses SQLAlchemy's expression language for type safety
209
+ - List fields stored as JSON use SQLite JSON1 extension functions
210
+ - List HAS: Uses json_extract with LIKE for single element existence
211
+ - List CONTAINS: Uses multiple json_extract checks for subset verification
212
+ - NOT operations use SQLAlchemy's not_() function
213
+ - SQLite doesn't have native JSON operators like PostgreSQL
214
+ """
215
+ where_clauses = self.build_where(filter)
216
+ order_by_clauses = self.build_order_by(filter)
217
+ return SQLiteQuerySpec(
218
+ where=where_clauses,
219
+ order_by=order_by_clauses,
220
+ )
221
+
222
+ def build_where(self, filter: t.Optional[Filter]) -> list[sa.ColumnElement[bool]]: # noqa
223
+ """Convert Filter conditions to SQLAlchemy where clauses for
224
+ SQLite.
225
+
226
+ This method translates the filter conditions into SQLAlchemy
227
+ where clause expressions that can be used with SQLite queries.
228
+ Supports nested AND/OR/NOT logic through ConditionGroup.
229
+
230
+ Args:
231
+ filter: The filter to convert, or None for no filtering.
232
+
233
+ Returns:
234
+ A list of SQLAlchemy ColumnElement expressions for WHERE clauses.
235
+ Returns empty list if filter is None or has no conditions.
236
+
237
+ Examples:
238
+ Simple equality:
239
+ ```python
240
+ filter = user_filter().username.eq("john")
241
+ clauses = repo.build_where(filter)
242
+ # Result: [UserTable.username == "john"]
243
+ ```
244
+
245
+ Multiple AND conditions:
246
+ ```python
247
+ filter = (
248
+ user_filter()
249
+ .is_active.eq(True)
250
+ .tier.eq(UserTier.PREMIUM)
251
+ )
252
+ clauses = repo.build_where(filter)
253
+ # Result: [UserTable.is_active == True, UserTable.tier == "premium"]
254
+ ```
255
+
256
+ OR conditions:
257
+ ```python
258
+ filter = user_filter().username.eq(
259
+ "john"
260
+ ) | user_filter().email.eq("john@example.com")
261
+ clauses = repo.build_where(filter)
262
+ # Result: [
263
+ # or_(
264
+ # UserTable.username == "john",
265
+ # UserTable.email == "john@example.com"
266
+ # )
267
+ # ]
268
+ ```
269
+
270
+ NOT conditions:
271
+ ```python
272
+ # Single field negation
273
+ filter = ~user_filter().username.eq("john")
274
+ clauses = repo.build_where(filter)
275
+ # Result: [not_(UserTable.username == "john")]
276
+
277
+ # Multiple AND conditions negation
278
+ filter = ~(
279
+ user_filter().username.eq("john").is_active.eq(True)
280
+ )
281
+ clauses = repo.build_where(filter)
282
+ # Result: [
283
+ # not_(
284
+ # and_(
285
+ # UserTable.username == "john",
286
+ # UserTable.is_active == True
287
+ # )
288
+ # )
289
+ # ]
290
+
291
+ # NOT with OR
292
+ filter = ~(
293
+ user_filter().username.eq("john")
294
+ | user_filter().email.eq("john@ex.com")
295
+ )
296
+ clauses = repo.build_where(filter)
297
+ # Result: [
298
+ # not_(
299
+ # or_(
300
+ # UserTable.username == "john",
301
+ # UserTable.email == "john@ex.com"
302
+ # )
303
+ # )
304
+ # ]
305
+ ```
306
+
307
+ Complex nested conditions:
308
+ ```python
309
+ filter = (
310
+ user_filter().tier.eq(UserTier.PREMIUM)
311
+ | user_filter().tier.eq(UserTier.VIP)
312
+ ) & user_filter().is_active.eq(True)
313
+ clauses = repo.build_where(filter)
314
+ # Result: [
315
+ # and_(
316
+ # or_(
317
+ # UserTable.tier == "premium",
318
+ # UserTable.tier == "vip"
319
+ # ),
320
+ # UserTable.is_active == True
321
+ # )
322
+ # ]
323
+ ```
324
+
325
+ List has (single element in JSON array):
326
+ ```python
327
+ filter = user_filter().tags.has("premium")
328
+ clauses = repo.build_where(filter)
329
+ # Result: Uses SQLite JSON1 extension
330
+ # SQL: WHERE json_extract(tags, '$') LIKE '%"premium"%'
331
+ ```
332
+
333
+ List contains (subset check - database array contains all specified elements):
334
+ ```python
335
+ filter = user_filter().tags.contains([
336
+ "premium",
337
+ "verified",
338
+ ])
339
+ clauses = repo.build_where(filter)
340
+ # Result: Multiple JSON checks for each element
341
+ # SQL: WHERE json_extract(tags, '$') LIKE '%"premium"%'
342
+ # AND json_extract(tags, '$') LIKE '%"verified"%'
343
+ ```
344
+
345
+ Notes:
346
+ - Supports recursive AND/OR/NOT nesting
347
+ - All conditions are properly parenthesized
348
+ - String CONTAINS operations use case-insensitive LIKE
349
+ - List HAS operations use SQLite JSON1 extension with LIKE
350
+ - List CONTAINS operations check each element individually
351
+ - NOT operations use SQLAlchemy's not_() function
352
+ - SQLite boolean values are stored as integers (0/1)
353
+ """
354
+ if filter is None or not object.__getattribute__(filter, "condition_group").conditions:
355
+ return []
356
+
357
+ clause = self._build_group_clause(object.__getattribute__(filter, "condition_group"))
358
+ return [clause] if clause is not None else []
359
+
360
+ def _build_group_clause(self, group: ConditionGroup) -> t.Optional[sa.ColumnElement[bool]]: # noqa
361
+ """Recursively build SQLAlchemy clause from a ConditionGroup.
362
+
363
+ Args:
364
+ group: The condition group to convert.
365
+
366
+ Returns:
367
+ A SQLAlchemy ColumnElement expression, or None if group is empty.
368
+
369
+ Examples:
370
+ AND group:
371
+ ```python
372
+ group = ConditionGroup(
373
+ conditions=[
374
+ ConditionSpec("username", Op.EQ, "john"),
375
+ ConditionSpec("is_active", Op.EQ, True),
376
+ ],
377
+ operator="AND",
378
+ )
379
+ clause = repo._build_group_clause(group)
380
+ # Result: and_(UserTable.username == "john", UserTable.is_active == True)
381
+ ```
382
+
383
+ OR group:
384
+ ```python
385
+ group = ConditionGroup(
386
+ conditions=[
387
+ ConditionSpec("username", Op.EQ, "john"),
388
+ ConditionSpec("email", Op.EQ, "john@ex.com"),
389
+ ],
390
+ operator="OR",
391
+ )
392
+ clause = repo._build_group_clause(group)
393
+ # Result: or_(UserTable.username == "john", UserTable.email == "john@ex.com")
394
+ ```
395
+
396
+ NOT group:
397
+ ```python
398
+ group = ConditionGroup(
399
+ conditions=[
400
+ ConditionSpec("username", Op.EQ, "john"),
401
+ ConditionSpec("is_active", Op.EQ, True),
402
+ ],
403
+ operator="AND",
404
+ negated=True,
405
+ )
406
+ clause = repo._build_group_clause(group)
407
+ # Result: not_(and_(UserTable.username == "john", UserTable.is_active == True))
408
+ ```
409
+ """
410
+ if not group.conditions:
411
+ return None
412
+
413
+ clauses: list[sa.ColumnElement[bool]] = []
414
+
415
+ for condition in group.conditions:
416
+ if isinstance(condition, ConditionGroup):
417
+ # Recursively handle nested group
418
+ nested_clause = self._build_group_clause(condition)
419
+ if nested_clause is not None:
420
+ clauses.append(nested_clause)
421
+ else:
422
+ # Handle single condition
423
+ clause = self._condition_to_sqlalchemy(condition)
424
+ clauses.append(clause)
425
+
426
+ if not clauses:
427
+ return None
428
+
429
+ # Single clause, no need for and_/or_
430
+ if len(clauses) == 1:
431
+ result = clauses[0]
432
+ else:
433
+ # Combine with AND or OR
434
+ result = sa.and_(*clauses) if group.operator == "AND" else sa.or_(*clauses)
435
+
436
+ # Apply negation if needed
437
+ if group.negated:
438
+ return sa.not_(result)
439
+
440
+ return result
441
+
442
+ def build_order_by(self, filter: t.Optional[Filter]) -> list[sa.UnaryExpression[t.Any]]: # noqa
443
+ """Convert Filter sorts to SQLAlchemy order by clauses for
444
+ SQLite.
445
+
446
+ This method translates the filter sort specifications into SQLAlchemy
447
+ order by expressions that can be used with SQLite queries.
448
+
449
+ Args:
450
+ filter: The filter to convert, or None for no sorting.
451
+
452
+ Returns:
453
+ A list of SQLAlchemy UnaryExpression objects for ORDER BY clauses.
454
+ Returns empty list if filter is None or has no sorts.
455
+
456
+ Examples:
457
+ Single sort:
458
+ ```python
459
+ filter = user_filter().created_at.desc()
460
+ order_clauses = repo.build_order_by(filter)
461
+ # Result: [UserTable.created_at.desc()]
462
+ ```
463
+
464
+ Multiple sorts:
465
+ ```python
466
+ filter = (
467
+ user_filter()
468
+ .tier.desc()
469
+ .created_at.asc()
470
+ .username.asc()
471
+ )
472
+ order_clauses = repo.build_order_by(filter)
473
+ # Result: [
474
+ # UserTable.tier.desc(),
475
+ # UserTable.created_at.asc(),
476
+ # UserTable.username.asc()
477
+ # ]
478
+ ```
479
+
480
+ Using with SQLAlchemy:
481
+ ```python
482
+ order_clauses = repo.build_order_by(filter)
483
+ stmt = sa.select(UserTable).order_by(*order_clauses)
484
+ result = await session.execute(stmt)
485
+ ```
486
+
487
+ Notes:
488
+ - Sort order is preserved as specified in the filter chain
489
+ - SQLite applies sorts in the order they appear
490
+ - Can be unpacked with * operator for order_by()
491
+ """
492
+ if filter is None or not filter._sorts:
493
+ return []
494
+
495
+ return [self._sort_to_sqlalchemy(sort) for sort in filter._sorts]
496
+
497
+ def _get_column(self, field_name: str) -> sa.Column[t.Any]:
498
+ """Get the SQLAlchemy column for a field name.
499
+
500
+ Args:
501
+ field_name: The name of the field.
502
+
503
+ Returns:
504
+ The SQLAlchemy Column object.
505
+
506
+ Raises:
507
+ AttributeError: If the field doesn't exist in the table.
508
+ """
509
+ if not hasattr(self.__table__, field_name):
510
+ raise AttributeError(f"Table '{self.__table__.__name__}' has no field '{field_name}'")
511
+ return getattr(self.__table__, field_name) # type: ignore
512
+
513
+ def _condition_to_sqlalchemy(self, condition: ConditionSpec) -> sa.ColumnElement[bool]:
514
+ """Convert a single Condition to SQLAlchemy where clause for
515
+ SQLite.
516
+
517
+ Args:
518
+ condition: The condition to convert.
519
+
520
+ Returns:
521
+ A SQLAlchemy ColumnElement expression for the WHERE clause.
522
+
523
+ Raises:
524
+ ValueError: If the operation is not supported.
525
+
526
+ Examples:
527
+ List operations:
528
+ ```python
529
+ # HAS: Check if JSON array contains a single element
530
+ condition = ConditionSpec("tags", Op.HAS, "premium")
531
+ result = repo._condition_to_sqlalchemy(condition)
532
+ # Result: Uses SQLite JSON1 extension
533
+ # SQL: WHERE json_extract(tags, '$') LIKE '%"premium"%'
534
+
535
+ # CONTAINS: Check if JSON array contains all elements (subset check)
536
+ condition = ConditionSpec(
537
+ "tags", Op.CONTAINS, ["premium", "verified"]
538
+ )
539
+ result = repo._condition_to_sqlalchemy(condition)
540
+ # Result: Multiple JSON checks combined with AND
541
+ # SQL: WHERE json_extract(tags, '$') LIKE '%"premium"%'
542
+ # AND json_extract(tags, '$') LIKE '%"verified"%'
543
+ ```
544
+
545
+ String operations:
546
+ ```python
547
+ # String CONTAINS: Case-insensitive substring match
548
+ condition = ConditionSpec("name", Op.CONTAINS, "john")
549
+ result = repo._condition_to_sqlalchemy(condition)
550
+ # Result: UserTable.name.like('%john%', escape='\\')
551
+ # SQL: WHERE name LIKE '%john%'
552
+ ```
553
+ """
554
+ column = self._get_column(condition.field)
555
+ op = condition.op
556
+ value = condition.value
557
+ value2 = condition.value2
558
+
559
+ match op:
560
+ case Op.EQ:
561
+ # Simple equality
562
+ return column == value
563
+
564
+ case Op.NE:
565
+ # Not equal
566
+ return column != value
567
+
568
+ case Op.GT:
569
+ # Greater than
570
+ return column > value
571
+
572
+ case Op.LT:
573
+ # Less than
574
+ return column < value
575
+
576
+ case Op.GTE:
577
+ # Greater than or equal to
578
+ return column >= value
579
+
580
+ case Op.LTE:
581
+ # Less than or equal to
582
+ return column <= value
583
+
584
+ case Op.IN:
585
+ # Value in list
586
+ return column.in_(value) # type: ignore
587
+
588
+ case Op.NIN:
589
+ # Value not in list
590
+ return column.not_in(value) # type: ignore
591
+
592
+ case Op.BETWEEN:
593
+ # BETWEEN is inclusive: field >= value1 AND field <= value2
594
+ return sa.and_(column >= value, column <= value2)
595
+
596
+ case Op.HAS:
597
+ # For list fields (stored as JSON), check if single value exists in array
598
+ # SQLite uses JSON1 extension for JSON operations
599
+ # Use json_extract to get the full array, then use LIKE to check for the value
600
+ # Format: WHERE json_extract(column, '$') LIKE '%"value"%'
601
+ json_extract = sa.func.json_extract(column, "$")
602
+ search_value = f'%"{value}"%'
603
+ return json_extract.like(search_value) # type: ignore
604
+
605
+ case Op.CONTAINS:
606
+ # Handle different field types for CONTAINS operation
607
+ if isinstance(value, list):
608
+ # For list fields (stored as JSON): Check if database array contains ALL specified elements
609
+ # This is a subset check - database array must be a superset of the provided list
610
+ # SQLite doesn't have a native subset operator like PostgreSQL's @>
611
+ # We need to check each element individually using JSON1 extension
612
+ json_extract = sa.func.json_extract(column, "$")
613
+ clauses = []
614
+ for item in value:
615
+ search_value = f'%"{item}"%'
616
+ clauses.append(json_extract.like(search_value)) # type: ignore
617
+ # Combine all checks with AND
618
+ return sa.and_(*clauses)
619
+ # For string fields, handle pattern matching
620
+ pattern = str(value)
621
+
622
+ # Check if it's a startswith/endswith pattern
623
+ if pattern.startswith("^"):
624
+ # startswith: ^prefix -> prefix%
625
+ prefix = pattern[1:] # Remove ^
626
+ # Escape SQL wildcards
627
+ escaped = prefix.replace("%", "\\%").replace("_", "\\_")
628
+ return column.like(f"{escaped}%", escape="\\") # type: ignore
629
+
630
+ if pattern.endswith("$"):
631
+ # endswith: suffix$ -> %suffix
632
+ suffix = pattern[:-1] # Remove $
633
+ # Escape SQL wildcards
634
+ escaped = suffix.replace("%", "\\%").replace("_", "\\_")
635
+ return column.like(f"%{escaped}", escape="\\") # type: ignore
636
+
637
+ # Plain contains: case-insensitive substring match
638
+ # For SQLite LIKE, we need to escape SQL wildcards (%, _)
639
+ escaped = pattern.replace("%", "\\%").replace("_", "\\_")
640
+ # SQLite LIKE is case-insensitive by default for ASCII characters
641
+ return column.like(f"%{escaped}%", escape="\\") # type: ignore
642
+
643
+ case _:
644
+ raise ValueError(f"Unsupported operation: {op}")
645
+
646
+ def _sort_to_sqlalchemy(self, sort: SortSpec) -> sa.UnaryExpression[t.Any]:
647
+ """Convert a single SortSpec to SQLAlchemy order by expression.
648
+
649
+ Args:
650
+ sort: The sort specification to convert.
651
+
652
+ Returns:
653
+ A SQLAlchemy UnaryExpression for ORDER BY clause.
654
+
655
+ Examples:
656
+ ```python
657
+ # Ascending sort
658
+ sort = SortSpec("username", Order.ASC)
659
+ expr = repo._sort_to_sqlalchemy(sort)
660
+ # Result: UserTable.username.asc()
661
+
662
+ # Descending sort
663
+ sort = SortSpec("created_at", Order.DESC)
664
+ expr = repo._sort_to_sqlalchemy(sort)
665
+ # Result: UserTable.created_at.desc()
666
+ ```
667
+ """
668
+ column = self._get_column(sort.field)
669
+
670
+ if sort.order == Order.ASC:
671
+ return column.asc()
672
+ return column.desc()