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/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)