duckling-orm 0.0.3__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.
- duckling/__init__.py +123 -0
- duckling/connection.py +173 -0
- duckling/document.py +598 -0
- duckling/exceptions.py +33 -0
- duckling/fields.py +261 -0
- duckling/init.py +142 -0
- duckling/operators.py +148 -0
- duckling/query.py +391 -0
- duckling_orm-0.0.3.dist-info/METADATA +349 -0
- duckling_orm-0.0.3.dist-info/RECORD +13 -0
- duckling_orm-0.0.3.dist-info/WHEEL +5 -0
- duckling_orm-0.0.3.dist-info/licenses/LICENSE +202 -0
- duckling_orm-0.0.3.dist-info/top_level.txt +1 -0
duckling/fields.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Field types, indexes, and query expression proxies for Duckling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Generic, Optional, TypeVar, get_args, get_origin
|
|
8
|
+
|
|
9
|
+
from pydantic import Field as PydanticField
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ──────────────────────────────────────────────
|
|
13
|
+
# Sort direction
|
|
14
|
+
# ──────────────────────────────────────────────
|
|
15
|
+
class SortDirection(enum.IntEnum):
|
|
16
|
+
ASCENDING = 1
|
|
17
|
+
DESCENDING = -1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ──────────────────────────────────────────────
|
|
21
|
+
# Index specification
|
|
22
|
+
# ──────────────────────────────────────────────
|
|
23
|
+
@dataclass
|
|
24
|
+
class IndexSpec:
|
|
25
|
+
"""Describes an index on a column."""
|
|
26
|
+
|
|
27
|
+
unique: bool = False
|
|
28
|
+
index_type: str = "default" # default, hash, art
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def Indexed(
|
|
32
|
+
field_type: type = None,
|
|
33
|
+
*,
|
|
34
|
+
unique: bool = False,
|
|
35
|
+
index_type: str = "default",
|
|
36
|
+
**kwargs,
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Mark a field as indexed, similar to Beanie's Indexed().
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
class User(Document):
|
|
43
|
+
email: Indexed(str, unique=True)
|
|
44
|
+
age: Indexed(int)
|
|
45
|
+
"""
|
|
46
|
+
if field_type is None:
|
|
47
|
+
field_type = Any
|
|
48
|
+
|
|
49
|
+
# Store index metadata in Pydantic's json_schema_extra
|
|
50
|
+
metadata = {
|
|
51
|
+
"_duckling_indexed": True,
|
|
52
|
+
"_duckling_unique": unique,
|
|
53
|
+
"_duckling_index_type": index_type,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Return an Annotated type with our metadata
|
|
57
|
+
from typing import Annotated
|
|
58
|
+
|
|
59
|
+
return Annotated[field_type, IndexSpec(unique=unique, index_type=index_type)]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ──────────────────────────────────────────────
|
|
63
|
+
# Query Expressions
|
|
64
|
+
# ──────────────────────────────────────────────
|
|
65
|
+
class Expression:
|
|
66
|
+
"""Base class for SQL expressions used in query building."""
|
|
67
|
+
|
|
68
|
+
def __and__(self, other: Expression) -> AndExpression:
|
|
69
|
+
return AndExpression(self, other)
|
|
70
|
+
|
|
71
|
+
def __or__(self, other: Expression) -> OrExpression:
|
|
72
|
+
return OrExpression(self, other)
|
|
73
|
+
|
|
74
|
+
def __invert__(self) -> NotExpression:
|
|
75
|
+
return NotExpression(self)
|
|
76
|
+
|
|
77
|
+
def to_sql(self) -> tuple[str, list]:
|
|
78
|
+
"""Return (sql_fragment, params) tuple."""
|
|
79
|
+
raise NotImplementedError
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ComparisonExpression(Expression):
|
|
83
|
+
"""A comparison like `field_name > value`."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, field_name: str, op: str, value: Any) -> None:
|
|
86
|
+
self.field_name = field_name
|
|
87
|
+
self.op = op
|
|
88
|
+
self.value = value
|
|
89
|
+
|
|
90
|
+
def to_sql(self) -> tuple[str, list]:
|
|
91
|
+
if self.value is None:
|
|
92
|
+
if self.op == "=":
|
|
93
|
+
return f'"{self.field_name}" IS NULL', []
|
|
94
|
+
elif self.op in ("!=", "<>"):
|
|
95
|
+
return f'"{self.field_name}" IS NOT NULL', []
|
|
96
|
+
return f'"{self.field_name}" {self.op} ?', [self.value]
|
|
97
|
+
|
|
98
|
+
def __repr__(self) -> str:
|
|
99
|
+
return f"ComparisonExpression({self.field_name!r} {self.op} {self.value!r})"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class InExpression(Expression):
|
|
103
|
+
"""An `IN (...)` expression."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, field_name: str, values: list, negate: bool = False) -> None:
|
|
106
|
+
self.field_name = field_name
|
|
107
|
+
self.values = values
|
|
108
|
+
self.negate = negate
|
|
109
|
+
|
|
110
|
+
def to_sql(self) -> tuple[str, list]:
|
|
111
|
+
placeholders = ", ".join("?" for _ in self.values)
|
|
112
|
+
op = "NOT IN" if self.negate else "IN"
|
|
113
|
+
return f'"{self.field_name}" {op} ({placeholders})', list(self.values)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class BetweenExpression(Expression):
|
|
117
|
+
"""A `BETWEEN` expression."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, field_name: str, low: Any, high: Any) -> None:
|
|
120
|
+
self.field_name = field_name
|
|
121
|
+
self.low = low
|
|
122
|
+
self.high = high
|
|
123
|
+
|
|
124
|
+
def to_sql(self) -> tuple[str, list]:
|
|
125
|
+
return f'"{self.field_name}" BETWEEN ? AND ?', [self.low, self.high]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class LikeExpression(Expression):
|
|
129
|
+
"""A `LIKE` / `ILIKE` expression."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, field_name: str, pattern: str, case_insensitive: bool = False) -> None:
|
|
132
|
+
self.field_name = field_name
|
|
133
|
+
self.pattern = pattern
|
|
134
|
+
self.case_insensitive = case_insensitive
|
|
135
|
+
|
|
136
|
+
def to_sql(self) -> tuple[str, list]:
|
|
137
|
+
op = "ILIKE" if self.case_insensitive else "LIKE"
|
|
138
|
+
return f'"{self.field_name}" {op} ?', [self.pattern]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class RawExpression(Expression):
|
|
142
|
+
"""A raw SQL expression."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, sql: str, params: Optional[list] = None) -> None:
|
|
145
|
+
self.sql = sql
|
|
146
|
+
self.params = params or []
|
|
147
|
+
|
|
148
|
+
def to_sql(self) -> tuple[str, list]:
|
|
149
|
+
return self.sql, self.params
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class AndExpression(Expression):
|
|
153
|
+
def __init__(self, left: Expression, right: Expression) -> None:
|
|
154
|
+
self.left = left
|
|
155
|
+
self.right = right
|
|
156
|
+
|
|
157
|
+
def to_sql(self) -> tuple[str, list]:
|
|
158
|
+
left_sql, left_params = self.left.to_sql()
|
|
159
|
+
right_sql, right_params = self.right.to_sql()
|
|
160
|
+
return f"({left_sql} AND {right_sql})", left_params + right_params
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class OrExpression(Expression):
|
|
164
|
+
def __init__(self, left: Expression, right: Expression) -> None:
|
|
165
|
+
self.left = left
|
|
166
|
+
self.right = right
|
|
167
|
+
|
|
168
|
+
def to_sql(self) -> tuple[str, list]:
|
|
169
|
+
left_sql, left_params = self.left.to_sql()
|
|
170
|
+
right_sql, right_params = self.right.to_sql()
|
|
171
|
+
return f"({left_sql} OR {right_sql})", left_params + right_params
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class NotExpression(Expression):
|
|
175
|
+
def __init__(self, expr: Expression) -> None:
|
|
176
|
+
self.expr = expr
|
|
177
|
+
|
|
178
|
+
def to_sql(self) -> tuple[str, list]:
|
|
179
|
+
sql, params = self.expr.to_sql()
|
|
180
|
+
return f"NOT ({sql})", params
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ──────────────────────────────────────────────
|
|
184
|
+
# Field Proxy — enables `User.name == "Alice"`
|
|
185
|
+
# ──────────────────────────────────────────────
|
|
186
|
+
class FieldProxy:
|
|
187
|
+
"""
|
|
188
|
+
A descriptor proxy returned when accessing a field on the Document *class*.
|
|
189
|
+
Supports comparison operators that produce Expression objects for queries.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(self, field_name: str, field_type: type = Any) -> None:
|
|
193
|
+
self.field_name = field_name
|
|
194
|
+
self.field_type = field_type
|
|
195
|
+
|
|
196
|
+
# Comparison operators → Expression
|
|
197
|
+
def __eq__(self, other: Any) -> ComparisonExpression: # type: ignore[override]
|
|
198
|
+
return ComparisonExpression(self.field_name, "=", other)
|
|
199
|
+
|
|
200
|
+
def __ne__(self, other: Any) -> ComparisonExpression: # type: ignore[override]
|
|
201
|
+
return ComparisonExpression(self.field_name, "!=", other)
|
|
202
|
+
|
|
203
|
+
def __gt__(self, other: Any) -> ComparisonExpression:
|
|
204
|
+
return ComparisonExpression(self.field_name, ">", other)
|
|
205
|
+
|
|
206
|
+
def __ge__(self, other: Any) -> ComparisonExpression:
|
|
207
|
+
return ComparisonExpression(self.field_name, ">=", other)
|
|
208
|
+
|
|
209
|
+
def __lt__(self, other: Any) -> ComparisonExpression:
|
|
210
|
+
return ComparisonExpression(self.field_name, "<", other)
|
|
211
|
+
|
|
212
|
+
def __le__(self, other: Any) -> ComparisonExpression:
|
|
213
|
+
return ComparisonExpression(self.field_name, "<=", other)
|
|
214
|
+
|
|
215
|
+
# Extra query helpers
|
|
216
|
+
def is_in(self, values: list) -> InExpression:
|
|
217
|
+
"""Field IN (values...)"""
|
|
218
|
+
return InExpression(self.field_name, values)
|
|
219
|
+
|
|
220
|
+
def not_in(self, values: list) -> InExpression:
|
|
221
|
+
"""Field NOT IN (values...)"""
|
|
222
|
+
return InExpression(self.field_name, values, negate=True)
|
|
223
|
+
|
|
224
|
+
def between(self, low: Any, high: Any) -> BetweenExpression:
|
|
225
|
+
"""Field BETWEEN low AND high"""
|
|
226
|
+
return BetweenExpression(self.field_name, low, high)
|
|
227
|
+
|
|
228
|
+
def like(self, pattern: str) -> LikeExpression:
|
|
229
|
+
"""Field LIKE pattern"""
|
|
230
|
+
return LikeExpression(self.field_name, pattern)
|
|
231
|
+
|
|
232
|
+
def ilike(self, pattern: str) -> LikeExpression:
|
|
233
|
+
"""Field ILIKE pattern (case-insensitive)"""
|
|
234
|
+
return LikeExpression(self.field_name, pattern, case_insensitive=True)
|
|
235
|
+
|
|
236
|
+
def startswith(self, prefix: str) -> LikeExpression:
|
|
237
|
+
return LikeExpression(self.field_name, f"{prefix}%")
|
|
238
|
+
|
|
239
|
+
def endswith(self, suffix: str) -> LikeExpression:
|
|
240
|
+
return LikeExpression(self.field_name, f"%{suffix}")
|
|
241
|
+
|
|
242
|
+
def contains(self, substring: str) -> LikeExpression:
|
|
243
|
+
return LikeExpression(self.field_name, f"%{substring}%")
|
|
244
|
+
|
|
245
|
+
# Sort helpers
|
|
246
|
+
def asc(self) -> tuple[str, SortDirection]:
|
|
247
|
+
return (self.field_name, SortDirection.ASCENDING)
|
|
248
|
+
|
|
249
|
+
def desc(self) -> tuple[str, SortDirection]:
|
|
250
|
+
return (self.field_name, SortDirection.DESCENDING)
|
|
251
|
+
|
|
252
|
+
def __pos__(self) -> tuple[str, SortDirection]:
|
|
253
|
+
"""Unary + for ascending sort: +User.name"""
|
|
254
|
+
return self.asc()
|
|
255
|
+
|
|
256
|
+
def __neg__(self) -> tuple[str, SortDirection]:
|
|
257
|
+
"""Unary - for descending sort: -User.name"""
|
|
258
|
+
return self.desc()
|
|
259
|
+
|
|
260
|
+
def __repr__(self) -> str:
|
|
261
|
+
return f"FieldProxy({self.field_name!r})"
|
duckling/init.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Initialization for the Duckling ORM.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from duckling import init_duckling, Document
|
|
6
|
+
|
|
7
|
+
class User(Document):
|
|
8
|
+
name: str
|
|
9
|
+
|
|
10
|
+
# Async init
|
|
11
|
+
await init_duckling(
|
|
12
|
+
database=":memory:",
|
|
13
|
+
document_models=[User],
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Sync init
|
|
17
|
+
init_duckling_sync(
|
|
18
|
+
database="my_data.db",
|
|
19
|
+
document_models=[User],
|
|
20
|
+
)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
from typing import Any, Optional, Sequence, Type
|
|
27
|
+
|
|
28
|
+
import duckdb
|
|
29
|
+
|
|
30
|
+
from .connection import DucklingSession, get_session
|
|
31
|
+
from .document import Document
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def init_duckling(
|
|
35
|
+
database: str = ":memory:",
|
|
36
|
+
document_models: Optional[Sequence[Type[Document]]] = None,
|
|
37
|
+
read_only: bool = False,
|
|
38
|
+
config: Optional[dict[str, Any]] = None,
|
|
39
|
+
recreate_tables: bool = False,
|
|
40
|
+
connection: Optional[duckdb.DuckDBPyConnection] = None,
|
|
41
|
+
) -> DucklingSession:
|
|
42
|
+
"""
|
|
43
|
+
Initialize the Duckling ORM — async version.
|
|
44
|
+
|
|
45
|
+
This connects to DuckDB and creates tables for all registered document models.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
database: Path to the DuckDB database file, or ":memory:" for in-memory.
|
|
49
|
+
Ignored if `connection` is provided.
|
|
50
|
+
document_models: List of Document subclasses to register.
|
|
51
|
+
read_only: Open database in read-only mode. Ignored if `connection` is provided.
|
|
52
|
+
config: Optional DuckDB configuration dict. Ignored if `connection` is provided.
|
|
53
|
+
recreate_tables: If True, drop and recreate all tables.
|
|
54
|
+
connection: An existing DuckDB connection to use. If provided, no new
|
|
55
|
+
connection will be created.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The DucklingSession instance.
|
|
59
|
+
|
|
60
|
+
Example:
|
|
61
|
+
await init_duckling(
|
|
62
|
+
database="app.db",
|
|
63
|
+
document_models=[User, Product, Order],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Or with an existing connection:
|
|
67
|
+
conn = duckdb.connect("app.db")
|
|
68
|
+
await init_duckling(
|
|
69
|
+
connection=conn,
|
|
70
|
+
document_models=[User, Product, Order],
|
|
71
|
+
)
|
|
72
|
+
"""
|
|
73
|
+
session = get_session()
|
|
74
|
+
|
|
75
|
+
if connection is not None:
|
|
76
|
+
session.use_connection(connection)
|
|
77
|
+
else:
|
|
78
|
+
await asyncio.to_thread(
|
|
79
|
+
session.connect,
|
|
80
|
+
database=database,
|
|
81
|
+
read_only=read_only,
|
|
82
|
+
config=config,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Register and create tables for each document model
|
|
86
|
+
if document_models:
|
|
87
|
+
for model in document_models:
|
|
88
|
+
if recreate_tables:
|
|
89
|
+
table = model._get_table_name()
|
|
90
|
+
await session.async_execute(f'DROP TABLE IF EXISTS "{table}"')
|
|
91
|
+
await session.async_execute(f"DROP SEQUENCE IF EXISTS seq_{table}_id")
|
|
92
|
+
await model._create_table()
|
|
93
|
+
|
|
94
|
+
return session
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def init_duckling_sync(
|
|
98
|
+
database: str = ":memory:",
|
|
99
|
+
document_models: Optional[Sequence[Type[Document]]] = None,
|
|
100
|
+
read_only: bool = False,
|
|
101
|
+
config: Optional[dict[str, Any]] = None,
|
|
102
|
+
recreate_tables: bool = False,
|
|
103
|
+
connection: Optional[duckdb.DuckDBPyConnection] = None,
|
|
104
|
+
) -> DucklingSession:
|
|
105
|
+
"""
|
|
106
|
+
Initialize the Duckling ORM — synchronous version.
|
|
107
|
+
|
|
108
|
+
Same as init_duckling() but runs synchronously.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
database: Path to the DuckDB database file, or ":memory:" for in-memory.
|
|
112
|
+
Ignored if `connection` is provided.
|
|
113
|
+
document_models: List of Document subclasses to register.
|
|
114
|
+
read_only: Open database in read-only mode. Ignored if `connection` is provided.
|
|
115
|
+
config: Optional DuckDB configuration dict. Ignored if `connection` is provided.
|
|
116
|
+
recreate_tables: If True, drop and recreate all tables.
|
|
117
|
+
connection: An existing DuckDB connection to use. If provided, no new
|
|
118
|
+
connection will be created.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
The DucklingSession instance.
|
|
122
|
+
"""
|
|
123
|
+
session = get_session()
|
|
124
|
+
|
|
125
|
+
if connection is not None:
|
|
126
|
+
session.use_connection(connection)
|
|
127
|
+
else:
|
|
128
|
+
session.connect(
|
|
129
|
+
database=database,
|
|
130
|
+
read_only=read_only,
|
|
131
|
+
config=config,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if document_models:
|
|
135
|
+
for model in document_models:
|
|
136
|
+
if recreate_tables:
|
|
137
|
+
table = model._get_table_name()
|
|
138
|
+
session.execute(f'DROP TABLE IF EXISTS "{table}"')
|
|
139
|
+
session.execute(f"DROP SEQUENCE IF EXISTS seq_{table}_id")
|
|
140
|
+
model._create_table_sync()
|
|
141
|
+
|
|
142
|
+
return session
|
duckling/operators.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Query operators for Duckling — providing both Beanie-style and SQL-friendly syntax.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from duckling.operators import In, Between, Like, And, Or, Not, Raw
|
|
6
|
+
|
|
7
|
+
# Beanie-style operator usage
|
|
8
|
+
await User.find(In(User.age, [25, 30, 35])).to_list()
|
|
9
|
+
await User.find(Between(User.age, 18, 65)).to_list()
|
|
10
|
+
await User.find(Like(User.name, "A%")).to_list()
|
|
11
|
+
|
|
12
|
+
# Combine with boolean operators
|
|
13
|
+
await User.find(
|
|
14
|
+
And(
|
|
15
|
+
User.age >= 18,
|
|
16
|
+
Or(User.city == "NYC", User.city == "LA")
|
|
17
|
+
)
|
|
18
|
+
).to_list()
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .fields import (
|
|
26
|
+
AndExpression,
|
|
27
|
+
BetweenExpression,
|
|
28
|
+
Expression,
|
|
29
|
+
InExpression,
|
|
30
|
+
LikeExpression,
|
|
31
|
+
NotExpression,
|
|
32
|
+
OrExpression,
|
|
33
|
+
RawExpression,
|
|
34
|
+
ComparisonExpression,
|
|
35
|
+
FieldProxy,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ──────────────────────────────────────────────
|
|
40
|
+
# Functional operator constructors
|
|
41
|
+
# ──────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def In(field: FieldProxy | str, values: list) -> InExpression:
|
|
44
|
+
"""Create an IN expression: `field IN (v1, v2, ...)`."""
|
|
45
|
+
name = field.field_name if isinstance(field, FieldProxy) else field
|
|
46
|
+
return InExpression(name, values)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def NotIn(field: FieldProxy | str, values: list) -> InExpression:
|
|
50
|
+
"""Create a NOT IN expression: `field NOT IN (v1, v2, ...)`."""
|
|
51
|
+
name = field.field_name if isinstance(field, FieldProxy) else field
|
|
52
|
+
return InExpression(name, values, negate=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def Between(field: FieldProxy | str, low: Any, high: Any) -> BetweenExpression:
|
|
56
|
+
"""Create a BETWEEN expression: `field BETWEEN low AND high`."""
|
|
57
|
+
name = field.field_name if isinstance(field, FieldProxy) else field
|
|
58
|
+
return BetweenExpression(name, low, high)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def Like(field: FieldProxy | str, pattern: str) -> LikeExpression:
|
|
62
|
+
"""Create a LIKE expression: `field LIKE pattern`."""
|
|
63
|
+
name = field.field_name if isinstance(field, FieldProxy) else field
|
|
64
|
+
return LikeExpression(name, pattern)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def ILike(field: FieldProxy | str, pattern: str) -> LikeExpression:
|
|
68
|
+
"""Create an ILIKE expression: `field ILIKE pattern` (case-insensitive)."""
|
|
69
|
+
name = field.field_name if isinstance(field, FieldProxy) else field
|
|
70
|
+
return LikeExpression(name, pattern, case_insensitive=True)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def And(*expressions: Expression) -> Expression:
|
|
74
|
+
"""Combine expressions with AND."""
|
|
75
|
+
if not expressions:
|
|
76
|
+
raise ValueError("And() requires at least one expression")
|
|
77
|
+
result = expressions[0]
|
|
78
|
+
for expr in expressions[1:]:
|
|
79
|
+
result = AndExpression(result, expr)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def Or(*expressions: Expression) -> Expression:
|
|
84
|
+
"""Combine expressions with OR."""
|
|
85
|
+
if not expressions:
|
|
86
|
+
raise ValueError("Or() requires at least one expression")
|
|
87
|
+
result = expressions[0]
|
|
88
|
+
for expr in expressions[1:]:
|
|
89
|
+
result = OrExpression(result, expr)
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def Not(expression: Expression) -> NotExpression:
|
|
94
|
+
"""Negate an expression with NOT."""
|
|
95
|
+
return NotExpression(expression)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def Raw(sql: str, params: list | None = None) -> RawExpression:
|
|
99
|
+
"""Create a raw SQL expression."""
|
|
100
|
+
return RawExpression(sql, params)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def Eq(field: FieldProxy | str, value: Any) -> ComparisonExpression:
|
|
104
|
+
"""Equality: `field = value`."""
|
|
105
|
+
name = field.field_name if isinstance(field, FieldProxy) else field
|
|
106
|
+
return ComparisonExpression(name, "=", value)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def Ne(field: FieldProxy | str, value: Any) -> ComparisonExpression:
|
|
110
|
+
"""Not equal: `field != value`."""
|
|
111
|
+
name = field.field_name if isinstance(field, FieldProxy) else field
|
|
112
|
+
return ComparisonExpression(name, "!=", value)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def Gt(field: FieldProxy | str, value: Any) -> ComparisonExpression:
|
|
116
|
+
"""Greater than: `field > value`."""
|
|
117
|
+
name = field.field_name if isinstance(field, FieldProxy) else field
|
|
118
|
+
return ComparisonExpression(name, ">", value)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def Gte(field: FieldProxy | str, value: Any) -> ComparisonExpression:
|
|
122
|
+
"""Greater than or equal: `field >= value`."""
|
|
123
|
+
name = field.field_name if isinstance(field, FieldProxy) else field
|
|
124
|
+
return ComparisonExpression(name, ">=", value)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def Lt(field: FieldProxy | str, value: Any) -> ComparisonExpression:
|
|
128
|
+
"""Less than: `field < value`."""
|
|
129
|
+
name = field.field_name if isinstance(field, FieldProxy) else field
|
|
130
|
+
return ComparisonExpression(name, "<", value)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def Lte(field: FieldProxy | str, value: Any) -> ComparisonExpression:
|
|
134
|
+
"""Less than or equal: `field <= value`."""
|
|
135
|
+
name = field.field_name if isinstance(field, FieldProxy) else field
|
|
136
|
+
return ComparisonExpression(name, "<=", value)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def IsNull(field: FieldProxy | str) -> ComparisonExpression:
|
|
140
|
+
"""IS NULL check."""
|
|
141
|
+
name = field.field_name if isinstance(field, FieldProxy) else field
|
|
142
|
+
return ComparisonExpression(name, "=", None)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def IsNotNull(field: FieldProxy | str) -> ComparisonExpression:
|
|
146
|
+
"""IS NOT NULL check."""
|
|
147
|
+
name = field.field_name if isinstance(field, FieldProxy) else field
|
|
148
|
+
return ComparisonExpression(name, "!=", None)
|