sqliter-py 0.12.0__py3-none-any.whl → 0.16.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.
Files changed (41) hide show
  1. sqliter/constants.py +4 -3
  2. sqliter/exceptions.py +13 -0
  3. sqliter/model/model.py +42 -3
  4. sqliter/orm/__init__.py +16 -0
  5. sqliter/orm/fields.py +412 -0
  6. sqliter/orm/foreign_key.py +8 -0
  7. sqliter/orm/model.py +243 -0
  8. sqliter/orm/query.py +221 -0
  9. sqliter/orm/registry.py +169 -0
  10. sqliter/query/query.py +573 -51
  11. sqliter/sqliter.py +141 -47
  12. sqliter/tui/__init__.py +62 -0
  13. sqliter/tui/__main__.py +6 -0
  14. sqliter/tui/app.py +179 -0
  15. sqliter/tui/demos/__init__.py +96 -0
  16. sqliter/tui/demos/base.py +114 -0
  17. sqliter/tui/demos/caching.py +283 -0
  18. sqliter/tui/demos/connection.py +150 -0
  19. sqliter/tui/demos/constraints.py +211 -0
  20. sqliter/tui/demos/crud.py +154 -0
  21. sqliter/tui/demos/errors.py +231 -0
  22. sqliter/tui/demos/field_selection.py +150 -0
  23. sqliter/tui/demos/filters.py +389 -0
  24. sqliter/tui/demos/models.py +248 -0
  25. sqliter/tui/demos/ordering.py +156 -0
  26. sqliter/tui/demos/orm.py +460 -0
  27. sqliter/tui/demos/results.py +241 -0
  28. sqliter/tui/demos/string_filters.py +210 -0
  29. sqliter/tui/demos/timestamps.py +126 -0
  30. sqliter/tui/demos/transactions.py +177 -0
  31. sqliter/tui/runner.py +116 -0
  32. sqliter/tui/styles/app.tcss +130 -0
  33. sqliter/tui/widgets/__init__.py +7 -0
  34. sqliter/tui/widgets/code_display.py +81 -0
  35. sqliter/tui/widgets/demo_list.py +65 -0
  36. sqliter/tui/widgets/output_display.py +92 -0
  37. {sqliter_py-0.12.0.dist-info → sqliter_py-0.16.0.dist-info}/METADATA +23 -7
  38. sqliter_py-0.16.0.dist-info/RECORD +47 -0
  39. {sqliter_py-0.12.0.dist-info → sqliter_py-0.16.0.dist-info}/WHEEL +2 -2
  40. sqliter_py-0.16.0.dist-info/entry_points.txt +3 -0
  41. sqliter_py-0.12.0.dist-info/RECORD +0 -15
sqliter/orm/model.py ADDED
@@ -0,0 +1,243 @@
1
+ """ORM model with lazy loading and reverse relationships."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, ClassVar, Optional
6
+
7
+ from pydantic import Field
8
+
9
+ from sqliter.model.model import BaseDBModel as _BaseDBModel
10
+ from sqliter.orm.fields import ForeignKey, HasPK, LazyLoader
11
+ from sqliter.orm.registry import ModelRegistry
12
+
13
+ __all__ = ["BaseDBModel"]
14
+
15
+
16
+ class BaseDBModel(_BaseDBModel):
17
+ """Extends BaseDBModel with ORM features.
18
+
19
+ Adds:
20
+ - Lazy loading of foreign key relationships
21
+ - Automatic reverse relationship setup
22
+ - db_context for query execution
23
+ """
24
+
25
+ # Store FK descriptors per class (not inherited)
26
+ fk_descriptors: ClassVar[dict[str, ForeignKey[Any]]] = {}
27
+
28
+ # Database context for lazy loading and reverse queries
29
+ # Using Any since SqliterDB would cause circular import issues with Pydantic
30
+ db_context: Optional[Any] = Field(default=None, exclude=True)
31
+
32
+ def __init__(self, **kwargs: Any) -> None: # noqa: ANN401
33
+ """Initialize model, converting FK fields to _id fields."""
34
+ # Convert FK field values to _id fields before validation
35
+ for fk_field in self.fk_descriptors:
36
+ if fk_field in kwargs:
37
+ value = kwargs[fk_field]
38
+ if isinstance(value, HasPK):
39
+ # Duck typing via Protocol: extract pk from model
40
+ kwargs[f"{fk_field}_id"] = value.pk
41
+ del kwargs[fk_field]
42
+ elif isinstance(value, int):
43
+ # Already an ID, just move to _id field
44
+ kwargs[f"{fk_field}_id"] = value
45
+ del kwargs[fk_field]
46
+ elif value is None:
47
+ # Keep None for nullable FKs
48
+ kwargs[f"{fk_field}_id"] = None
49
+ del kwargs[fk_field]
50
+
51
+ super().__init__(**kwargs)
52
+
53
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]: # noqa: ANN401
54
+ """Dump model, excluding FK descriptor fields.
55
+
56
+ FK descriptor fields (like 'author') are excluded from serialization.
57
+ Only the _id fields (like 'author_id') are included.
58
+ """
59
+ data = super().model_dump(**kwargs)
60
+ # Remove FK descriptor fields from the dump
61
+ for fk_field in self.fk_descriptors:
62
+ data.pop(fk_field, None)
63
+ return data
64
+
65
+ def __getattribute__(self, name: str) -> object:
66
+ """Intercept FK field access to provide lazy loading."""
67
+ # Check if this is a FK field
68
+ if name in object.__getattribute__(self, "fk_descriptors"):
69
+ # Get FK ID
70
+ fk_id = object.__getattribute__(self, f"{name}_id")
71
+
72
+ # Null FK returns None directly (standard ORM behavior)
73
+ if fk_id is None:
74
+ return None
75
+
76
+ # Check instance cache for identity (same object on repeated access)
77
+ instance_dict = object.__getattribute__(self, "__dict__")
78
+ cache = instance_dict.setdefault("_fk_cache", {})
79
+ db_context = object.__getattribute__(self, "db_context")
80
+
81
+ # Check if we need to create or refresh the cached loader
82
+ cached_loader = cache.get(name)
83
+ needs_refresh = cached_loader is None or (
84
+ cached_loader.db_context is None and db_context is not None
85
+ )
86
+
87
+ if needs_refresh:
88
+ # Get the descriptor and create LazyLoader
89
+ fk_descs = object.__getattribute__(self, "fk_descriptors")
90
+ descriptor = fk_descs[name]
91
+ cache[name] = LazyLoader(
92
+ instance=self,
93
+ to_model=descriptor.to_model,
94
+ fk_id=fk_id,
95
+ db_context=db_context,
96
+ )
97
+ return cache[name]
98
+ # For non-FK fields, use normal attribute access
99
+ return object.__getattribute__(self, name)
100
+
101
+ def __setattr__(self, name: str, value: object) -> None:
102
+ """Intercept FK field assignment to convert to _id field."""
103
+ # Check if this is a FK field assignment
104
+ fk_descs = getattr(self, "fk_descriptors", {})
105
+ if name in fk_descs:
106
+ # Convert FK assignment to _id field assignment
107
+ # This bypasses Pydantic's validation for the FK field (which is
108
+ # not in model_fields) and uses the _id field instead
109
+ id_field_name = f"{name}_id"
110
+ if value is None:
111
+ setattr(self, id_field_name, None)
112
+ elif isinstance(value, int):
113
+ setattr(self, id_field_name, value)
114
+ elif isinstance(value, HasPK):
115
+ setattr(self, id_field_name, value.pk)
116
+ else:
117
+ msg = (
118
+ f"FK value must be BaseModel, int, or None, "
119
+ f"got {type(value)}"
120
+ )
121
+ raise TypeError(msg)
122
+ return
123
+
124
+ # If setting an _id field, clear corresponding FK cache
125
+ if name.endswith("_id"):
126
+ fk_name = name[:-3] # Remove "_id" suffix
127
+ if fk_name in fk_descs:
128
+ cache = self.__dict__.get("_fk_cache")
129
+ if cache and fk_name in cache:
130
+ del cache[fk_name]
131
+ super().__setattr__(name, value)
132
+
133
+ def __init_subclass__(cls, **kwargs: Any) -> None: # noqa: ANN401
134
+ """Set up ORM field annotations before Pydantic processes the class.
135
+
136
+ This runs BEFORE Pydantic populates model_fields, so we add the _id
137
+ field annotations here so Pydantic creates proper FieldInfo for them.
138
+ """
139
+ # Call parent __init_subclass__ FIRST
140
+ super().__init_subclass__(**kwargs)
141
+
142
+ # Collect FK descriptors from class dict
143
+ if "fk_descriptors" not in cls.__dict__:
144
+ cls.fk_descriptors = {}
145
+
146
+ # Find all ForeignKeys in the class and add _id field annotations
147
+ # Make a copy of items to avoid modifying dict during iteration
148
+ class_items = list(cls.__dict__.items())
149
+ for name, value in class_items:
150
+ if isinstance(value, ForeignKey):
151
+ cls.fk_descriptors[name] = value
152
+ # Add _id field annotation so Pydantic creates a field for it
153
+ id_field_name = f"{name}_id"
154
+ if id_field_name not in cls.__annotations__:
155
+ if value.fk_info.null:
156
+ cls.__annotations__[id_field_name] = Optional[int]
157
+ # Nullable FKs default to None so they can be omitted
158
+ setattr(cls, id_field_name, None)
159
+ else:
160
+ cls.__annotations__[id_field_name] = int
161
+
162
+ # Remove FK field annotation so Pydantic doesn't treat it as
163
+ # a field to be copied to instance __dict__ (which breaks
164
+ # the descriptor protocol)
165
+ if name in cls.__annotations__:
166
+ del cls.__annotations__[name]
167
+
168
+ @classmethod
169
+ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: # noqa: ANN401
170
+ """Set up ORM FK metadata after Pydantic has created model_fields.
171
+
172
+ This runs AFTER Pydantic populates model_fields, so we can add FK
173
+ metadata to the _id fields that Pydantic created.
174
+ """
175
+ # Call parent __pydantic_init_subclass__ FIRST
176
+ super().__pydantic_init_subclass__(**kwargs)
177
+
178
+ # Process FK descriptors - add FK metadata, register relationships
179
+ cls._setup_orm_fields()
180
+
181
+ # Register model in global registry
182
+ ModelRegistry.register_model(cls)
183
+
184
+ @classmethod
185
+ def _setup_orm_fields(cls) -> None:
186
+ """Add FK metadata to _id fields and register FK relationships.
187
+
188
+ Called during class creation (after Pydantic setup) to:
189
+ 1. Add FK metadata to _id fields for constraint generation
190
+ 2. Register FK relationships in ModelRegistry
191
+ 3. Remove descriptor from model_fields so Pydantic doesn't validate it
192
+ """
193
+ # Get FK descriptors for this class
194
+ fk_descriptors_copy = cls.fk_descriptors.copy()
195
+
196
+ for field_name in fk_descriptors_copy:
197
+ descriptor = cls.fk_descriptors[field_name]
198
+
199
+ # Create _id field name
200
+ id_field_name = f"{field_name}_id"
201
+
202
+ # Get ForeignKeyInfo from descriptor
203
+ fk_info = descriptor.fk_info
204
+
205
+ # The _id field should exist (created by Pydantic from annotation)
206
+ # We need to add FK metadata for constraint generation
207
+ if id_field_name in cls.model_fields:
208
+ existing_field = cls.model_fields[id_field_name]
209
+
210
+ # Create ForeignKeyInfo with proper db_column
211
+ fk_info_for_field = type(fk_info)(
212
+ to_model=fk_info.to_model,
213
+ on_delete=fk_info.on_delete,
214
+ on_update=fk_info.on_update,
215
+ null=fk_info.null,
216
+ unique=fk_info.unique,
217
+ related_name=fk_info.related_name,
218
+ db_column=fk_info.db_column or id_field_name,
219
+ )
220
+
221
+ # Add FK metadata to existing field's json_schema_extra
222
+ # The ForeignKeyInfo is stored for _build_field_definitions()
223
+ if existing_field.json_schema_extra is None:
224
+ existing_field.json_schema_extra = {}
225
+ if isinstance(existing_field.json_schema_extra, dict):
226
+ # ForeignKeyInfo stored for _build_field_definitions
227
+ existing_field.json_schema_extra["foreign_key"] = (
228
+ fk_info_for_field # type: ignore[assignment]
229
+ )
230
+
231
+ # Register FK relationship
232
+ ModelRegistry.register_foreign_key(
233
+ from_model=cls,
234
+ to_model=fk_info.to_model,
235
+ fk_field=field_name,
236
+ on_delete=fk_info.on_delete,
237
+ related_name=descriptor.related_name,
238
+ )
239
+
240
+ # Remove descriptor from model_fields so Pydantic doesn't
241
+ # validate it
242
+ if field_name in cls.model_fields:
243
+ del cls.model_fields[field_name]
sqliter/orm/query.py ADDED
@@ -0,0 +1,221 @@
1
+ """Query builders for reverse relationships."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import (
6
+ TYPE_CHECKING,
7
+ Any,
8
+ Optional,
9
+ Protocol,
10
+ overload,
11
+ runtime_checkable,
12
+ )
13
+
14
+ if TYPE_CHECKING: # pragma: no cover
15
+ from sqliter.model.model import BaseDBModel
16
+ from sqliter.sqliter import SqliterDB
17
+
18
+
19
+ @runtime_checkable
20
+ class HasPKAndContext(Protocol):
21
+ """Protocol for model instances with pk and db_context."""
22
+
23
+ pk: Optional[int]
24
+ db_context: Optional[SqliterDB]
25
+
26
+
27
+ class ReverseQuery:
28
+ """Query builder for reverse relationships.
29
+
30
+ Delegates to QueryBuilder for actual SQL execution.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ instance: HasPKAndContext,
36
+ to_model: type[BaseDBModel],
37
+ fk_field: str,
38
+ db_context: Optional[SqliterDB],
39
+ ) -> None:
40
+ """Initialize reverse query.
41
+
42
+ Args:
43
+ instance: The model instance (e.g., Author)
44
+ to_model: The related model class (e.g., Book)
45
+ fk_field: The FK field name (e.g., "author")
46
+ db_context: Database connection for queries
47
+ """
48
+ self.instance = instance
49
+ self.to_model = to_model
50
+ self.fk_field = fk_field
51
+ self._db = db_context
52
+ self._filters: dict[str, Any] = {}
53
+ self._limit: Optional[int] = None
54
+ self._offset: Optional[int] = None
55
+
56
+ @property
57
+ def fk_value(self) -> Optional[int]:
58
+ """Get the FK ID value from the instance."""
59
+ return self.instance.pk
60
+
61
+ def filter(self, **kwargs: Any) -> ReverseQuery: # noqa: ANN401
62
+ """Store filters for later execution.
63
+
64
+ Args:
65
+ **kwargs: Filter criteria
66
+
67
+ Returns:
68
+ Self for chaining
69
+ """
70
+ self._filters.update(kwargs)
71
+ return self
72
+
73
+ def limit(self, count: int) -> ReverseQuery:
74
+ """Set limit on query results.
75
+
76
+ Args:
77
+ count: Maximum number of results
78
+
79
+ Returns:
80
+ Self for chaining
81
+ """
82
+ self._limit = count
83
+ return self
84
+
85
+ def offset(self, count: int) -> ReverseQuery:
86
+ """Set offset on query results.
87
+
88
+ Args:
89
+ count: Number of results to skip
90
+
91
+ Returns:
92
+ Self for chaining
93
+ """
94
+ self._offset = count
95
+ return self
96
+
97
+ def fetch_all(self) -> list[BaseDBModel]:
98
+ """Execute query using stored db_context.
99
+
100
+ Returns:
101
+ List of related model instances
102
+ """
103
+ fk_id = self.fk_value
104
+ if fk_id is None or self._db is None:
105
+ return []
106
+
107
+ # Build query with FK filter and additional filters
108
+ query = self._db.select(self.to_model).filter(
109
+ **{f"{self.fk_field}_id": fk_id}
110
+ )
111
+
112
+ # Apply additional filters
113
+ if self._filters:
114
+ query = query.filter(**self._filters)
115
+
116
+ # Apply limit and offset
117
+ if self._limit is not None:
118
+ query = query.limit(self._limit)
119
+ if self._offset is not None:
120
+ query = query.offset(self._offset)
121
+
122
+ return query.fetch_all()
123
+
124
+ def fetch_one(self) -> Optional[BaseDBModel]:
125
+ """Execute query and return single result.
126
+
127
+ Returns:
128
+ Related model instance or None
129
+ """
130
+ results = self.limit(1).fetch_all()
131
+ return results[0] if results else None
132
+
133
+ def count(self) -> int:
134
+ """Count related objects.
135
+
136
+ Returns:
137
+ Number of related objects
138
+ """
139
+ fk_id = self.fk_value
140
+ if fk_id is None or self._db is None:
141
+ return 0
142
+
143
+ # Build query with FK filter and additional filters
144
+ query = self._db.select(self.to_model).filter(
145
+ **{f"{self.fk_field}_id": fk_id}
146
+ )
147
+
148
+ # Apply additional filters
149
+ if self._filters:
150
+ query = query.filter(**self._filters)
151
+
152
+ return query.count()
153
+
154
+ def exists(self) -> bool:
155
+ """Check if any related objects exist.
156
+
157
+ Returns:
158
+ True if at least one related object exists
159
+ """
160
+ return self.count() > 0
161
+
162
+
163
+ class ReverseRelationship:
164
+ """Descriptor that returns ReverseQuery when accessed.
165
+
166
+ Added automatically to models during class creation by ForeignKeyDescriptor.
167
+ """
168
+
169
+ def __init__(
170
+ self, from_model: type[BaseDBModel], fk_field: str, related_name: str
171
+ ) -> None:
172
+ """Initialize reverse relationship descriptor.
173
+
174
+ Args:
175
+ from_model: The model with the FK field (e.g., Book)
176
+ fk_field: The FK field name (e.g., "author")
177
+ related_name: The name of this reverse relationship (e.g., "books")
178
+ """
179
+ self.from_model = from_model
180
+ self.fk_field = fk_field
181
+ self.related_name = related_name
182
+
183
+ @overload
184
+ def __get__(
185
+ self, instance: None, owner: type[object]
186
+ ) -> ReverseRelationship: ...
187
+
188
+ @overload
189
+ def __get__(
190
+ self, instance: HasPKAndContext, owner: type[object]
191
+ ) -> ReverseQuery: ...
192
+
193
+ def __get__(
194
+ self, instance: Optional[HasPKAndContext], owner: type[object]
195
+ ) -> ReverseRelationship | ReverseQuery:
196
+ """Return ReverseQuery when accessed on instance.
197
+
198
+ Args:
199
+ instance: Model instance (e.g., Author)
200
+ owner: Model class
201
+
202
+ Returns:
203
+ ReverseQuery for fetching related objects
204
+ """
205
+ if instance is None:
206
+ return self
207
+
208
+ return ReverseQuery(
209
+ instance=instance,
210
+ to_model=self.from_model,
211
+ fk_field=self.fk_field,
212
+ db_context=instance.db_context,
213
+ )
214
+
215
+ def __set__(self, instance: object, value: object) -> None:
216
+ """Prevent setting reverse relationships."""
217
+ msg = (
218
+ f"Cannot set reverse relationship '{self.related_name}'. "
219
+ f"Use the ForeignKey field on {self.from_model.__name__} instead."
220
+ )
221
+ raise AttributeError(msg)
@@ -0,0 +1,169 @@
1
+ """Model registry for ORM functionality.
2
+
3
+ Central registry for:
4
+ - Model classes by table name
5
+ - Foreign key relationships
6
+ - Pending reverse relationships
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, ClassVar, Optional
12
+
13
+
14
+ class ModelRegistry:
15
+ """Registry for ORM models and FK relationships.
16
+
17
+ Uses automatic setup via descriptor __set_name__ hook - no manual setup
18
+ required.
19
+ """
20
+
21
+ _models: ClassVar[dict[str, type]] = {}
22
+ _foreign_keys: ClassVar[dict[str, list[dict[str, Any]]]] = {}
23
+ _pending_reverses: ClassVar[dict[str, list[dict[str, Any]]]] = {}
24
+
25
+ @classmethod
26
+ def register_model(cls, model_class: type[Any]) -> None:
27
+ """Register a model class in the global registry.
28
+
29
+ Args:
30
+ model_class: The model class to register
31
+ """
32
+ table_name = model_class.get_table_name()
33
+ cls._models[table_name] = model_class
34
+
35
+ # Process any pending reverse relationships for this model
36
+ if table_name in cls._pending_reverses:
37
+ for pending in cls._pending_reverses[table_name]:
38
+ cls._add_reverse_relationship_now(**pending)
39
+ del cls._pending_reverses[table_name]
40
+
41
+ @classmethod
42
+ def register_foreign_key(
43
+ cls,
44
+ from_model: type[Any],
45
+ to_model: type[Any],
46
+ fk_field: str,
47
+ on_delete: str,
48
+ related_name: Optional[str] = None,
49
+ ) -> None:
50
+ """Register a FK relationship.
51
+
52
+ Args:
53
+ from_model: The model with the FK field
54
+ to_model: The model being referenced
55
+ fk_field: Name of the FK field
56
+ on_delete: Action when related object is deleted
57
+ related_name: Name for reverse relationship
58
+ """
59
+ from_table = from_model.get_table_name()
60
+
61
+ if from_table not in cls._foreign_keys:
62
+ cls._foreign_keys[from_table] = []
63
+
64
+ cls._foreign_keys[from_table].append(
65
+ {
66
+ "to_model": to_model,
67
+ "fk_field": fk_field,
68
+ "on_delete": on_delete,
69
+ "related_name": related_name,
70
+ }
71
+ )
72
+
73
+ @classmethod
74
+ def get_model(cls, table_name: str) -> Optional[type[Any]]:
75
+ """Get model by table name.
76
+
77
+ Args:
78
+ table_name: The table name to look up
79
+
80
+ Returns:
81
+ The model class or None if not found
82
+ """
83
+ return cls._models.get(table_name)
84
+
85
+ @classmethod
86
+ def get_foreign_keys(cls, table_name: str) -> list[dict[str, Any]]:
87
+ """Get FK relationships for a model.
88
+
89
+ Args:
90
+ table_name: The table name to look up
91
+
92
+ Returns:
93
+ List of FK relationship dictionaries
94
+ """
95
+ return cls._foreign_keys.get(table_name, [])
96
+
97
+ @classmethod
98
+ def add_reverse_relationship(
99
+ cls,
100
+ from_model: type[Any],
101
+ to_model: type[Any],
102
+ fk_field: str,
103
+ related_name: str,
104
+ ) -> None:
105
+ """Automatically add reverse relationship descriptor during class.
106
+
107
+ Called by ForeignKeyDescriptor.__set_name__ during class creation.
108
+ If to_model doesn't exist yet, stores as pending and adds when
109
+ to_model is registered.
110
+
111
+ Args:
112
+ from_model: The model with the FK field (e.g., Book)
113
+ to_model: The model being referenced (e.g., Author)
114
+ fk_field: Name of the FK field (e.g., "author")
115
+ related_name: Name for reverse relationship (e.g., "books")
116
+ """
117
+ to_table = to_model.get_table_name()
118
+
119
+ # Check if to_model has been registered yet
120
+ if to_table in cls._models:
121
+ # Model exists, add reverse relationship now
122
+ cls._add_reverse_relationship_now(
123
+ from_model, to_model, fk_field, related_name
124
+ )
125
+ else:
126
+ # Model doesn't exist yet, store as pending
127
+ if to_table not in cls._pending_reverses:
128
+ cls._pending_reverses[to_table] = []
129
+ cls._pending_reverses[to_table].append(
130
+ {
131
+ "from_model": from_model,
132
+ "to_model": to_model,
133
+ "fk_field": fk_field,
134
+ "related_name": related_name,
135
+ }
136
+ )
137
+
138
+ @classmethod
139
+ def _add_reverse_relationship_now(
140
+ cls,
141
+ from_model: type[Any],
142
+ to_model: type[Any],
143
+ fk_field: str,
144
+ related_name: str,
145
+ ) -> None:
146
+ """Add reverse relationship descriptor to model.
147
+
148
+ Args:
149
+ from_model: The model with the FK field (e.g., Book)
150
+ to_model: The model being referenced (e.g., Author)
151
+ fk_field: Name of the FK field (e.g., "author")
152
+ related_name: Name for reverse relationship (e.g., "books")
153
+ """
154
+ from sqliter.orm.query import ReverseRelationship # noqa: PLC0415
155
+
156
+ # Guard against overwriting existing attributes
157
+ if hasattr(to_model, related_name):
158
+ msg = (
159
+ f"Reverse relationship '{related_name}' already exists on "
160
+ f"{to_model.__name__}"
161
+ )
162
+ raise AttributeError(msg)
163
+
164
+ # Add reverse relationship descriptor to to_model
165
+ setattr(
166
+ to_model,
167
+ related_name,
168
+ ReverseRelationship(from_model, fk_field, related_name),
169
+ )