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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.