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.
- audex/__init__.py +9 -0
- audex/__main__.py +7 -0
- audex/cli/__init__.py +189 -0
- audex/cli/apis/__init__.py +12 -0
- audex/cli/apis/init/__init__.py +34 -0
- audex/cli/apis/init/gencfg.py +130 -0
- audex/cli/apis/init/setup.py +330 -0
- audex/cli/apis/init/vprgroup.py +125 -0
- audex/cli/apis/serve.py +141 -0
- audex/cli/args.py +356 -0
- audex/cli/exceptions.py +44 -0
- audex/cli/helper/__init__.py +0 -0
- audex/cli/helper/ansi.py +193 -0
- audex/cli/helper/display.py +288 -0
- audex/config/__init__.py +64 -0
- audex/config/core/__init__.py +30 -0
- audex/config/core/app.py +29 -0
- audex/config/core/audio.py +45 -0
- audex/config/core/logging.py +163 -0
- audex/config/core/session.py +11 -0
- audex/config/helper/__init__.py +1 -0
- audex/config/helper/client/__init__.py +1 -0
- audex/config/helper/client/http.py +28 -0
- audex/config/helper/client/websocket.py +21 -0
- audex/config/helper/provider/__init__.py +1 -0
- audex/config/helper/provider/dashscope.py +13 -0
- audex/config/helper/provider/unisound.py +18 -0
- audex/config/helper/provider/xfyun.py +23 -0
- audex/config/infrastructure/__init__.py +31 -0
- audex/config/infrastructure/cache.py +51 -0
- audex/config/infrastructure/database.py +48 -0
- audex/config/infrastructure/recorder.py +32 -0
- audex/config/infrastructure/store.py +19 -0
- audex/config/provider/__init__.py +18 -0
- audex/config/provider/transcription.py +109 -0
- audex/config/provider/vpr.py +99 -0
- audex/container.py +40 -0
- audex/entity/__init__.py +468 -0
- audex/entity/doctor.py +109 -0
- audex/entity/doctor.pyi +51 -0
- audex/entity/fields.py +401 -0
- audex/entity/segment.py +115 -0
- audex/entity/segment.pyi +38 -0
- audex/entity/session.py +133 -0
- audex/entity/session.pyi +47 -0
- audex/entity/utterance.py +142 -0
- audex/entity/utterance.pyi +48 -0
- audex/entity/vp.py +68 -0
- audex/entity/vp.pyi +35 -0
- audex/exceptions.py +157 -0
- audex/filters/__init__.py +692 -0
- audex/filters/generated/__init__.py +21 -0
- audex/filters/generated/doctor.py +987 -0
- audex/filters/generated/segment.py +723 -0
- audex/filters/generated/session.py +978 -0
- audex/filters/generated/utterance.py +939 -0
- audex/filters/generated/vp.py +815 -0
- audex/helper/__init__.py +1 -0
- audex/helper/hash.py +33 -0
- audex/helper/mixin.py +65 -0
- audex/helper/net.py +19 -0
- audex/helper/settings/__init__.py +830 -0
- audex/helper/settings/fields.py +317 -0
- audex/helper/stream.py +153 -0
- audex/injectors/__init__.py +1 -0
- audex/injectors/config.py +12 -0
- audex/injectors/lifespan.py +7 -0
- audex/lib/__init__.py +1 -0
- audex/lib/cache/__init__.py +383 -0
- audex/lib/cache/inmemory.py +513 -0
- audex/lib/database/__init__.py +83 -0
- audex/lib/database/sqlite.py +406 -0
- audex/lib/exporter.py +189 -0
- audex/lib/injectors/__init__.py +1 -0
- audex/lib/injectors/cache.py +25 -0
- audex/lib/injectors/container.py +47 -0
- audex/lib/injectors/exporter.py +26 -0
- audex/lib/injectors/recorder.py +33 -0
- audex/lib/injectors/server.py +17 -0
- audex/lib/injectors/session.py +18 -0
- audex/lib/injectors/sqlite.py +24 -0
- audex/lib/injectors/store.py +13 -0
- audex/lib/injectors/transcription.py +42 -0
- audex/lib/injectors/usb.py +12 -0
- audex/lib/injectors/vpr.py +65 -0
- audex/lib/injectors/wifi.py +7 -0
- audex/lib/recorder.py +844 -0
- audex/lib/repos/__init__.py +149 -0
- audex/lib/repos/container.py +23 -0
- audex/lib/repos/database/__init__.py +1 -0
- audex/lib/repos/database/sqlite.py +672 -0
- audex/lib/repos/decorators.py +74 -0
- audex/lib/repos/doctor.py +286 -0
- audex/lib/repos/segment.py +302 -0
- audex/lib/repos/session.py +285 -0
- audex/lib/repos/tables/__init__.py +70 -0
- audex/lib/repos/tables/doctor.py +137 -0
- audex/lib/repos/tables/segment.py +113 -0
- audex/lib/repos/tables/session.py +140 -0
- audex/lib/repos/tables/utterance.py +131 -0
- audex/lib/repos/tables/vp.py +102 -0
- audex/lib/repos/utterance.py +288 -0
- audex/lib/repos/vp.py +286 -0
- audex/lib/restful.py +251 -0
- audex/lib/server/__init__.py +97 -0
- audex/lib/server/auth.py +98 -0
- audex/lib/server/handlers.py +248 -0
- audex/lib/server/templates/index.html.j2 +226 -0
- audex/lib/server/templates/login.html.j2 +111 -0
- audex/lib/server/templates/static/script.js +68 -0
- audex/lib/server/templates/static/style.css +579 -0
- audex/lib/server/types.py +123 -0
- audex/lib/session.py +503 -0
- audex/lib/store/__init__.py +238 -0
- audex/lib/store/localfile.py +411 -0
- audex/lib/transcription/__init__.py +33 -0
- audex/lib/transcription/dashscope.py +525 -0
- audex/lib/transcription/events.py +62 -0
- audex/lib/usb.py +554 -0
- audex/lib/vpr/__init__.py +38 -0
- audex/lib/vpr/unisound/__init__.py +185 -0
- audex/lib/vpr/unisound/types.py +469 -0
- audex/lib/vpr/xfyun/__init__.py +483 -0
- audex/lib/vpr/xfyun/types.py +679 -0
- audex/lib/websocket/__init__.py +8 -0
- audex/lib/websocket/connection.py +485 -0
- audex/lib/websocket/pool.py +991 -0
- audex/lib/wifi.py +1146 -0
- audex/lifespan.py +75 -0
- audex/service/__init__.py +27 -0
- audex/service/decorators.py +73 -0
- audex/service/doctor/__init__.py +652 -0
- audex/service/doctor/const.py +36 -0
- audex/service/doctor/exceptions.py +96 -0
- audex/service/doctor/types.py +54 -0
- audex/service/export/__init__.py +236 -0
- audex/service/export/const.py +17 -0
- audex/service/export/exceptions.py +34 -0
- audex/service/export/types.py +21 -0
- audex/service/injectors/__init__.py +1 -0
- audex/service/injectors/container.py +53 -0
- audex/service/injectors/doctor.py +34 -0
- audex/service/injectors/export.py +27 -0
- audex/service/injectors/session.py +49 -0
- audex/service/session/__init__.py +754 -0
- audex/service/session/const.py +34 -0
- audex/service/session/exceptions.py +67 -0
- audex/service/session/types.py +91 -0
- audex/types.py +39 -0
- audex/utils.py +287 -0
- audex/valueobj/__init__.py +81 -0
- audex/valueobj/common/__init__.py +1 -0
- audex/valueobj/common/auth.py +84 -0
- audex/valueobj/common/email.py +16 -0
- audex/valueobj/common/ops.py +22 -0
- audex/valueobj/common/phone.py +84 -0
- audex/valueobj/common/version.py +72 -0
- audex/valueobj/session.py +19 -0
- audex/valueobj/utterance.py +15 -0
- audex/view/__init__.py +51 -0
- audex/view/container.py +17 -0
- audex/view/decorators.py +303 -0
- audex/view/pages/__init__.py +1 -0
- audex/view/pages/dashboard/__init__.py +286 -0
- audex/view/pages/dashboard/wifi.py +407 -0
- audex/view/pages/login.py +110 -0
- audex/view/pages/recording.py +348 -0
- audex/view/pages/register.py +202 -0
- audex/view/pages/sessions/__init__.py +196 -0
- audex/view/pages/sessions/details.py +224 -0
- audex/view/pages/sessions/export.py +443 -0
- audex/view/pages/settings.py +374 -0
- audex/view/pages/voiceprint/__init__.py +1 -0
- audex/view/pages/voiceprint/enroll.py +195 -0
- audex/view/pages/voiceprint/update.py +195 -0
- audex/view/static/css/dashboard.css +452 -0
- audex/view/static/css/glass.css +22 -0
- audex/view/static/css/global.css +541 -0
- audex/view/static/css/login.css +386 -0
- audex/view/static/css/recording.css +439 -0
- audex/view/static/css/register.css +293 -0
- audex/view/static/css/sessions/styles.css +501 -0
- audex/view/static/css/settings.css +186 -0
- audex/view/static/css/voiceprint/enroll.css +43 -0
- audex/view/static/css/voiceprint/styles.css +209 -0
- audex/view/static/css/voiceprint/update.css +44 -0
- audex/view/static/images/logo.svg +95 -0
- audex/view/static/js/recording.js +42 -0
- audex-1.0.7a3.dist-info/METADATA +361 -0
- audex-1.0.7a3.dist-info/RECORD +192 -0
- audex-1.0.7a3.dist-info/WHEEL +4 -0
- 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()
|