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.
Files changed (43) hide show
  1. sqliter/constants.py +4 -3
  2. sqliter/exceptions.py +29 -0
  3. sqliter/helpers.py +27 -0
  4. sqliter/model/model.py +21 -4
  5. sqliter/orm/__init__.py +17 -0
  6. sqliter/orm/fields.py +412 -0
  7. sqliter/orm/foreign_key.py +8 -0
  8. sqliter/orm/m2m.py +784 -0
  9. sqliter/orm/model.py +308 -0
  10. sqliter/orm/query.py +221 -0
  11. sqliter/orm/registry.py +440 -0
  12. sqliter/query/query.py +573 -51
  13. sqliter/sqliter.py +182 -47
  14. sqliter/tui/__init__.py +62 -0
  15. sqliter/tui/__main__.py +6 -0
  16. sqliter/tui/app.py +179 -0
  17. sqliter/tui/demos/__init__.py +96 -0
  18. sqliter/tui/demos/base.py +114 -0
  19. sqliter/tui/demos/caching.py +283 -0
  20. sqliter/tui/demos/connection.py +150 -0
  21. sqliter/tui/demos/constraints.py +211 -0
  22. sqliter/tui/demos/crud.py +154 -0
  23. sqliter/tui/demos/errors.py +231 -0
  24. sqliter/tui/demos/field_selection.py +150 -0
  25. sqliter/tui/demos/filters.py +389 -0
  26. sqliter/tui/demos/models.py +248 -0
  27. sqliter/tui/demos/ordering.py +156 -0
  28. sqliter/tui/demos/orm.py +537 -0
  29. sqliter/tui/demos/results.py +241 -0
  30. sqliter/tui/demos/string_filters.py +210 -0
  31. sqliter/tui/demos/timestamps.py +126 -0
  32. sqliter/tui/demos/transactions.py +177 -0
  33. sqliter/tui/runner.py +116 -0
  34. sqliter/tui/styles/app.tcss +130 -0
  35. sqliter/tui/widgets/__init__.py +7 -0
  36. sqliter/tui/widgets/code_display.py +81 -0
  37. sqliter/tui/widgets/demo_list.py +65 -0
  38. sqliter/tui/widgets/output_display.py +92 -0
  39. {sqliter_py-0.12.0.dist-info → sqliter_py-0.17.0.dist-info}/METADATA +28 -14
  40. sqliter_py-0.17.0.dist-info/RECORD +48 -0
  41. {sqliter_py-0.12.0.dist-info → sqliter_py-0.17.0.dist-info}/WHEEL +2 -2
  42. sqliter_py-0.17.0.dist-info/entry_points.txt +3 -0
  43. 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
- "__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
@@ -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 from_unix_timestamp, to_unix_timestamp
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
- return table_name
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
- return p.plural(snake_case_name)
165
+ table_name = p.plural(snake_case_name)
152
166
  except ImportError:
153
167
  # Fallback to simple pluralization by adding 's'
154
- return (
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'."""
@@ -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
@@ -0,0 +1,8 @@
1
+ """ForeignKey for ORM mode.
2
+
3
+ Re-exports the ForeignKey class from fields module.
4
+ """
5
+
6
+ from sqliter.orm.fields import ForeignKey
7
+
8
+ __all__ = ["ForeignKey"]