sqliter-py 0.12.0__py3-none-any.whl → 0.17.0__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.
- sqliter/constants.py +4 -3
- sqliter/exceptions.py +29 -0
- sqliter/helpers.py +27 -0
- sqliter/model/model.py +21 -4
- sqliter/orm/__init__.py +17 -0
- sqliter/orm/fields.py +412 -0
- sqliter/orm/foreign_key.py +8 -0
- sqliter/orm/m2m.py +784 -0
- sqliter/orm/model.py +308 -0
- sqliter/orm/query.py +221 -0
- sqliter/orm/registry.py +440 -0
- sqliter/query/query.py +573 -51
- sqliter/sqliter.py +182 -47
- sqliter/tui/__init__.py +62 -0
- sqliter/tui/__main__.py +6 -0
- sqliter/tui/app.py +179 -0
- sqliter/tui/demos/__init__.py +96 -0
- sqliter/tui/demos/base.py +114 -0
- sqliter/tui/demos/caching.py +283 -0
- sqliter/tui/demos/connection.py +150 -0
- sqliter/tui/demos/constraints.py +211 -0
- sqliter/tui/demos/crud.py +154 -0
- sqliter/tui/demos/errors.py +231 -0
- sqliter/tui/demos/field_selection.py +150 -0
- sqliter/tui/demos/filters.py +389 -0
- sqliter/tui/demos/models.py +248 -0
- sqliter/tui/demos/ordering.py +156 -0
- sqliter/tui/demos/orm.py +537 -0
- sqliter/tui/demos/results.py +241 -0
- sqliter/tui/demos/string_filters.py +210 -0
- sqliter/tui/demos/timestamps.py +126 -0
- sqliter/tui/demos/transactions.py +177 -0
- sqliter/tui/runner.py +116 -0
- sqliter/tui/styles/app.tcss +130 -0
- sqliter/tui/widgets/__init__.py +7 -0
- sqliter/tui/widgets/code_display.py +81 -0
- sqliter/tui/widgets/demo_list.py +65 -0
- sqliter/tui/widgets/output_display.py +92 -0
- {sqliter_py-0.12.0.dist-info → sqliter_py-0.17.0.dist-info}/METADATA +28 -14
- sqliter_py-0.17.0.dist-info/RECORD +48 -0
- {sqliter_py-0.12.0.dist-info → sqliter_py-0.17.0.dist-info}/WHEEL +2 -2
- sqliter_py-0.17.0.dist-info/entry_points.txt +3 -0
- sqliter_py-0.12.0.dist-info/RECORD +0 -15
sqliter/constants.py
CHANGED
|
@@ -21,9 +21,10 @@ OPERATOR_MAPPING = {
|
|
|
21
21
|
"__not_in": "NOT IN",
|
|
22
22
|
"__isnull": "IS NULL",
|
|
23
23
|
"__notnull": "IS NOT NULL",
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
24
|
+
"__like": "LIKE",
|
|
25
|
+
"__startswith": "GLOB",
|
|
26
|
+
"__endswith": "GLOB",
|
|
27
|
+
"__contains": "GLOB",
|
|
27
28
|
"__istartswith": "LIKE",
|
|
28
29
|
"__iendswith": "LIKE",
|
|
29
30
|
"__icontains": "LIKE",
|
sqliter/exceptions.py
CHANGED
|
@@ -196,3 +196,32 @@ class InvalidForeignKeyError(ForeignKeyError):
|
|
|
196
196
|
"""
|
|
197
197
|
|
|
198
198
|
message_template = "Invalid foreign key configuration: {}"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class InvalidRelationshipError(SqliterError):
|
|
202
|
+
"""Raised when an invalid relationship path is specified.
|
|
203
|
+
|
|
204
|
+
This error occurs when using select_related() or relationship filter
|
|
205
|
+
traversal with a non-existent relationship field or invalid path.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
message_template = (
|
|
209
|
+
"Invalid relationship path '{}': field '{}' is not a valid "
|
|
210
|
+
"foreign key relationship on model {}"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class ManyToManyError(SqliterError):
|
|
215
|
+
"""Base exception for many-to-many relationship errors."""
|
|
216
|
+
|
|
217
|
+
message_template = "Many-to-many error: {}"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class ManyToManyIntegrityError(ManyToManyError):
|
|
221
|
+
"""Raised when a M2M operation fails due to missing context or pk.
|
|
222
|
+
|
|
223
|
+
This error occurs when attempting to use a M2M relationship without
|
|
224
|
+
a database context or on an unsaved instance (no primary key).
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
message_template = "Many-to-many integrity error: {}"
|
sqliter/helpers.py
CHANGED
|
@@ -9,11 +9,38 @@ to database schema translation.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import datetime
|
|
12
|
+
import re
|
|
12
13
|
from typing import Union
|
|
13
14
|
|
|
14
15
|
from sqliter.constants import SQLITE_TYPE_MAPPING
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
def validate_table_name(table_name: str) -> str:
|
|
19
|
+
"""Validate that a table name contains only safe characters.
|
|
20
|
+
|
|
21
|
+
Table names must contain only alphanumeric characters and underscores,
|
|
22
|
+
and must start with a letter or underscore. This prevents SQL injection
|
|
23
|
+
through malicious table names.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
table_name: The table name to validate.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The validated table name.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ValueError: If the table name contains invalid characters.
|
|
33
|
+
"""
|
|
34
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", table_name):
|
|
35
|
+
msg = (
|
|
36
|
+
f"Invalid table name '{table_name}'. "
|
|
37
|
+
"Table names must start with a letter or underscore and "
|
|
38
|
+
"contain only letters, numbers, and underscores."
|
|
39
|
+
)
|
|
40
|
+
raise ValueError(msg)
|
|
41
|
+
return table_name
|
|
42
|
+
|
|
43
|
+
|
|
17
44
|
def infer_sqlite_type(field_type: Union[type, None]) -> str:
|
|
18
45
|
"""Infer the SQLite column type based on the Python type.
|
|
19
46
|
|
sqliter/model/model.py
CHANGED
|
@@ -26,7 +26,11 @@ from typing import (
|
|
|
26
26
|
from pydantic import BaseModel, ConfigDict, Field
|
|
27
27
|
from typing_extensions import Self
|
|
28
28
|
|
|
29
|
-
from sqliter.helpers import
|
|
29
|
+
from sqliter.helpers import (
|
|
30
|
+
from_unix_timestamp,
|
|
31
|
+
to_unix_timestamp,
|
|
32
|
+
validate_table_name,
|
|
33
|
+
)
|
|
30
34
|
|
|
31
35
|
|
|
32
36
|
class SerializableField(Protocol):
|
|
@@ -130,12 +134,22 @@ class BaseDBModel(BaseModel):
|
|
|
130
134
|
This method determines the table name based on the Meta configuration
|
|
131
135
|
or derives it from the class name if not explicitly set.
|
|
132
136
|
|
|
137
|
+
When deriving the table name automatically, the class name is converted
|
|
138
|
+
to snake_case and pluralized. If the `inflect` library is installed,
|
|
139
|
+
it provides grammatically correct pluralization (e.g., "person" becomes
|
|
140
|
+
"people", "category" becomes "categories"). Otherwise, a simple "s"
|
|
141
|
+
suffix is added if the name doesn't already end in "s".
|
|
142
|
+
|
|
133
143
|
Returns:
|
|
134
144
|
The name of the database table for this model.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValueError: If the table name contains invalid characters.
|
|
135
148
|
"""
|
|
136
149
|
table_name: str | None = getattr(cls.Meta, "table_name", None)
|
|
137
150
|
if table_name is not None:
|
|
138
|
-
|
|
151
|
+
# Validate custom table names
|
|
152
|
+
return validate_table_name(table_name)
|
|
139
153
|
|
|
140
154
|
# Get class name and remove 'Model' suffix if present
|
|
141
155
|
class_name = cls.__name__.removesuffix("Model")
|
|
@@ -148,15 +162,18 @@ class BaseDBModel(BaseModel):
|
|
|
148
162
|
import inflect # noqa: PLC0415
|
|
149
163
|
|
|
150
164
|
p = inflect.engine()
|
|
151
|
-
|
|
165
|
+
table_name = p.plural(snake_case_name)
|
|
152
166
|
except ImportError:
|
|
153
167
|
# Fallback to simple pluralization by adding 's'
|
|
154
|
-
|
|
168
|
+
table_name = (
|
|
155
169
|
snake_case_name
|
|
156
170
|
if snake_case_name.endswith("s")
|
|
157
171
|
else snake_case_name + "s"
|
|
158
172
|
)
|
|
159
173
|
|
|
174
|
+
# Validate auto-generated table names (should always pass)
|
|
175
|
+
return validate_table_name(table_name)
|
|
176
|
+
|
|
160
177
|
@classmethod
|
|
161
178
|
def get_primary_key(cls) -> str:
|
|
162
179
|
"""Returns the mandatory primary key, always 'pk'."""
|
sqliter/orm/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""ORM submodule for SQLiter.
|
|
2
|
+
|
|
3
|
+
This module provides ORM functionality including lazy loading and reverse
|
|
4
|
+
relationships. It extends the BaseDBModel from sqliter.model without breaking
|
|
5
|
+
changes to the existing code.
|
|
6
|
+
|
|
7
|
+
Users can choose between modes via import:
|
|
8
|
+
- Legacy mode: from sqliter.model import BaseDBModel
|
|
9
|
+
- ORM mode: from sqliter.orm import BaseDBModel
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from sqliter.orm.foreign_key import ForeignKey
|
|
13
|
+
from sqliter.orm.m2m import ManyToMany
|
|
14
|
+
from sqliter.orm.model import BaseDBModel
|
|
15
|
+
from sqliter.orm.registry import ModelRegistry
|
|
16
|
+
|
|
17
|
+
__all__ = ["BaseDBModel", "ForeignKey", "ManyToMany", "ModelRegistry"]
|
sqliter/orm/fields.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Field descriptors for ORM relationships."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import types
|
|
7
|
+
from typing import (
|
|
8
|
+
TYPE_CHECKING,
|
|
9
|
+
Any,
|
|
10
|
+
Generic,
|
|
11
|
+
Optional,
|
|
12
|
+
Protocol,
|
|
13
|
+
TypeVar,
|
|
14
|
+
Union,
|
|
15
|
+
cast,
|
|
16
|
+
get_args,
|
|
17
|
+
get_origin,
|
|
18
|
+
get_type_hints,
|
|
19
|
+
overload,
|
|
20
|
+
runtime_checkable,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from pydantic_core import core_schema
|
|
24
|
+
|
|
25
|
+
from sqliter.model.foreign_key import ForeignKeyInfo
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
28
|
+
from pydantic import GetCoreSchemaHandler
|
|
29
|
+
|
|
30
|
+
from sqliter.model.foreign_key import FKAction
|
|
31
|
+
from sqliter.model.model import BaseDBModel
|
|
32
|
+
from sqliter.sqliter import SqliterDB
|
|
33
|
+
|
|
34
|
+
T = TypeVar("T")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _split_top_level(text: str, sep: str) -> list[str]:
|
|
41
|
+
parts: list[str] = []
|
|
42
|
+
depth = 0
|
|
43
|
+
buf: list[str] = []
|
|
44
|
+
for ch in text:
|
|
45
|
+
if ch in "[(":
|
|
46
|
+
depth += 1
|
|
47
|
+
elif ch in "])":
|
|
48
|
+
depth -= 1
|
|
49
|
+
if ch == sep and depth == 0:
|
|
50
|
+
parts.append("".join(buf).strip())
|
|
51
|
+
buf = []
|
|
52
|
+
else:
|
|
53
|
+
buf.append(ch)
|
|
54
|
+
parts.append("".join(buf).strip())
|
|
55
|
+
return parts
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _annotation_is_nullable(raw: str) -> bool:
|
|
59
|
+
"""Best-effort check for Optional or | None at top level."""
|
|
60
|
+
s = raw.replace("typing.", "").replace("sqliter.orm.fields.", "").strip()
|
|
61
|
+
|
|
62
|
+
if "[" not in s or "]" not in s:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
inner = s[s.find("[") + 1 : s.rfind("]")].strip()
|
|
66
|
+
|
|
67
|
+
if inner.startswith("Optional["):
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
if "|" in inner and any(
|
|
71
|
+
part == "None" for part in _split_top_level(inner, "|")
|
|
72
|
+
):
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
if inner.startswith("Union[") and inner.endswith("]"):
|
|
76
|
+
union_inner = inner[len("Union[") : -1]
|
|
77
|
+
if any(part == "None" for part in _split_top_level(union_inner, ",")):
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@runtime_checkable
|
|
84
|
+
class HasPK(Protocol):
|
|
85
|
+
"""Protocol for objects that have a pk attribute."""
|
|
86
|
+
|
|
87
|
+
pk: Optional[int]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class LazyLoader(Generic[T]):
|
|
91
|
+
"""Proxy object that lazy loads a related object when accessed.
|
|
92
|
+
|
|
93
|
+
When a FK field is accessed, returns a LazyLoader that queries the database
|
|
94
|
+
on first access and caches the result.
|
|
95
|
+
|
|
96
|
+
Note: This class is an implementation detail. For type checking purposes,
|
|
97
|
+
ForeignKey fields are typed as returning T (the type parameter), not
|
|
98
|
+
LazyLoader[T]. This follows the standard ORM pattern used by SQLAlchemy,
|
|
99
|
+
where the proxy is transparent to users. Use ForeignKey[Optional[Model]]
|
|
100
|
+
for nullable foreign keys.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
instance: object,
|
|
106
|
+
to_model: type[T],
|
|
107
|
+
fk_id: Optional[int],
|
|
108
|
+
db_context: Optional[SqliterDB],
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Initialize lazy loader.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
instance: The model instance with the FK
|
|
114
|
+
to_model: The related model class to load
|
|
115
|
+
fk_id: The foreign key ID value
|
|
116
|
+
db_context: Database connection for queries
|
|
117
|
+
"""
|
|
118
|
+
self._instance = instance
|
|
119
|
+
self._to_model = to_model
|
|
120
|
+
self._fk_id = fk_id
|
|
121
|
+
self._db = db_context
|
|
122
|
+
self._cached: Optional[T] = None
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def db_context(self) -> object:
|
|
126
|
+
"""Return the database context (for checking if loader is valid)."""
|
|
127
|
+
return self._db
|
|
128
|
+
|
|
129
|
+
def __getattr__(self, name: str) -> object:
|
|
130
|
+
"""Load related object and delegate attribute access."""
|
|
131
|
+
if self._cached is None:
|
|
132
|
+
self._load()
|
|
133
|
+
if self._cached is None:
|
|
134
|
+
msg = (
|
|
135
|
+
f"Cannot access {name} on None (FK is null or object not found)"
|
|
136
|
+
)
|
|
137
|
+
raise AttributeError(msg)
|
|
138
|
+
return getattr(self._cached, name)
|
|
139
|
+
|
|
140
|
+
def _load(self) -> None:
|
|
141
|
+
"""Load related object from database if not already cached."""
|
|
142
|
+
if self._fk_id is None:
|
|
143
|
+
self._cached = None
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
if self._cached is None and self._db is not None:
|
|
147
|
+
# Use db_context to fetch the related object
|
|
148
|
+
# Catch DB errors (missing table, connection issues, etc.)
|
|
149
|
+
# and treat as "not found" - AttributeError will be raised
|
|
150
|
+
# by __getattr__ when accessing attributes on None
|
|
151
|
+
from sqliter.exceptions import SqliterError # noqa: PLC0415
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
# Cast to type[BaseDBModel] for SqliterDB.get() - T is always
|
|
155
|
+
# a BaseDBModel subclass in practice
|
|
156
|
+
result = self._db.get(
|
|
157
|
+
cast("type[BaseDBModel]", self._to_model), self._fk_id
|
|
158
|
+
)
|
|
159
|
+
self._cached = cast("Optional[T]", result)
|
|
160
|
+
except SqliterError as e:
|
|
161
|
+
# DB errors (missing table, fetch errors) → treat as not found
|
|
162
|
+
logger.debug(
|
|
163
|
+
"LazyLoader failed to fetch %s with pk=%s: %s",
|
|
164
|
+
self._to_model.__name__,
|
|
165
|
+
self._fk_id,
|
|
166
|
+
e,
|
|
167
|
+
)
|
|
168
|
+
self._cached = None
|
|
169
|
+
|
|
170
|
+
def __repr__(self) -> str:
|
|
171
|
+
"""Representation showing lazy state."""
|
|
172
|
+
if self._cached is None:
|
|
173
|
+
return (
|
|
174
|
+
f"<LazyLoader unloaded for {self._to_model.__name__} "
|
|
175
|
+
f"id={self._fk_id}>"
|
|
176
|
+
)
|
|
177
|
+
return f"<LazyLoader loaded: {self._cached!r}>"
|
|
178
|
+
|
|
179
|
+
def __eq__(self, other: object) -> bool:
|
|
180
|
+
"""Compare based on loaded object."""
|
|
181
|
+
if self._cached is None:
|
|
182
|
+
self._load()
|
|
183
|
+
if self._cached is None:
|
|
184
|
+
return other is None
|
|
185
|
+
return self._cached == other
|
|
186
|
+
|
|
187
|
+
# Unhashable due to mutable equality (based on cached object)
|
|
188
|
+
__hash__ = None # type: ignore[assignment]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class ForeignKey(Generic[T]):
|
|
192
|
+
"""Generic descriptor for FK fields providing lazy loading.
|
|
193
|
+
|
|
194
|
+
When a FK field is accessed on a model instance, returns a LazyLoader
|
|
195
|
+
that queries the database for the related object.
|
|
196
|
+
|
|
197
|
+
Usage:
|
|
198
|
+
class Book(BaseDBModel):
|
|
199
|
+
title: str
|
|
200
|
+
author: ForeignKey[Author] = ForeignKey(Author, on_delete="CASCADE")
|
|
201
|
+
|
|
202
|
+
The generic parameter T represents the related model type, ensuring
|
|
203
|
+
proper type checking when accessing the relationship.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def __init__( # noqa: PLR0913
|
|
207
|
+
self,
|
|
208
|
+
to_model: type[T],
|
|
209
|
+
*,
|
|
210
|
+
on_delete: FKAction = "RESTRICT",
|
|
211
|
+
on_update: FKAction = "RESTRICT",
|
|
212
|
+
null: bool = False,
|
|
213
|
+
unique: bool = False,
|
|
214
|
+
related_name: Optional[str] = None,
|
|
215
|
+
db_column: Optional[str] = None,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Initialize FK descriptor.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
to_model: The related model class
|
|
221
|
+
on_delete: Action when related object is deleted
|
|
222
|
+
on_update: Action when related object's PK is updated
|
|
223
|
+
null: Whether FK can be null
|
|
224
|
+
unique: Whether FK must be unique
|
|
225
|
+
related_name: Name for reverse relationship (auto-generated if None)
|
|
226
|
+
db_column: Custom column name for _id field
|
|
227
|
+
"""
|
|
228
|
+
self.to_model = to_model
|
|
229
|
+
self.fk_info = ForeignKeyInfo(
|
|
230
|
+
to_model=cast("type[BaseDBModel]", to_model),
|
|
231
|
+
on_delete=on_delete,
|
|
232
|
+
on_update=on_update,
|
|
233
|
+
null=null,
|
|
234
|
+
unique=unique,
|
|
235
|
+
related_name=related_name,
|
|
236
|
+
# Let _setup_orm_fields set default from actual field name
|
|
237
|
+
db_column=db_column,
|
|
238
|
+
)
|
|
239
|
+
self.related_name = related_name
|
|
240
|
+
self.name: Optional[str] = None # Set by __set_name__
|
|
241
|
+
self.owner: Optional[type] = None # Set by __set_name__
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def __get_pydantic_core_schema__(
|
|
245
|
+
cls,
|
|
246
|
+
source_type: type[Any],
|
|
247
|
+
handler: GetCoreSchemaHandler,
|
|
248
|
+
) -> core_schema.CoreSchema:
|
|
249
|
+
"""Tell Pydantic how to handle ForeignKey[T] type annotations.
|
|
250
|
+
|
|
251
|
+
Uses no_info_plain_validator_function to prevent the descriptor from
|
|
252
|
+
being stored in instance __dict__, which would break the descriptor
|
|
253
|
+
protocol. The ForeignKey descriptor must remain at class level only.
|
|
254
|
+
"""
|
|
255
|
+
# Return a validator that doesn't store anything in __dict__
|
|
256
|
+
# This prevents Pydantic from copying the descriptor to instances
|
|
257
|
+
return core_schema.no_info_plain_validator_function(
|
|
258
|
+
function=lambda _: None # Value is ignored
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def _detect_nullable_from_annotation(self, owner: type, name: str) -> None:
|
|
262
|
+
"""Detect if FK is nullable from type annotation.
|
|
263
|
+
|
|
264
|
+
If the annotation is ForeignKey[Optional[T]], automatically set
|
|
265
|
+
null=True on the FK info. This allows users to declare nullability
|
|
266
|
+
via the type annotation alone.
|
|
267
|
+
"""
|
|
268
|
+
try:
|
|
269
|
+
hints = get_type_hints(owner)
|
|
270
|
+
except Exception: # noqa: BLE001
|
|
271
|
+
# Can fail with forward refs, NameError, etc. - fallback to raw
|
|
272
|
+
raw = owner.__annotations__.get(name)
|
|
273
|
+
if isinstance(raw, str) and _annotation_is_nullable(raw):
|
|
274
|
+
self.fk_info.null = True
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
if name not in hints:
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
annotation = hints[name] # e.g., ForeignKey[Optional[Author]]
|
|
281
|
+
fk_args = get_args(annotation) # e.g., (Optional[Author],)
|
|
282
|
+
|
|
283
|
+
if not fk_args:
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
inner_type = fk_args[0] # e.g., Optional[Author] or Author
|
|
287
|
+
|
|
288
|
+
# Check if inner_type is Optional (Union with None)
|
|
289
|
+
# Handle both typing.Union (Optional[T]) and types.UnionType
|
|
290
|
+
# (T | None on Python 3.10+)
|
|
291
|
+
origin = get_origin(inner_type)
|
|
292
|
+
is_union = origin is Union
|
|
293
|
+
if not is_union and hasattr(types, "UnionType"):
|
|
294
|
+
is_union = isinstance(inner_type, types.UnionType)
|
|
295
|
+
if is_union:
|
|
296
|
+
args = get_args(inner_type)
|
|
297
|
+
if type(None) in args:
|
|
298
|
+
self.fk_info.null = True
|
|
299
|
+
|
|
300
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
301
|
+
"""Called automatically during class creation.
|
|
302
|
+
|
|
303
|
+
Sets up reverse relationship on the related model immediately.
|
|
304
|
+
If related model doesn't exist yet, stores as pending in ModelRegistry.
|
|
305
|
+
|
|
306
|
+
If no `related_name` is provided, one is auto-generated by pluralizing
|
|
307
|
+
the owner class name. If the `inflect` library is installed, it provides
|
|
308
|
+
grammatically correct pluralization (e.g., "Person" becomes "people").
|
|
309
|
+
Otherwise, a simple "s" suffix is added.
|
|
310
|
+
|
|
311
|
+
Auto-detects nullable FKs from the type annotation: if the type is
|
|
312
|
+
ForeignKey[Optional[T]], sets null=True automatically.
|
|
313
|
+
"""
|
|
314
|
+
self.name = name
|
|
315
|
+
self.owner = owner
|
|
316
|
+
|
|
317
|
+
# Auto-detect nullable from type annotation
|
|
318
|
+
# If user writes ForeignKey[Optional[Model]], set null=True
|
|
319
|
+
self._detect_nullable_from_annotation(owner, name)
|
|
320
|
+
|
|
321
|
+
# Store descriptor in class's OWN fk_descriptors dict (not inherited)
|
|
322
|
+
# Check __dict__ to avoid getting inherited dict from parent class
|
|
323
|
+
if "fk_descriptors" not in owner.__dict__:
|
|
324
|
+
owner.fk_descriptors = {} # type: ignore[attr-defined]
|
|
325
|
+
owner.fk_descriptors[name] = self # type: ignore[attr-defined]
|
|
326
|
+
|
|
327
|
+
# Auto-generate related_name if not provided
|
|
328
|
+
if self.related_name is None:
|
|
329
|
+
# Generate pluralized name from owner class name
|
|
330
|
+
base_name = owner.__name__.lower()
|
|
331
|
+
try:
|
|
332
|
+
import inflect # noqa: PLC0415
|
|
333
|
+
|
|
334
|
+
p = inflect.engine()
|
|
335
|
+
self.related_name = p.plural(base_name)
|
|
336
|
+
except ImportError:
|
|
337
|
+
# Fallback to simple pluralization by adding 's'
|
|
338
|
+
self.related_name = (
|
|
339
|
+
base_name if base_name.endswith("s") else base_name + "s"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Set up reverse relationship on related model
|
|
343
|
+
from sqliter.orm.registry import ModelRegistry # noqa: PLC0415
|
|
344
|
+
|
|
345
|
+
ModelRegistry.add_reverse_relationship(
|
|
346
|
+
from_model=owner,
|
|
347
|
+
to_model=self.to_model,
|
|
348
|
+
fk_field=name,
|
|
349
|
+
related_name=self.related_name,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
@overload
|
|
353
|
+
def __get__(self, instance: None, owner: type[object]) -> ForeignKey[T]: ...
|
|
354
|
+
|
|
355
|
+
@overload
|
|
356
|
+
def __get__(self, instance: object, owner: type[object]) -> T: ...
|
|
357
|
+
|
|
358
|
+
def __get__(
|
|
359
|
+
self, instance: Optional[object], owner: type[object]
|
|
360
|
+
) -> Union[ForeignKey[T], T]:
|
|
361
|
+
"""Return LazyLoader that loads related object on attribute access.
|
|
362
|
+
|
|
363
|
+
If accessed on class (not instance), return the descriptor itself.
|
|
364
|
+
|
|
365
|
+
Note: The return type is T (the type parameter). For nullable FKs,
|
|
366
|
+
use ForeignKey[Optional[Model]] and T will be Optional[Model].
|
|
367
|
+
The actual runtime return is a LazyLoader[T] proxy, but type checkers
|
|
368
|
+
see T for proper attribute access inference.
|
|
369
|
+
"""
|
|
370
|
+
if instance is None:
|
|
371
|
+
return self
|
|
372
|
+
|
|
373
|
+
# Get FK ID from instance
|
|
374
|
+
fk_id = getattr(instance, f"{self.name}_id", None)
|
|
375
|
+
|
|
376
|
+
# Return LazyLoader for lazy loading
|
|
377
|
+
# Cast to T for type checking - LazyLoader is a transparent proxy
|
|
378
|
+
# that behaves like T. For nullable FKs, T is Optional[Model].
|
|
379
|
+
return cast(
|
|
380
|
+
"T",
|
|
381
|
+
LazyLoader(
|
|
382
|
+
instance=instance,
|
|
383
|
+
to_model=self.to_model,
|
|
384
|
+
fk_id=fk_id,
|
|
385
|
+
db_context=getattr(instance, "db_context", None),
|
|
386
|
+
),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def __set__(self, instance: object, value: object) -> None:
|
|
390
|
+
"""Set FK value - handles model instances, ints, or None.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
instance: Model instance
|
|
394
|
+
value: New FK value (model instance, int ID, or None)
|
|
395
|
+
"""
|
|
396
|
+
if value is None:
|
|
397
|
+
# Set to None
|
|
398
|
+
setattr(instance, f"{self.name}_id", None)
|
|
399
|
+
elif isinstance(value, int):
|
|
400
|
+
# Set ID directly
|
|
401
|
+
setattr(instance, f"{self.name}_id", value)
|
|
402
|
+
elif isinstance(value, HasPK):
|
|
403
|
+
# Duck typing via Protocol: extract pk from model instance
|
|
404
|
+
setattr(instance, f"{self.name}_id", value.pk)
|
|
405
|
+
else:
|
|
406
|
+
msg = f"FK value must be BaseModel, int, or None, got {type(value)}"
|
|
407
|
+
raise TypeError(msg)
|
|
408
|
+
# Note: FK cache is cleared by BaseDBModel.__setattr__ when _id changes
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# Backwards compatibility alias
|
|
412
|
+
ForeignKeyDescriptor = ForeignKey
|