tapestry-orm 0.0.3__tar.gz → 0.0.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tapestry-orm
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: A prototype ORM for SurrealDB
5
5
  Author-email: timothee@obrecht.xyz
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tapestry-orm"
3
- version = "0.0.3"
3
+ version = "0.0.4"
4
4
  description = "A prototype ORM for SurrealDB"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,9 +1,9 @@
1
- __all__ = ["Base", "Text", "create_engine", "create_engine_context", "Engine", "Reference", "Node", "Edge", "Q"]
1
+ __all__ = ["Base", "Text", "create_engine", "create_engine_context", "Engine", "Reference", "Indexed", "Node", "Edge", "Q"]
2
2
 
3
3
  from .query import Q
4
4
  from .base import Base
5
5
  from .node import Node
6
6
  from .edge import Edge
7
7
  from .tokenizer import Text
8
- from .table import Reference
8
+ from .table import Reference, Indexed
9
9
  from .engine import create_engine, create_engine_context, Engine
@@ -17,6 +17,26 @@ from .utils import flatten_type
17
17
  from .tokenizer import Text, Tokenizer, FrenchTokenizer
18
18
 
19
19
 
20
+ @dataclass(frozen=True)
21
+ class Indexed:
22
+ """Opt-in index annotation for model fields.
23
+
24
+ Use with ``typing.Annotated`` to request a database index on a field::
25
+
26
+ from typing import Annotated
27
+ from tapestry import Node, Indexed
28
+
29
+ class Person(Node):
30
+ ext_id: Annotated[str | None, Indexed(unique=True)] = None
31
+ party: Annotated[str | None, Indexed()] = None
32
+
33
+ Attributes:
34
+ unique: If True, the index enforces uniqueness.
35
+ """
36
+
37
+ unique: bool = False
38
+
39
+
20
40
  @dataclass(frozen=True)
21
41
  class Link:
22
42
  _in: type[Any]
@@ -86,11 +106,18 @@ class Table:
86
106
  relation: Link | None = None
87
107
  index: dict[str, str] = field(default_factory=dict)
88
108
  tokenizers: tuple[Tokenizer, ...] = ()
109
+ record_link_fields: tuple[str, ...] = ()
110
+ indexed_fields: dict[str, bool] = field(default_factory=dict)
89
111
 
90
112
  def __post_init__(self):
91
113
  indices = {}
92
114
  tokenizers = []
115
+ record_links = []
116
+ opt_in: dict[str, bool] = {}
117
+
93
118
  for field in self.fields:
119
+ if field.name in ("id", "is_stub_"):
120
+ continue
94
121
  if field.field_type:
95
122
  for tp in tuple(flatten_type(field.field_type, base_type=Text)):
96
123
  if inspect.isclass(tp) and issubclass(tp, Text):
@@ -105,9 +132,44 @@ class Table:
105
132
  if not args or not issubclass(args[0], self.base_class):
106
133
  raise TypeError("References must point to a Node class")
107
134
 
135
+ # Detect record link fields (foreign keys)
136
+ if self._is_record_link_type(field.field_type):
137
+ record_links.append(field.name)
138
+
139
+ # Detect opt-in Indexed metadata from Pydantic FieldInfo
140
+ if hasattr(self.model_class, "model_fields"):
141
+ for fname, finfo in self.model_class.model_fields.items():
142
+ if hasattr(finfo, "metadata") and finfo.metadata:
143
+ for meta in finfo.metadata:
144
+ if isinstance(meta, Indexed):
145
+ opt_in[fname] = meta.unique
146
+ break
147
+
108
148
  object.__setattr__(self, "index", indices)
109
149
  object.__setattr__(self, "tokenizers", tuple(tokenizers))
110
150
  object.__setattr__(self, "name_to_type", {f.name: f.field_type for f in self.fields})
151
+ object.__setattr__(self, "record_link_fields", tuple(record_links))
152
+ object.__setattr__(self, "indexed_fields", opt_in)
153
+
154
+ def _is_record_link_type(self, tp: Any) -> bool:
155
+ """Check whether *tp* resolves to a direct record link.
156
+
157
+ Returns ``True`` when the type is (or contains via Union) a subclass
158
+ of ``self.base_class``, but does **not** recurse into generic
159
+ containers like ``list[...]`` or ``set[...]``.
160
+ """
161
+ if tp is type(None):
162
+ return False
163
+ try:
164
+ if inspect.isclass(tp) and issubclass(tp, self.base_class):
165
+ return True
166
+ except TypeError:
167
+ return False
168
+ origin = get_origin(tp)
169
+ args = get_args(tp)
170
+ if origin is types.UnionType or (origin is not None and hasattr(origin, "__name__") and origin.__name__ == "UnionType"):
171
+ return any(self._is_record_link_type(a) for a in (args or ()))
172
+ return False
111
173
 
112
174
  # -------------------- helpers to map python type -> surreal type ----------
113
175
 
@@ -438,7 +500,7 @@ class Table:
438
500
  return getattr(tp, "__name__", str(tp)).lower()
439
501
 
440
502
  def generate_table_sql(self) -> str:
441
- stmts = []
503
+ stmts: list[str] = []
442
504
  if self.relation:
443
505
  in_tbl = self._pytype_to_surreal("_in", self.relation._in)
444
506
  out_tbl = self._pytype_to_surreal("_out", self.relation._out)
@@ -458,8 +520,26 @@ class Table:
458
520
  surreal_t = self._pytype_to_surreal(ft.name, ft.field_type)
459
521
  stmts.append(f"DEFINE FIELD {self._quote_ident(ft.name)} ON {self.name} TYPE {surreal_t};")
460
522
 
461
- define_index = [
462
- f"DEFINE INDEX {field}_search ON TABLE {self.name} FIELDS {field} FULLTEXT ANALYZER {analyzer} BM25 HIGHLIGHTS;"
463
- for field, analyzer in self.index.items()
464
- ]
465
- return "\n".join(stmts) + "\n" + "\n".join(define_index)
523
+ # -- Indexes -----------------------------------------------------------
524
+
525
+ # Auto-index edge in/out fields
526
+ if self.relation is not None:
527
+ stmts.append(f"DEFINE INDEX idx_{self.name}_in ON TABLE {self.name} FIELDS in;")
528
+ stmts.append(f"DEFINE INDEX idx_{self.name}_out ON TABLE {self.name} FIELDS out;")
529
+
530
+ # Auto-index record link fields (foreign keys)
531
+ for field_name in self.record_link_fields:
532
+ stmts.append(f"DEFINE INDEX idx_{self.name}_{field_name} ON TABLE {self.name} FIELDS {field_name};")
533
+
534
+ # Opt-in indexed fields
535
+ for field_name, unique in self.indexed_fields.items():
536
+ unique_suffix = " UNIQUE" if unique else ""
537
+ stmts.append(f"DEFINE INDEX idx_{self.name}_{field_name} ON TABLE {self.name} FIELDS {field_name}{unique_suffix};")
538
+
539
+ # Fulltext search indexes (preserve existing naming convention)
540
+ for field, analyzer in self.index.items():
541
+ stmts.append(
542
+ f"DEFINE INDEX {field}_search ON TABLE {self.name} FIELDS {field} FULLTEXT ANALYZER {analyzer} BM25 HIGHLIGHTS;"
543
+ )
544
+
545
+ return "\n".join(stmts)
File without changes