sqliter-py 0.9.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 (44) hide show
  1. sqliter/constants.py +4 -3
  2. sqliter/exceptions.py +43 -0
  3. sqliter/model/__init__.py +38 -3
  4. sqliter/model/foreign_key.py +153 -0
  5. sqliter/model/model.py +42 -3
  6. sqliter/model/unique.py +20 -11
  7. sqliter/orm/__init__.py +16 -0
  8. sqliter/orm/fields.py +412 -0
  9. sqliter/orm/foreign_key.py +8 -0
  10. sqliter/orm/model.py +243 -0
  11. sqliter/orm/query.py +221 -0
  12. sqliter/orm/registry.py +169 -0
  13. sqliter/query/query.py +720 -69
  14. sqliter/sqliter.py +533 -76
  15. sqliter/tui/__init__.py +62 -0
  16. sqliter/tui/__main__.py +6 -0
  17. sqliter/tui/app.py +179 -0
  18. sqliter/tui/demos/__init__.py +96 -0
  19. sqliter/tui/demos/base.py +114 -0
  20. sqliter/tui/demos/caching.py +283 -0
  21. sqliter/tui/demos/connection.py +150 -0
  22. sqliter/tui/demos/constraints.py +211 -0
  23. sqliter/tui/demos/crud.py +154 -0
  24. sqliter/tui/demos/errors.py +231 -0
  25. sqliter/tui/demos/field_selection.py +150 -0
  26. sqliter/tui/demos/filters.py +389 -0
  27. sqliter/tui/demos/models.py +248 -0
  28. sqliter/tui/demos/ordering.py +156 -0
  29. sqliter/tui/demos/orm.py +460 -0
  30. sqliter/tui/demos/results.py +241 -0
  31. sqliter/tui/demos/string_filters.py +210 -0
  32. sqliter/tui/demos/timestamps.py +126 -0
  33. sqliter/tui/demos/transactions.py +177 -0
  34. sqliter/tui/runner.py +116 -0
  35. sqliter/tui/styles/app.tcss +130 -0
  36. sqliter/tui/widgets/__init__.py +7 -0
  37. sqliter/tui/widgets/code_display.py +81 -0
  38. sqliter/tui/widgets/demo_list.py +65 -0
  39. sqliter/tui/widgets/output_display.py +92 -0
  40. {sqliter_py-0.9.0.dist-info → sqliter_py-0.16.0.dist-info}/METADATA +27 -11
  41. sqliter_py-0.16.0.dist-info/RECORD +47 -0
  42. {sqliter_py-0.9.0.dist-info → sqliter_py-0.16.0.dist-info}/WHEEL +2 -2
  43. sqliter_py-0.16.0.dist-info/entry_points.txt +3 -0
  44. sqliter_py-0.9.0.dist-info/RECORD +0 -14
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
- "__startswith": "LIKE",
25
- "__endswith": "LIKE",
26
- "__contains": "LIKE",
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
@@ -166,3 +166,46 @@ class InvalidIndexError(SqliterError):
166
166
  invalid_fields_str = ", ".join(invalid_fields)
167
167
  # Pass the formatted message to the parent class
168
168
  super().__init__(model_class, invalid_fields_str)
169
+
170
+
171
+ class ForeignKeyError(SqliterError):
172
+ """Base exception for foreign key related errors."""
173
+
174
+ message_template = "Foreign key error: {}"
175
+
176
+
177
+ class ForeignKeyConstraintError(ForeignKeyError):
178
+ """Raised when a foreign key constraint is violated.
179
+
180
+ This error occurs when attempting to insert/update a record with a
181
+ foreign key value that doesn't exist in the referenced table, or
182
+ when attempting to delete a record that is still referenced.
183
+ """
184
+
185
+ message_template = (
186
+ "Foreign key constraint violation: Cannot {} record - "
187
+ "referenced record {}"
188
+ )
189
+
190
+
191
+ class InvalidForeignKeyError(ForeignKeyError):
192
+ """Raised when an invalid foreign key configuration is detected.
193
+
194
+ This error occurs when defining a foreign key with invalid parameters,
195
+ such as using SET NULL without null=True.
196
+ """
197
+
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
+ )
sqliter/model/__init__.py CHANGED
@@ -1,11 +1,46 @@
1
1
  """This module provides the base model class for SQLiter database models.
2
2
 
3
3
  It exports the BaseDBModel class, which is used to define database
4
- models in SQLiter applications, and the Unique class, which is used to
4
+ models in SQLiter applications, and the unique function, which is used to
5
5
  define unique constraints on model fields.
6
6
  """
7
7
 
8
+ import warnings
9
+ from typing import Any
10
+
11
+ from typing_extensions import deprecated
12
+
13
+ from .foreign_key import ForeignKey, ForeignKeyInfo, get_foreign_key_info
8
14
  from .model import BaseDBModel, SerializableField
9
- from .unique import Unique
15
+ from .unique import unique
16
+
17
+
18
+ @deprecated("Use 'unique' instead. Will be removed in a future version.")
19
+ def Unique(default: Any = ..., **kwargs: Any) -> Any: # noqa: ANN401, N802
20
+ """Deprecated: Use 'unique' instead. Will be removed in a future version.
21
+
22
+ Args:
23
+ default: The default value for the field.
24
+ **kwargs: Additional keyword arguments to pass to Field.
25
+
26
+ Returns:
27
+ A Field with unique metadata attached.
28
+ """
29
+ warnings.warn(
30
+ "Unique is deprecated and will be removed in a future version. "
31
+ "Use 'unique' instead.",
32
+ DeprecationWarning,
33
+ stacklevel=2,
34
+ )
35
+ return unique(default=default, **kwargs)
36
+
10
37
 
11
- __all__ = ["BaseDBModel", "SerializableField", "Unique"]
38
+ __all__ = [
39
+ "BaseDBModel",
40
+ "ForeignKey",
41
+ "ForeignKeyInfo",
42
+ "SerializableField",
43
+ "Unique",
44
+ "get_foreign_key_info",
45
+ "unique",
46
+ ]
@@ -0,0 +1,153 @@
1
+ """Foreign key support for SQLiter ORM.
2
+
3
+ This module provides the ForeignKey factory function and ForeignKeyInfo
4
+ dataclass for defining foreign key relationships between models.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING, Any, Literal, Optional
11
+
12
+ from pydantic import Field
13
+
14
+ from sqliter.exceptions import InvalidForeignKeyError
15
+
16
+ if TYPE_CHECKING: # pragma: no cover
17
+ from pydantic.fields import FieldInfo
18
+
19
+ from sqliter.model.model import BaseDBModel
20
+
21
+ # Type alias for foreign key actions
22
+ FKAction = Literal["CASCADE", "SET NULL", "RESTRICT", "NO ACTION"]
23
+
24
+
25
+ @dataclass
26
+ class ForeignKeyInfo:
27
+ """Metadata about a foreign key relationship.
28
+
29
+ Attributes:
30
+ to_model: The target model class that this foreign key references.
31
+ on_delete: Action to take when the referenced record is deleted.
32
+ on_update: Action to take when the referenced record's PK is updated.
33
+ null: Whether the foreign key field can be NULL.
34
+ unique: Whether the foreign key field must be unique (one-to-one).
35
+ related_name: Optional name for the reverse relationship (Phase 2).
36
+ db_column: Optional custom column name in the database.
37
+ """
38
+
39
+ to_model: type[BaseDBModel]
40
+ on_delete: FKAction
41
+ on_update: FKAction
42
+ null: bool
43
+ unique: bool
44
+ related_name: Optional[str]
45
+ db_column: Optional[str]
46
+
47
+
48
+ def ForeignKey( # noqa: N802, PLR0913
49
+ to: type[BaseDBModel],
50
+ *,
51
+ on_delete: FKAction = "RESTRICT",
52
+ on_update: FKAction = "RESTRICT",
53
+ null: bool = False,
54
+ unique: bool = False,
55
+ related_name: Optional[str] = None,
56
+ db_column: Optional[str] = None,
57
+ default: Any = ..., # noqa: ANN401
58
+ **kwargs: Any, # noqa: ANN401
59
+ ) -> Any: # noqa: ANN401
60
+ """Create a foreign key field.
61
+
62
+ This function creates a Pydantic Field with foreign key metadata stored
63
+ in json_schema_extra. In Phase 1, this provides FK constraint support.
64
+ Phase 2 will add descriptor support for `book.author` style access.
65
+
66
+ Args:
67
+ to: The target model class that this foreign key references.
68
+ on_delete: Action when referenced record is deleted.
69
+ - CASCADE: Delete this record too.
70
+ - SET NULL: Set this field to NULL (requires null=True).
71
+ - RESTRICT: Prevent deletion if references exist (default).
72
+ - NO ACTION: Similar to RESTRICT in SQLite.
73
+ on_update: Action when referenced record's PK is updated.
74
+ - CASCADE: Update this field to the new PK value.
75
+ - SET NULL: Set this field to NULL (requires null=True).
76
+ - RESTRICT: Prevent update if references exist (default).
77
+ - NO ACTION: Similar to RESTRICT in SQLite.
78
+ null: Whether the foreign key field can be NULL. Default is False.
79
+ unique: Whether the field must be unique (creates one-to-one
80
+ relationship). Default is False.
81
+ related_name: Optional name for the reverse relationship. Reserved
82
+ for Phase 2 implementation.
83
+ db_column: Optional custom column name. If not specified, defaults
84
+ to `{field_name}_id`.
85
+ default: Default value for the field. If null=True and no default
86
+ is provided, defaults to None.
87
+ **kwargs: Additional keyword arguments passed to Pydantic Field.
88
+
89
+ Returns:
90
+ A Pydantic Field with foreign key metadata.
91
+
92
+ Raises:
93
+ InvalidForeignKeyError: If SET NULL action is used without null=True.
94
+
95
+ Example:
96
+ >>> class Book(BaseDBModel):
97
+ ... title: str
98
+ ... author_id: int = ForeignKey(Author, on_delete="CASCADE")
99
+ """
100
+ # Validate SET NULL requires null=True
101
+ if on_delete == "SET NULL" and not null:
102
+ msg = "on_delete='SET NULL' requires null=True"
103
+ raise InvalidForeignKeyError(msg)
104
+ if on_update == "SET NULL" and not null:
105
+ msg = "on_update='SET NULL' requires null=True"
106
+ raise InvalidForeignKeyError(msg)
107
+
108
+ # Handle existing json_schema_extra
109
+ existing_extra = kwargs.pop("json_schema_extra", {})
110
+ if not isinstance(existing_extra, dict):
111
+ existing_extra = {}
112
+
113
+ # Create ForeignKeyInfo metadata
114
+ fk_info = ForeignKeyInfo(
115
+ to_model=to,
116
+ on_delete=on_delete,
117
+ on_update=on_update,
118
+ null=null,
119
+ unique=unique,
120
+ related_name=related_name,
121
+ db_column=db_column,
122
+ )
123
+
124
+ # Store FK metadata in json_schema_extra
125
+ existing_extra["foreign_key"] = fk_info
126
+ if unique:
127
+ existing_extra["unique"] = True
128
+
129
+ # Set default value
130
+ if default is ... and "default_factory" not in kwargs:
131
+ default = None if null else ...
132
+
133
+ return Field(default=default, json_schema_extra=existing_extra, **kwargs)
134
+
135
+
136
+ def get_foreign_key_info(field_info: FieldInfo) -> Optional[ForeignKeyInfo]:
137
+ """Extract ForeignKeyInfo from a field if it's a foreign key.
138
+
139
+ Args:
140
+ field_info: The Pydantic FieldInfo to examine.
141
+
142
+ Returns:
143
+ The ForeignKeyInfo if the field is a foreign key, None otherwise.
144
+ """
145
+ if not hasattr(field_info, "json_schema_extra"):
146
+ return None
147
+ extra = field_info.json_schema_extra
148
+ if not isinstance(extra, dict):
149
+ return None
150
+ fk_info = extra.get("foreign_key")
151
+ if isinstance(fk_info, ForeignKeyInfo):
152
+ return fk_info
153
+ return None
sqliter/model/model.py CHANGED
@@ -123,6 +123,32 @@ class BaseDBModel(BaseModel):
123
123
 
124
124
  return cast("Self", cls.model_construct(**converted_obj))
125
125
 
126
+ @staticmethod
127
+ def _validate_table_name(table_name: str) -> str:
128
+ """Validate that a table name contains only safe characters.
129
+
130
+ Table names must contain only alphanumeric characters and underscores,
131
+ and must start with a letter or underscore. This prevents SQL injection
132
+ through malicious table names.
133
+
134
+ Args:
135
+ table_name: The table name to validate.
136
+
137
+ Returns:
138
+ The validated table name.
139
+
140
+ Raises:
141
+ ValueError: If the table name contains invalid characters.
142
+ """
143
+ if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", table_name):
144
+ msg = (
145
+ f"Invalid table name '{table_name}'. "
146
+ "Table names must start with a letter or underscore and "
147
+ "contain only letters, numbers, and underscores."
148
+ )
149
+ raise ValueError(msg)
150
+ return table_name
151
+
126
152
  @classmethod
127
153
  def get_table_name(cls) -> str:
128
154
  """Get the database table name for the model.
@@ -130,12 +156,22 @@ class BaseDBModel(BaseModel):
130
156
  This method determines the table name based on the Meta configuration
131
157
  or derives it from the class name if not explicitly set.
132
158
 
159
+ When deriving the table name automatically, the class name is converted
160
+ to snake_case and pluralized. If the `inflect` library is installed,
161
+ it provides grammatically correct pluralization (e.g., "person" becomes
162
+ "people", "category" becomes "categories"). Otherwise, a simple "s"
163
+ suffix is added if the name doesn't already end in "s".
164
+
133
165
  Returns:
134
166
  The name of the database table for this model.
167
+
168
+ Raises:
169
+ ValueError: If the table name contains invalid characters.
135
170
  """
136
171
  table_name: str | None = getattr(cls.Meta, "table_name", None)
137
172
  if table_name is not None:
138
- return table_name
173
+ # Validate custom table names
174
+ return cls._validate_table_name(table_name)
139
175
 
140
176
  # Get class name and remove 'Model' suffix if present
141
177
  class_name = cls.__name__.removesuffix("Model")
@@ -148,15 +184,18 @@ class BaseDBModel(BaseModel):
148
184
  import inflect # noqa: PLC0415
149
185
 
150
186
  p = inflect.engine()
151
- return p.plural(snake_case_name)
187
+ table_name = p.plural(snake_case_name)
152
188
  except ImportError:
153
189
  # Fallback to simple pluralization by adding 's'
154
- return (
190
+ table_name = (
155
191
  snake_case_name
156
192
  if snake_case_name.endswith("s")
157
193
  else snake_case_name + "s"
158
194
  )
159
195
 
196
+ # Validate auto-generated table names (should always pass)
197
+ return cls._validate_table_name(table_name)
198
+
160
199
  @classmethod
161
200
  def get_primary_key(cls) -> str:
162
201
  """Returns the mandatory primary key, always 'pk'."""
sqliter/model/unique.py CHANGED
@@ -2,18 +2,27 @@
2
2
 
3
3
  from typing import Any
4
4
 
5
- from pydantic.fields import FieldInfo
5
+ from pydantic import Field
6
6
 
7
7
 
8
- class Unique(FieldInfo):
9
- """A custom field type for unique constraints in SQLiter."""
8
+ def unique(default: Any = ..., **kwargs: Any) -> Any: # noqa: ANN401
9
+ """A custom field type for unique constraints in SQLiter.
10
10
 
11
- def __init__(self, default: Any = ..., **kwargs: Any) -> None: # noqa: ANN401
12
- """Initialize a Unique field.
11
+ Args:
12
+ default: The default value for the field.
13
+ **kwargs: Additional keyword arguments to pass to Field.
13
14
 
14
- Args:
15
- default: The default value for the field.
16
- **kwargs: Additional keyword arguments to pass to FieldInfo.
17
- """
18
- super().__init__(default=default, **kwargs)
19
- self.unique = True
15
+ Returns:
16
+ A Field with unique metadata attached.
17
+ """
18
+ # Extract any existing json_schema_extra from kwargs
19
+ existing_extra = kwargs.pop("json_schema_extra", {})
20
+
21
+ # Ensure it's a dict
22
+ if not isinstance(existing_extra, dict):
23
+ existing_extra = {}
24
+
25
+ # Add our unique marker to json_schema_extra
26
+ existing_extra["unique"] = True
27
+
28
+ return Field(default=default, json_schema_extra=existing_extra, **kwargs)
@@ -0,0 +1,16 @@
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.model import BaseDBModel
14
+ from sqliter.orm.registry import ModelRegistry
15
+
16
+ __all__ = ["BaseDBModel", "ForeignKey", "ModelRegistry"]