sustained 0.0.1__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.
- sustained/__init__.py +39 -0
- sustained/builder.py +479 -0
- sustained/model.py +145 -0
- sustained/types.py +74 -0
- sustained-0.0.1.dist-info/METADATA +89 -0
- sustained-0.0.1.dist-info/RECORD +8 -0
- sustained-0.0.1.dist-info/WHEEL +4 -0
- sustained-0.0.1.dist-info/licenses/LICENSE +21 -0
sustained/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A Python query builder inspired by Objection.js.
|
|
3
|
+
|
|
4
|
+
This package provides a set of classes that allow you to build SQL queries
|
|
5
|
+
in a more programmatic and reusable way. The main components are:
|
|
6
|
+
|
|
7
|
+
- Model: A base class for defining database models and their relations.
|
|
8
|
+
- QueryBuilder: A class for constructing SQL queries.
|
|
9
|
+
- RelationType: An Enum for defining the type of relationship between models.
|
|
10
|
+
- create_model: A factory function for dynamically creating Model classes.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .builder import QueryBuilder
|
|
14
|
+
from .model import Model, create_model
|
|
15
|
+
from .types import (
|
|
16
|
+
BasicJoinMapping,
|
|
17
|
+
Join,
|
|
18
|
+
JoinMappingWithThrough,
|
|
19
|
+
RelationMapping,
|
|
20
|
+
RelationType,
|
|
21
|
+
ThroughJoinMapping,
|
|
22
|
+
ThroughJoinValue,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
# from types
|
|
27
|
+
"RelationType",
|
|
28
|
+
"BasicJoinMapping",
|
|
29
|
+
"ThroughJoinValue",
|
|
30
|
+
"ThroughJoinMapping",
|
|
31
|
+
"JoinMappingWithThrough",
|
|
32
|
+
"Join",
|
|
33
|
+
"RelationMapping",
|
|
34
|
+
# from builder
|
|
35
|
+
"QueryBuilder",
|
|
36
|
+
# from model
|
|
37
|
+
"Model",
|
|
38
|
+
"create_model",
|
|
39
|
+
]
|
sustained/builder.py
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import (
|
|
5
|
+
TYPE_CHECKING,
|
|
6
|
+
Any,
|
|
7
|
+
Callable,
|
|
8
|
+
Dict,
|
|
9
|
+
List,
|
|
10
|
+
Optional,
|
|
11
|
+
Tuple,
|
|
12
|
+
Type,
|
|
13
|
+
Union,
|
|
14
|
+
cast,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from .types import BasicJoinMapping, JoinMappingWithThrough
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .model import Model
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JoinClauseBuilder:
|
|
24
|
+
"""
|
|
25
|
+
A helper class for building complex JOIN ... ON clauses.
|
|
26
|
+
An instance of this is passed to the lambda in `...join(..., lambda j: ...)` calls.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
self._conditions: List[Tuple[str, str]] = []
|
|
31
|
+
|
|
32
|
+
def on(self, col1: str, op: str, col2: str) -> "JoinClauseBuilder":
|
|
33
|
+
"""Adds an ON condition. If this is not the first condition, it's treated as AND ON."""
|
|
34
|
+
conjunction = "AND" if self._conditions else ""
|
|
35
|
+
self._add_condition(conjunction, col1, op, col2)
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def andOn(self, col1: str, op: str, col2: str) -> "JoinClauseBuilder":
|
|
39
|
+
"""Adds an AND ON condition."""
|
|
40
|
+
if not self._conditions:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
"Cannot use 'andOn' for the first join condition. Use 'on' instead."
|
|
43
|
+
)
|
|
44
|
+
self._add_condition("AND", col1, op, col2)
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def orOn(self, col1: str, op: str, col2: str) -> "JoinClauseBuilder":
|
|
48
|
+
"""Adds an OR ON condition."""
|
|
49
|
+
if not self._conditions:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
"Cannot use 'orOn' for the first join condition. Use 'on' instead."
|
|
52
|
+
)
|
|
53
|
+
self._add_condition("OR", col1, op, col2)
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def _add_condition(self, conjunction: str, col1: str, op: str, col2: str):
|
|
57
|
+
condition_str = f"{col1} {op} {col2}"
|
|
58
|
+
self._conditions.append((conjunction, condition_str))
|
|
59
|
+
|
|
60
|
+
def __str__(self) -> str:
|
|
61
|
+
"""Builds the final ON clause string."""
|
|
62
|
+
if not self._conditions:
|
|
63
|
+
raise RuntimeError("A join condition must be specified inside the lambda.")
|
|
64
|
+
|
|
65
|
+
# The first condition doesn't have a preceding conjunction
|
|
66
|
+
parts = [self._conditions[0][1]]
|
|
67
|
+
for conjunction, clause in self._conditions[1:]:
|
|
68
|
+
parts.append(f"{conjunction} {clause}")
|
|
69
|
+
return " ".join(parts)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class QueryBuilder:
|
|
73
|
+
"""
|
|
74
|
+
A builder for creating and executing SQL queries in a programmatic way.
|
|
75
|
+
|
|
76
|
+
This class is not meant to be instantiated directly. Instead, you should use
|
|
77
|
+
the `query()` class method on a `Model` subclass.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
_JOIN_METHOD_MAP = {
|
|
81
|
+
"": "JOIN",
|
|
82
|
+
"inner": "INNER JOIN",
|
|
83
|
+
"left": "LEFT JOIN",
|
|
84
|
+
"leftOuter": "LEFT OUTER JOIN",
|
|
85
|
+
"right": "RIGHT JOIN",
|
|
86
|
+
"rightOuter": "RIGHT OUTER JOIN",
|
|
87
|
+
"full": "FULL JOIN",
|
|
88
|
+
"fullOuter": "FULL OUTER JOIN",
|
|
89
|
+
"cross": "CROSS JOIN",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def __init__(self, model_class: Type["Model"]):
|
|
93
|
+
"""
|
|
94
|
+
Initializes the QueryBuilder.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
model_class (Type[Model]): The `Model` subclass this query is based on.
|
|
98
|
+
"""
|
|
99
|
+
self._model_class = model_class
|
|
100
|
+
self._selected_columns: List[str] = []
|
|
101
|
+
self._joins: List[str] = []
|
|
102
|
+
self._where_clauses: List[Tuple[str, str]] = []
|
|
103
|
+
self._with_clauses: List[Tuple[str, str]] = []
|
|
104
|
+
|
|
105
|
+
def select(self, *columns: str) -> "QueryBuilder":
|
|
106
|
+
"""
|
|
107
|
+
Specifies the columns to be selected in the query.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
*columns (str): A list of column names to select.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
QueryBuilder: The current QueryBuilder instance for chaining.
|
|
114
|
+
"""
|
|
115
|
+
self._selected_columns.extend(columns)
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def with_(self, table_alias: str, subquery: "QueryBuilder") -> "QueryBuilder":
|
|
119
|
+
"""
|
|
120
|
+
Adds a Common Table Expression (CTE) to the query.
|
|
121
|
+
NOTE: This method is named `with_` to avoid conflict with the Python `with` keyword.
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
cte_query = OtherModel.query().select("id", "name")
|
|
125
|
+
main_query = MyModel.query().with_("my_cte", cte_query).select("*")
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
table_alias (str): The alias for the CTE.
|
|
129
|
+
subquery (QueryBuilder): The query builder instance for the CTE's subquery.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
QueryBuilder: The current QueryBuilder instance for chaining.
|
|
133
|
+
"""
|
|
134
|
+
self._with_clauses.append((table_alias, str(subquery)))
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def __str__(self) -> str:
|
|
138
|
+
"""
|
|
139
|
+
Builds and returns the final SQL query string.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
str: The complete SQL query.
|
|
143
|
+
"""
|
|
144
|
+
query_parts = []
|
|
145
|
+
|
|
146
|
+
if self._with_clauses:
|
|
147
|
+
cte_strs = [
|
|
148
|
+
f"{alias} AS ({subquery_str})"
|
|
149
|
+
for alias, subquery_str in self._with_clauses
|
|
150
|
+
]
|
|
151
|
+
with_str = "WITH " + ", ".join(cte_strs)
|
|
152
|
+
query_parts.append(with_str)
|
|
153
|
+
|
|
154
|
+
cols = ", ".join(self._selected_columns) if self._selected_columns else "*"
|
|
155
|
+
model_cls = self._model_class
|
|
156
|
+
parts = []
|
|
157
|
+
if model_cls.database:
|
|
158
|
+
parts.append(model_cls.database)
|
|
159
|
+
if model_cls.tableSchema:
|
|
160
|
+
parts.append(model_cls.tableSchema)
|
|
161
|
+
if model_cls.tableName:
|
|
162
|
+
parts.append(model_cls.tableName)
|
|
163
|
+
full_table_name = ".".join(parts)
|
|
164
|
+
|
|
165
|
+
joins_str = " ".join(self._joins)
|
|
166
|
+
|
|
167
|
+
where_str = ""
|
|
168
|
+
if self._where_clauses:
|
|
169
|
+
where_str = "WHERE " + self._build_clause_list_string()
|
|
170
|
+
|
|
171
|
+
query_parts.append(f"SELECT {cols} FROM {full_table_name}")
|
|
172
|
+
|
|
173
|
+
if joins_str:
|
|
174
|
+
query_parts.append(joins_str)
|
|
175
|
+
|
|
176
|
+
if where_str:
|
|
177
|
+
query_parts.append(where_str)
|
|
178
|
+
|
|
179
|
+
return " ".join(query_parts)
|
|
180
|
+
|
|
181
|
+
def __getattr__(self, name: str) -> Callable:
|
|
182
|
+
"""
|
|
183
|
+
Dynamically handles method calls for joins and where clauses.
|
|
184
|
+
|
|
185
|
+
This allows for methods like `where('id', '=', 1)`, `orWhere(...)`,
|
|
186
|
+
`innerJoinRelated('owner')`, etc., to be called on the query builder.
|
|
187
|
+
|
|
188
|
+
Supported join methods:
|
|
189
|
+
- `joinRelated`
|
|
190
|
+
- `innerJoinRelated`
|
|
191
|
+
- `leftJoinRelated`
|
|
192
|
+
- `leftOuterJoinRelated`
|
|
193
|
+
- `rightJoinRelated`
|
|
194
|
+
- `rightOuterJoinRelated`
|
|
195
|
+
- `fullJoinRelated`
|
|
196
|
+
- `fullOuterJoinRelated`
|
|
197
|
+
- `crossJoinRelated`
|
|
198
|
+
|
|
199
|
+
Supported where methods:
|
|
200
|
+
- `where`
|
|
201
|
+
- `andWhere`
|
|
202
|
+
- `orWhere`
|
|
203
|
+
- `whereIn`
|
|
204
|
+
- `andWhereIn`
|
|
205
|
+
- `orWhereIn`
|
|
206
|
+
- `whereNotIn`
|
|
207
|
+
- `andWhereNotIn`
|
|
208
|
+
- `orWhereNotIn`
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
name (str): The name of the method being called.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Callable: A function that executes the corresponding join or where logic.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
AttributeError: If the method name is not a valid dynamic method.
|
|
218
|
+
"""
|
|
219
|
+
# Handle all join methods (e.g., innerJoinRelated, leftJoin)
|
|
220
|
+
join_prefixes = "|".join(k for k in self._JOIN_METHOD_MAP.keys() if k)
|
|
221
|
+
join_match = re.match(
|
|
222
|
+
rf"^({join_prefixes})?(Join)(Related)?$", name, re.IGNORECASE
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if join_match:
|
|
226
|
+
join_prefix, _, related_suffix = join_match.groups()
|
|
227
|
+
sql_join_type = self._JOIN_METHOD_MAP[join_prefix or ""]
|
|
228
|
+
|
|
229
|
+
if related_suffix:
|
|
230
|
+
# This is a ...joinRelated() call
|
|
231
|
+
def dynamic_join_caller(
|
|
232
|
+
relation_name: str, alias: Optional[str] = None
|
|
233
|
+
):
|
|
234
|
+
self._join_related_internal(sql_join_type, relation_name, alias)
|
|
235
|
+
return self
|
|
236
|
+
|
|
237
|
+
return dynamic_join_caller
|
|
238
|
+
else:
|
|
239
|
+
# This is a raw ...join() call
|
|
240
|
+
def dynamic_raw_join_caller(table: str, *args: Any):
|
|
241
|
+
if len(args) == 3:
|
|
242
|
+
# Static syntax: .join('table', 'col1', '=', 'col2')
|
|
243
|
+
col1, op, col2 = args
|
|
244
|
+
on_clause = f"{col1} {op} {col2}"
|
|
245
|
+
elif len(args) == 1 and callable(args[0]):
|
|
246
|
+
# Composable syntax: .join('table', lambda j: ...)
|
|
247
|
+
join_builder_fn = args[0]
|
|
248
|
+
join_builder = JoinClauseBuilder()
|
|
249
|
+
join_builder_fn(join_builder)
|
|
250
|
+
on_clause = str(join_builder)
|
|
251
|
+
else:
|
|
252
|
+
raise ValueError(
|
|
253
|
+
"Invalid arguments for join method. Use `join(table, col1, op, col2)` or `join(table, lambda j: ...)`."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
join_clause = f"{sql_join_type} {table} ON {on_clause}"
|
|
257
|
+
self._joins.append(join_clause)
|
|
258
|
+
return self
|
|
259
|
+
|
|
260
|
+
return dynamic_raw_join_caller
|
|
261
|
+
|
|
262
|
+
# Handle where methods (e.g., where, andWhereIn)
|
|
263
|
+
where_match = re.match(
|
|
264
|
+
r"^(or|and)?(Where|WhereIn|WhereNotIn)$", name, re.IGNORECASE
|
|
265
|
+
)
|
|
266
|
+
if where_match:
|
|
267
|
+
conjunction_str, where_type_str = where_match.groups()
|
|
268
|
+
|
|
269
|
+
if not self._where_clauses:
|
|
270
|
+
if conjunction_str and conjunction_str.lower() != "and":
|
|
271
|
+
raise RuntimeError(
|
|
272
|
+
f"Cannot start a where clause with '{conjunction_str}'."
|
|
273
|
+
)
|
|
274
|
+
conjunction = ""
|
|
275
|
+
else:
|
|
276
|
+
conjunction = (conjunction_str or "and").upper()
|
|
277
|
+
|
|
278
|
+
where_type = where_type_str.lower()
|
|
279
|
+
|
|
280
|
+
def dynamic_where_caller(*args):
|
|
281
|
+
if where_type == "where":
|
|
282
|
+
if len(args) == 1 and callable(args[0]):
|
|
283
|
+
self._add_where_internal(conjunction, args[0])
|
|
284
|
+
elif len(args) == 3:
|
|
285
|
+
self._add_where_internal(conjunction, *args)
|
|
286
|
+
else:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
"Invalid arguments for 'where' method. Use `where(column, operator, value)` or `where(lambda q: ...)`."
|
|
289
|
+
)
|
|
290
|
+
elif where_type == "wherein":
|
|
291
|
+
self._add_where_in_internal(conjunction, "IN", *args)
|
|
292
|
+
elif where_type == "wherenotin":
|
|
293
|
+
self._add_where_in_internal(conjunction, "NOT IN", *args)
|
|
294
|
+
return self
|
|
295
|
+
|
|
296
|
+
return dynamic_where_caller
|
|
297
|
+
|
|
298
|
+
raise AttributeError(
|
|
299
|
+
f"'{type(self).__name__}' object has no attribute '{name}'"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def _build_where_clause(self, column: str, operator: str, value: Any) -> str:
|
|
303
|
+
"""Formats a single where clause condition."""
|
|
304
|
+
formatted_value = value
|
|
305
|
+
if isinstance(value, str):
|
|
306
|
+
escaped_value = value.replace("'", "''")
|
|
307
|
+
formatted_value = f"'{escaped_value}'"
|
|
308
|
+
return f"{column} {operator} {formatted_value}"
|
|
309
|
+
|
|
310
|
+
def _add_where_internal(
|
|
311
|
+
self,
|
|
312
|
+
conjunction: str,
|
|
313
|
+
column_or_callable: Any,
|
|
314
|
+
op: Optional[str] = None,
|
|
315
|
+
val: Optional[Any] = None,
|
|
316
|
+
):
|
|
317
|
+
"""Internal handler for adding `where` clauses."""
|
|
318
|
+
if not self._where_clauses and conjunction:
|
|
319
|
+
raise RuntimeError(f"Cannot use '{conjunction}' on the first where clause.")
|
|
320
|
+
|
|
321
|
+
if callable(column_or_callable):
|
|
322
|
+
# Handle grouped where clauses, e.g., where(lambda q: q.where(...))
|
|
323
|
+
temp_builder = QueryBuilder(self._model_class)
|
|
324
|
+
column_or_callable(temp_builder)
|
|
325
|
+
if temp_builder._where_clauses:
|
|
326
|
+
grouped_clause_str = temp_builder._build_clause_list_string()
|
|
327
|
+
self._where_clauses.append((conjunction, f"({grouped_clause_str})"))
|
|
328
|
+
else:
|
|
329
|
+
# We can assert op is not None because of the checks in __getattr__
|
|
330
|
+
assert op is not None
|
|
331
|
+
clause = self._build_where_clause(column_or_callable, op, val)
|
|
332
|
+
self._where_clauses.append((conjunction, clause))
|
|
333
|
+
|
|
334
|
+
def _add_where_in_internal(self, conjunction: str, op: str, col: str, vals: list):
|
|
335
|
+
"""Internal handler for adding `WHERE IN` and `WHERE NOT IN` clauses."""
|
|
336
|
+
if not self._where_clauses and conjunction:
|
|
337
|
+
raise RuntimeError(f"Cannot use '{conjunction}' on the first where clause.")
|
|
338
|
+
|
|
339
|
+
formatted_values = []
|
|
340
|
+
for v in vals:
|
|
341
|
+
if isinstance(v, str):
|
|
342
|
+
formatted_values.append(f"'{v.replace("'", "''")}'")
|
|
343
|
+
else:
|
|
344
|
+
formatted_values.append(str(v))
|
|
345
|
+
values_str = ", ".join(formatted_values)
|
|
346
|
+
clause = f"{col} {op} ({values_str})"
|
|
347
|
+
self._where_clauses.append((conjunction, clause))
|
|
348
|
+
|
|
349
|
+
def _build_clause_list_string(self) -> str:
|
|
350
|
+
"""Builds the complete WHERE clause string from all parts."""
|
|
351
|
+
if not self._where_clauses:
|
|
352
|
+
return ""
|
|
353
|
+
|
|
354
|
+
# The first clause doesn't have a preceding conjunction
|
|
355
|
+
parts = [self._where_clauses[0][1]]
|
|
356
|
+
for conjunction, clause in self._where_clauses[1:]:
|
|
357
|
+
parts.append(f"{conjunction} {clause}")
|
|
358
|
+
return " ".join(parts)
|
|
359
|
+
|
|
360
|
+
def _join_related_internal(
|
|
361
|
+
self, join_type: str, relation_name: str, alias: Optional[str] = None
|
|
362
|
+
):
|
|
363
|
+
"""Internal handler for adding a join based on a defined relation."""
|
|
364
|
+
relation = self._model_class.relationMappings.get(relation_name)
|
|
365
|
+
if not relation:
|
|
366
|
+
raise ValueError(
|
|
367
|
+
f"Relation '{relation_name}' not found in model '{self._model_class.__name__}'"
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
related_model_class = self._resolve_model_class(relation["modelClass"])
|
|
371
|
+
join_info = relation["join"]
|
|
372
|
+
|
|
373
|
+
if "through" in join_info:
|
|
374
|
+
# Cast to the more specific TypedDict to satisfy mypy
|
|
375
|
+
through_join_info = cast(JoinMappingWithThrough, join_info)
|
|
376
|
+
self._add_through_join(
|
|
377
|
+
join_type, through_join_info, related_model_class, alias
|
|
378
|
+
)
|
|
379
|
+
else:
|
|
380
|
+
basic_join_info = cast(BasicJoinMapping, join_info)
|
|
381
|
+
self._add_basic_join(join_type, basic_join_info, related_model_class, alias)
|
|
382
|
+
|
|
383
|
+
def _resolve_model_class(
|
|
384
|
+
self, model_class_ref: Union[Type["Model"], str]
|
|
385
|
+
) -> Type["Model"]:
|
|
386
|
+
"""Resolves a model class reference (string or class) to a class type."""
|
|
387
|
+
if isinstance(model_class_ref, str):
|
|
388
|
+
# Try to find the model class in the global scope.
|
|
389
|
+
# This is a simple mechanism for resolving string references.
|
|
390
|
+
if model_class_ref in globals():
|
|
391
|
+
return globals()[model_class_ref]
|
|
392
|
+
else:
|
|
393
|
+
raise ValueError(
|
|
394
|
+
f"Could not resolve model class string '{model_class_ref}'"
|
|
395
|
+
)
|
|
396
|
+
return model_class_ref
|
|
397
|
+
|
|
398
|
+
def _add_basic_join(
|
|
399
|
+
self,
|
|
400
|
+
join_type: str,
|
|
401
|
+
join_info: BasicJoinMapping,
|
|
402
|
+
related_model_class: Type["Model"],
|
|
403
|
+
alias: Optional[str] = None,
|
|
404
|
+
):
|
|
405
|
+
"""Adds a basic (e.g., one-to-one, one-to-many) join to the query."""
|
|
406
|
+
final_related_table_name = related_model_class.tableName
|
|
407
|
+
assert (
|
|
408
|
+
final_related_table_name is not None
|
|
409
|
+
), "Model used in a relation must have a tableName"
|
|
410
|
+
|
|
411
|
+
from_col = join_info["from"]
|
|
412
|
+
to_col = join_info["to"]
|
|
413
|
+
on_clause = f"{from_col} = {to_col}"
|
|
414
|
+
|
|
415
|
+
join_table_part = final_related_table_name
|
|
416
|
+
if alias:
|
|
417
|
+
join_table_part = f"{final_related_table_name} AS {alias}"
|
|
418
|
+
# If an alias is used, update the `ON` clause to reference it.
|
|
419
|
+
to_table, to_column = to_col.split(".")
|
|
420
|
+
if to_table == final_related_table_name:
|
|
421
|
+
on_clause = f"{from_col} = {alias}.{to_column}"
|
|
422
|
+
|
|
423
|
+
join_clause = f"{join_type} {join_table_part} ON {on_clause}"
|
|
424
|
+
self._joins.append(join_clause)
|
|
425
|
+
|
|
426
|
+
def _add_through_join(
|
|
427
|
+
self,
|
|
428
|
+
join_type: str,
|
|
429
|
+
join_info: JoinMappingWithThrough,
|
|
430
|
+
related_model_class: Type["Model"],
|
|
431
|
+
alias: Optional[str] = None,
|
|
432
|
+
):
|
|
433
|
+
"""Adds a many-to-many join using a 'through' table."""
|
|
434
|
+
related_table_name_from_model = related_model_class.tableName
|
|
435
|
+
assert (
|
|
436
|
+
related_table_name_from_model is not None
|
|
437
|
+
), "Model used in a relation must have a tableName"
|
|
438
|
+
|
|
439
|
+
# First join: from the base model's table to the 'through' table.
|
|
440
|
+
from_col = join_info["from"]
|
|
441
|
+
through_from_mapping = join_info["through"]["from"]
|
|
442
|
+
|
|
443
|
+
through_table_ref = through_from_mapping["table"]
|
|
444
|
+
through_table_name: str
|
|
445
|
+
if isinstance(through_table_ref, str):
|
|
446
|
+
through_table_name = through_table_ref
|
|
447
|
+
else:
|
|
448
|
+
table_name = through_table_ref.tableName
|
|
449
|
+
assert (
|
|
450
|
+
table_name is not None
|
|
451
|
+
), "Model used as a through table must have a tableName"
|
|
452
|
+
through_table_name = table_name
|
|
453
|
+
|
|
454
|
+
through_from_key = through_from_mapping["key"]
|
|
455
|
+
|
|
456
|
+
on_clause1 = f"{from_col} = {through_table_name}.{through_from_key}"
|
|
457
|
+
# The join to the through table is always an INNER JOIN.
|
|
458
|
+
join_clause1 = f"INNER JOIN {through_table_name} ON {on_clause1}"
|
|
459
|
+
self._joins.append(join_clause1)
|
|
460
|
+
|
|
461
|
+
# Second join: from the 'through' table to the final related model's table.
|
|
462
|
+
through_to_mapping = join_info["through"]["to"]
|
|
463
|
+
through_to_key = through_to_mapping["key"]
|
|
464
|
+
to_col = join_info["to"]
|
|
465
|
+
|
|
466
|
+
on_clause2 = f"{through_table_name}.{through_to_key} = {to_col}"
|
|
467
|
+
|
|
468
|
+
join_table_part = related_table_name_from_model
|
|
469
|
+
if alias:
|
|
470
|
+
join_table_part = f"{related_table_name_from_model} AS {alias}"
|
|
471
|
+
# If an alias is used, update the `ON` clause to reference it.
|
|
472
|
+
to_table, to_column = to_col.split(".")
|
|
473
|
+
if to_table == related_table_name_from_model:
|
|
474
|
+
on_clause2 = (
|
|
475
|
+
f"{through_table_name}.{through_to_key} = {alias}.{to_column}"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
join_clause2 = f"{join_type} {join_table_part} ON {on_clause2}"
|
|
479
|
+
self._joins.append(join_clause2)
|
sustained/model.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Dict, Optional, Type
|
|
4
|
+
|
|
5
|
+
from .types import RelationMapping
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .builder import QueryBuilder
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Model:
|
|
12
|
+
"""
|
|
13
|
+
A base model class that mimics Objection.js models for defining database tables
|
|
14
|
+
and their relationships.
|
|
15
|
+
|
|
16
|
+
To use this, create a subclass and define the `tableName` and, optionally,
|
|
17
|
+
`relationMappings`, `tableSchema`, and `database`.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
database (str, optional): The name of the database. Defaults to None.
|
|
21
|
+
tableName (str): The name of the table in the database. Defaults to None.
|
|
22
|
+
tableSchema (str, optional): The schema of the table. Defaults to None.
|
|
23
|
+
relationMappings (Dict[str, RelationMapping]): A dictionary defining
|
|
24
|
+
relationships to other models.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
database: Optional[str] = None
|
|
28
|
+
tableName: Optional[str] = None
|
|
29
|
+
tableSchema: Optional[str] = None
|
|
30
|
+
relationMappings: Dict[str, RelationMapping] = {}
|
|
31
|
+
|
|
32
|
+
def __init__(self, **kwargs):
|
|
33
|
+
"""
|
|
34
|
+
Initializes a model instance, allowing attributes to be set from
|
|
35
|
+
keyword arguments.
|
|
36
|
+
"""
|
|
37
|
+
for key, value in kwargs.items():
|
|
38
|
+
setattr(self, key, value)
|
|
39
|
+
|
|
40
|
+
def __repr__(self):
|
|
41
|
+
"""Provides a developer-friendly representation of the model instance."""
|
|
42
|
+
attributes = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
|
|
43
|
+
return f"{self.__class__.__name__}({attributes})"
|
|
44
|
+
|
|
45
|
+
def __getattr__(self, name: str) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Provides attribute-style access to table columns, which returns a
|
|
48
|
+
fully-qualified column name string for use in queries.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
If a `User` model has `tableName = 'users'`, then `User().id` would
|
|
52
|
+
return `'users.id'`.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
AttributeError: If the attribute does not exist or if `tableName`
|
|
56
|
+
is not defined on the model.
|
|
57
|
+
"""
|
|
58
|
+
cls = self.__class__
|
|
59
|
+
if name.startswith("__") and name.endswith("__"):
|
|
60
|
+
raise AttributeError(f"'{cls.__name__}' object has no attribute '{name}'")
|
|
61
|
+
|
|
62
|
+
database = getattr(cls, "database", None)
|
|
63
|
+
table_schema = getattr(cls, "tableSchema", None)
|
|
64
|
+
table_name = getattr(cls, "tableName", None)
|
|
65
|
+
|
|
66
|
+
# We must have a table name to provide a column reference.
|
|
67
|
+
if table_name:
|
|
68
|
+
parts = []
|
|
69
|
+
if database:
|
|
70
|
+
parts.append(database)
|
|
71
|
+
if table_schema:
|
|
72
|
+
parts.append(table_schema)
|
|
73
|
+
parts.append(table_name)
|
|
74
|
+
parts.append(name)
|
|
75
|
+
return ".".join(parts)
|
|
76
|
+
|
|
77
|
+
raise AttributeError(f"'{cls.__name__}' object has no attribute '{name}'")
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def query(cls) -> "QueryBuilder":
|
|
81
|
+
"""
|
|
82
|
+
Starts a new query for this model.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
QueryBuilder: A new QueryBuilder instance for this model.
|
|
86
|
+
"""
|
|
87
|
+
from .builder import QueryBuilder
|
|
88
|
+
|
|
89
|
+
return QueryBuilder(cls)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def create_model(
|
|
93
|
+
name: str,
|
|
94
|
+
table_name: str,
|
|
95
|
+
mappings: Optional[Dict[str, RelationMapping]] = None,
|
|
96
|
+
table_schema: Optional[str] = None,
|
|
97
|
+
database: Optional[str] = None,
|
|
98
|
+
) -> Type[Model]:
|
|
99
|
+
"""
|
|
100
|
+
Dynamically creates a `Model` subclass.
|
|
101
|
+
|
|
102
|
+
This is useful when you need to define models programmatically instead of
|
|
103
|
+
declaratively.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
name (str): The name of the new model class (e.g., "Animal").
|
|
107
|
+
table_name (str): The database table name for the model.
|
|
108
|
+
mappings (Dict[str, RelationMapping], optional): A dictionary of
|
|
109
|
+
relation mappings. Defaults to None.
|
|
110
|
+
table_schema (str, optional): The database schema. Defaults to None.
|
|
111
|
+
database (str, optional): The database name. Defaults to None.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Type[Model]: A new class that inherits from `Model`.
|
|
115
|
+
|
|
116
|
+
Example:
|
|
117
|
+
.. code-block:: python
|
|
118
|
+
|
|
119
|
+
Person = create_model('Person', 'persons')
|
|
120
|
+
|
|
121
|
+
Animal = create_model(
|
|
122
|
+
'Animal',
|
|
123
|
+
'animals',
|
|
124
|
+
mappings={
|
|
125
|
+
'owner': {
|
|
126
|
+
'relation': RelationType.BelongsToOneRelation,
|
|
127
|
+
'modelClass': Person,
|
|
128
|
+
'join': {'from': 'animals.ownerId', 'to': 'persons.id'}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
query = Animal.query().select('name').joinRelated('owner')
|
|
134
|
+
"""
|
|
135
|
+
if mappings is None:
|
|
136
|
+
mappings = {}
|
|
137
|
+
|
|
138
|
+
model_attrs = {"tableName": table_name, "relationMappings": mappings}
|
|
139
|
+
|
|
140
|
+
if table_schema:
|
|
141
|
+
model_attrs["tableSchema"] = table_schema
|
|
142
|
+
if database:
|
|
143
|
+
model_attrs["database"] = database
|
|
144
|
+
|
|
145
|
+
return type(name, (Model,), model_attrs)
|
sustained/types.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import TYPE_CHECKING, Dict, Type, TypedDict, Union
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .model import Model
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RelationType(Enum):
|
|
11
|
+
"""
|
|
12
|
+
Defines the types of relations between models, mirroring Objection.js relation types.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
BelongsToOneRelation = "BelongsToOneRelation"
|
|
16
|
+
HasManyRelation = "HasManyRelation"
|
|
17
|
+
HasOneRelation = "HasOneRelation"
|
|
18
|
+
ManyToManyRelation = "ManyToManyRelation"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
BasicJoinMapping = TypedDict(
|
|
22
|
+
"BasicJoinMapping",
|
|
23
|
+
{
|
|
24
|
+
"from": str,
|
|
25
|
+
"to": str,
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
"""Defines a basic join between two tables."""
|
|
29
|
+
|
|
30
|
+
ThroughJoinValue = TypedDict(
|
|
31
|
+
"ThroughJoinValue",
|
|
32
|
+
{
|
|
33
|
+
"table": Union[Type["Model"], str],
|
|
34
|
+
"key": str,
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
"""Specifies the intermediate table and key for a through relation."""
|
|
38
|
+
|
|
39
|
+
ThroughJoinMapping = TypedDict(
|
|
40
|
+
"ThroughJoinMapping",
|
|
41
|
+
{
|
|
42
|
+
"from": ThroughJoinValue,
|
|
43
|
+
"to": ThroughJoinValue,
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
"""Defines the 'from' and 'to' parts of a 'through' clause in a many-to-many relation."""
|
|
47
|
+
|
|
48
|
+
JoinMappingWithThrough = TypedDict(
|
|
49
|
+
"JoinMappingWithThrough",
|
|
50
|
+
{
|
|
51
|
+
"from": str,
|
|
52
|
+
"through": ThroughJoinMapping,
|
|
53
|
+
"to": str,
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
"""Defines a many-to-many join that includes an intermediate 'through' table."""
|
|
57
|
+
|
|
58
|
+
Join = Union[BasicJoinMapping, JoinMappingWithThrough]
|
|
59
|
+
"""A union of possible join mapping types."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RelationMapping(TypedDict):
|
|
63
|
+
"""
|
|
64
|
+
Describes a relationship between two models.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
relation (RelationType): The type of the relation.
|
|
68
|
+
modelClass (Union[Type["Model"], str]): The related model class or its name.
|
|
69
|
+
join (Join): The join mapping that defines how the tables are connected.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
relation: RelationType
|
|
73
|
+
modelClass: Union[Type["Model"], str]
|
|
74
|
+
join: Join
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sustained
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A Python query builder inspired by Objection.js
|
|
5
|
+
Project-URL: Homepage, https://github.com/wetherc/sustained
|
|
6
|
+
Project-URL: Issues, https://github.com/wetherc/sustained/issues
|
|
7
|
+
Author-email: Christopher Wetherill <git@tbmh.org>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.7
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Sustained.py
|
|
16
|
+
|
|
17
|
+
A Python query builder inspired by [Objection.js](https://vincit.github.io/objection.js/).
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
This package is not available on PyPI and must be installed from source.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from sustained import Model, RelationType, create_model
|
|
31
|
+
|
|
32
|
+
class Person(Model):
|
|
33
|
+
database = 'my_db'
|
|
34
|
+
tableSchema = 'public'
|
|
35
|
+
tableName = 'persons'
|
|
36
|
+
|
|
37
|
+
class Animal(Model):
|
|
38
|
+
tableName = 'animals'
|
|
39
|
+
|
|
40
|
+
relationMappings = {
|
|
41
|
+
'owner': {
|
|
42
|
+
'relation': RelationType.BelongsToOneRelation,
|
|
43
|
+
'modelClass': Person,
|
|
44
|
+
'join': {
|
|
45
|
+
'from': 'animals.ownerId',
|
|
46
|
+
'to': 'persons.id'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Build a query
|
|
52
|
+
query = Animal.query().select('animals.name', 'persons.name').leftOuterJoinRelated('owner')
|
|
53
|
+
|
|
54
|
+
print(query)
|
|
55
|
+
# SELECT animals.name, persons.name FROM animals LEFT OUTER JOIN persons ON animals.ownerId = persons.id
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Build a more complex query with a CTE and a raw join
|
|
59
|
+
active_owners = Person.query().select('id').where('status', '=', 'active')
|
|
60
|
+
|
|
61
|
+
query = (
|
|
62
|
+
Animal.query()
|
|
63
|
+
.with_('active_owners', active_owners)
|
|
64
|
+
.join('active_owners', 'animals.ownerId', '=', 'active_owners.id')
|
|
65
|
+
.select('animals.name')
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
print(query)
|
|
69
|
+
# WITH active_owners AS (SELECT id FROM persons WHERE status = 'active') SELECT animals.name FROM animals JOIN active_owners ON animals.ownerId = active_owners.id
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
This project uses `pre-commit` to enforce code quality and run tests before committing code.
|
|
75
|
+
|
|
76
|
+
### Pre-commit Hooks Setup
|
|
77
|
+
|
|
78
|
+
1. **Install pre-commit:**
|
|
79
|
+
```bash
|
|
80
|
+
pip install pre-commit
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
2. **Install the Git hooks:**
|
|
84
|
+
From the root of the project directory, run:
|
|
85
|
+
```bash
|
|
86
|
+
pre-commit install
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Now, the pre-commit hooks (including `black`, `isort`, `mypy`, and unit tests) will run automatically on every commit.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
sustained/__init__.py,sha256=-mugN3a1eK-C0585KOW4DBfcxzpZGVQUv4mun3Oo_Ps,1001
|
|
2
|
+
sustained/builder.py,sha256=JJqzBOxAAShAV1kyIlXp4WvfRKAO0hMwiV8Cp02_Bak,17990
|
|
3
|
+
sustained/model.py,sha256=afzdOJzJnJH26k8W_B0nBBAfbD0BeRHvgfVcoOI5Lqk,4765
|
|
4
|
+
sustained/types.py,sha256=u5sHFG3QM8qAvYOtsCjYJdvX8r1BFJzx6ya-97ydhxY,1843
|
|
5
|
+
sustained-0.0.1.dist-info/METADATA,sha256=4aMKH4ddejwQaxAOnJI4xEbaCdVPXpx5_hxzgwd7tO4,2380
|
|
6
|
+
sustained-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
sustained-0.0.1.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
|
|
8
|
+
sustained-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|