oxyde-postgres 0.1.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 (66) hide show
  1. oxyde/__init__.py +151 -0
  2. oxyde/_msgpack.py +3 -0
  3. oxyde/cli/__init__.py +7 -0
  4. oxyde/cli/app.py +107 -0
  5. oxyde/cli/codegen.py +66 -0
  6. oxyde/cli/config.py +84 -0
  7. oxyde/cli/migrations.py +536 -0
  8. oxyde/codegen/__init__.py +15 -0
  9. oxyde/codegen/stub_generator.py +1229 -0
  10. oxyde/core/__init__.py +90 -0
  11. oxyde/core/column_types.py +367 -0
  12. oxyde/core/ir.py +380 -0
  13. oxyde/core/types.py +69 -0
  14. oxyde/core/wrapper.py +242 -0
  15. oxyde/db/__init__.py +230 -0
  16. oxyde/db/pool.py +305 -0
  17. oxyde/db/registry.py +113 -0
  18. oxyde/db/schema.py +51 -0
  19. oxyde/db/transaction.py +392 -0
  20. oxyde/exceptions.py +89 -0
  21. oxyde/migrations/__init__.py +49 -0
  22. oxyde/migrations/config.py +187 -0
  23. oxyde/migrations/context.py +598 -0
  24. oxyde/migrations/executor.py +456 -0
  25. oxyde/migrations/extract.py +316 -0
  26. oxyde/migrations/generator.py +534 -0
  27. oxyde/migrations/replay.py +367 -0
  28. oxyde/migrations/squash.py +109 -0
  29. oxyde/migrations/tracker.py +162 -0
  30. oxyde/migrations/utils.py +145 -0
  31. oxyde/models/__init__.py +83 -0
  32. oxyde/models/base.py +871 -0
  33. oxyde/models/decorators.py +111 -0
  34. oxyde/models/field.py +204 -0
  35. oxyde/models/lookups.py +667 -0
  36. oxyde/models/metadata.py +174 -0
  37. oxyde/models/registry.py +170 -0
  38. oxyde/models/serializers.py +144 -0
  39. oxyde/models/utils.py +116 -0
  40. oxyde/py.typed +0 -0
  41. oxyde/queries/__init__.py +79 -0
  42. oxyde/queries/aggregates.py +145 -0
  43. oxyde/queries/base.py +249 -0
  44. oxyde/queries/conditions.py +92 -0
  45. oxyde/queries/expressions.py +171 -0
  46. oxyde/queries/insert.py +124 -0
  47. oxyde/queries/joins.py +66 -0
  48. oxyde/queries/manager.py +409 -0
  49. oxyde/queries/mixins/__init__.py +68 -0
  50. oxyde/queries/mixins/aggregation.py +335 -0
  51. oxyde/queries/mixins/debug.py +154 -0
  52. oxyde/queries/mixins/execution.py +701 -0
  53. oxyde/queries/mixins/filtering.py +144 -0
  54. oxyde/queries/mixins/joining.py +160 -0
  55. oxyde/queries/mixins/mutation.py +508 -0
  56. oxyde/queries/mixins/pagination.py +145 -0
  57. oxyde/queries/q.py +250 -0
  58. oxyde/queries/raw.py +114 -0
  59. oxyde/queries/select.py +345 -0
  60. oxyde/queries/typed.py +213 -0
  61. oxyde_postgres-0.1.0.dist-info/METADATA +141 -0
  62. oxyde_postgres-0.1.0.dist-info/RECORD +66 -0
  63. oxyde_postgres-0.1.0.dist-info/WHEEL +5 -0
  64. oxyde_postgres-0.1.0.dist-info/entry_points.txt +2 -0
  65. oxyde_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
  66. oxyde_postgres-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,174 @@
1
+ """Dataclasses for storing parsed model and field metadata.
2
+
3
+ This module defines the internal data structures used to store metadata
4
+ extracted from Model class definitions. These are populated by
5
+ Model._parse_field_tags() during model initialization.
6
+
7
+ Hierarchy:
8
+ ModelMeta (table-level)
9
+ ├── table_name: str
10
+ ├── schema: str (PostgreSQL schema)
11
+ ├── indexes: list[Index]
12
+ ├── constraints: list[Check]
13
+ ├── field_metadata: dict[str, ColumnMeta]
14
+ └── relations: dict[str, RelationInfo]
15
+
16
+ ColumnMeta (field-level)
17
+ ├── name: Field name in Python
18
+ ├── db_column: Column name in database
19
+ ├── python_type: Python type (str, int, datetime, etc.)
20
+ ├── nullable: bool
21
+ ├── primary_key: bool
22
+ ├── index/unique: bool
23
+ ├── foreign_key: ForeignKeyInfo | None
24
+ └── db_default: SQL DEFAULT expression
25
+
26
+ ForeignKeyInfo (FK constraint)
27
+ ├── target: Target model key ("module.ClassName")
28
+ ├── column_name: Column in this table ("author_id")
29
+ ├── target_field: Target column ("id")
30
+ └── on_delete/on_update: CASCADE, RESTRICT, etc.
31
+
32
+ RelationInfo (virtual relation, not stored in DB)
33
+ ├── kind: "one_to_many" | "many_to_many"
34
+ ├── target: Target model name
35
+ ├── remote_field: FK field on related model
36
+ └── through: M2M junction table
37
+
38
+ Usage:
39
+ # Access via model's _db_meta attribute
40
+ User._db_meta.table_name # "users"
41
+ User._db_meta.field_metadata["email"].unique # True
42
+ User._db_meta.field_metadata["author"].foreign_key.target # "myapp.Author"
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ from dataclasses import dataclass
48
+ from dataclasses import field as dataclass_field
49
+ from typing import Any
50
+
51
+ from pydantic.fields import PydanticUndefined
52
+
53
+ from oxyde.core.column_types import compute_column_type
54
+ from oxyde.models.decorators import Check, Index
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class ForeignKeyInfo:
59
+ """Metadata describing a foreign key relationship."""
60
+
61
+ target: (
62
+ str # Target model key (e.g., "__main__.User") or table name for primitive FK
63
+ )
64
+ column_name: str # Column name in this table (e.g., "author_id")
65
+ target_field: str # Target field name in referenced table (e.g., "id", "uuid")
66
+ nullable: bool
67
+ on_delete: str | None = None
68
+ on_update: str | None = None
69
+
70
+
71
+ @dataclass(slots=True)
72
+ class ColumnMeta:
73
+ """Metadata captured for a single model field."""
74
+
75
+ name: str
76
+ db_column: str
77
+ python_type: Any
78
+ nullable: bool
79
+ db_type: str | None = None
80
+ index: bool = False
81
+ index_name: str | None = None
82
+ index_method: str | None = None # btree, hash, gin, gist
83
+ unique: bool = False
84
+ primary_key: bool = False
85
+ comment: str | None = None
86
+ default: Any = PydanticUndefined
87
+ default_factory: Any | None = None
88
+ db_default: str | None = None # Raw SQL DEFAULT expression
89
+ max_length: int | None = None
90
+ max_digits: int | None = None
91
+ decimal_places: int | None = None
92
+ foreign_key: ForeignKeyInfo | None = None
93
+ checks: list[str] = dataclass_field(default_factory=list)
94
+ extra: dict[str, Any] = dataclass_field(default_factory=dict)
95
+
96
+
97
+ @dataclass(slots=True)
98
+ class RelationInfo:
99
+ """Metadata describing a relationship between models."""
100
+
101
+ name: str
102
+ kind: str # one_to_many, many_to_many
103
+ target: str
104
+ remote_field: str | None = None
105
+ through: str | None = None
106
+ back_populates: str | None = None
107
+
108
+
109
+ @dataclass(slots=True)
110
+ class ModelMeta:
111
+ """Container for model metadata."""
112
+
113
+ table_name: str | None = None
114
+ schema: str | None = None
115
+ comment: str | None = None
116
+ indexes: list[Index] = dataclass_field(default_factory=list)
117
+ unique_together: list[tuple[str, ...]] = dataclass_field(default_factory=list)
118
+ constraints: list[Check] = dataclass_field(default_factory=list)
119
+ field_metadata: dict[str, ColumnMeta] = dataclass_field(default_factory=dict)
120
+ relations: dict[str, RelationInfo] = dataclass_field(default_factory=dict)
121
+ extra: dict[str, Any] = dataclass_field(default_factory=dict)
122
+ # Cached IR type hints for Rust decoding (computed at finalization)
123
+ column_types: dict[str, dict] | None = None
124
+ # Column db_type overrides for PostgreSQL array casts and JSON lookups
125
+ column_db_types: dict[str, str] | None = None
126
+ # Reverse mapping: db_column → field_name (only where they differ)
127
+ reverse_column_map: dict[str, str] = dataclass_field(default_factory=dict)
128
+ # Primary key field name and db column (cached at finalization)
129
+ pk_field: str | None = None
130
+ pk_column: str | None = None
131
+
132
+
133
+ def is_column_field(meta: ColumnMeta) -> bool:
134
+ """Whether a field is backed by its own column.
135
+
136
+ Virtual relation fields (reverse FK, m2m) and FK model fields
137
+ (author: Author) are not columns; the synthetic FK field (author_id) is.
138
+ """
139
+ return not (
140
+ meta.extra.get("reverse_fk")
141
+ or meta.extra.get("m2m")
142
+ or meta.foreign_key is not None
143
+ )
144
+
145
+
146
+ def column_type_for_meta(meta: ColumnMeta) -> dict[str, Any] | None:
147
+ """ColumnTypeSpec dict of a field's column, or None when it has none
148
+ (see ``is_column_field``)."""
149
+ if not is_column_field(meta):
150
+ return None
151
+ return compute_column_type(
152
+ meta.python_type,
153
+ meta.db_type,
154
+ max_length=meta.max_length,
155
+ max_digits=meta.max_digits,
156
+ decimal_places=meta.decimal_places,
157
+ )
158
+
159
+
160
+ class RelationDescriptorBase:
161
+ """Marker base class for relation descriptors."""
162
+
163
+ _is_relation_descriptor = True
164
+
165
+
166
+ __all__ = [
167
+ "ForeignKeyInfo",
168
+ "ColumnMeta",
169
+ "RelationInfo",
170
+ "ModelMeta",
171
+ "RelationDescriptorBase",
172
+ "column_type_for_meta",
173
+ "is_column_field",
174
+ ]
@@ -0,0 +1,170 @@
1
+ """Global registry of Model table classes.
2
+
3
+ This module maintains a global dict mapping model keys to model classes.
4
+ Models are auto-registered when defined with Meta.is_table = True.
5
+
6
+ Model Key Format:
7
+ "{module}.{qualname}" e.g., "myapp.models.User"
8
+
9
+ This ensures unique identification even for models with same class names
10
+ in different modules.
11
+
12
+ Functions:
13
+ register_table(model, overwrite=False):
14
+ Add model to registry. Raises ValueError if already registered
15
+ and overwrite=False.
16
+
17
+ unregister_table(model):
18
+ Remove model from registry (no-op if not registered).
19
+
20
+ registered_tables() -> dict[str, type[Model]]:
21
+ Return copy of registry.
22
+
23
+ iter_tables() -> tuple[type[Model], ...]:
24
+ Return tuple of registered model classes.
25
+
26
+ clear_registry():
27
+ Remove all models (used in tests for cleanup).
28
+
29
+ finalize_pending():
30
+ Finalize all pending models: FK resolve + metadata parse + PK cache.
31
+
32
+ assert_no_pending_models():
33
+ Fail-fast check that all models are finalized.
34
+
35
+ Auto-Registration:
36
+ Models with Meta.is_table = True are automatically registered in
37
+ Model.__init_subclass__(). This happens at class definition time.
38
+
39
+ class User(Model):
40
+ class Meta:
41
+ is_table = True # Auto-registers as "myapp.models.User"
42
+
43
+ Migration Integration:
44
+ The migration system uses registered_tables() to discover models
45
+ and generate schema diffs.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ from typing import TYPE_CHECKING
51
+
52
+ if TYPE_CHECKING:
53
+ from oxyde.models.base import Model
54
+
55
+ _TABLES: dict[str, type[Model]] = {}
56
+ _PENDING_MODELS: set[type[Model]] = set()
57
+
58
+
59
+ def _model_key(model: type[Model]) -> str:
60
+ return f"{model.__module__}.{model.__qualname__}"
61
+
62
+
63
+ def _finalize_model(model: type[Model]) -> bool:
64
+ """Try to fully finalize a single model: FK resolve → parse → column_types → PK cache.
65
+
66
+ Returns True if model is fully finalized, False if it should be retried later.
67
+ """
68
+ # Step 1: Resolve FK fields if needed
69
+ pending_fk = getattr(model, "__pending_fk_fields__", [])
70
+ if pending_fk:
71
+ fk_resolved = getattr(model, "__fk_fields_resolved__", False)
72
+ if not fk_resolved:
73
+ model.__fk_fields_resolved__ = False
74
+ model._resolve_fk_fields()
75
+ if not getattr(model, "__fk_fields_resolved__", False):
76
+ return False
77
+
78
+ # Step 2: Parse field tags → field_metadata
79
+ if not model._db_meta.field_metadata:
80
+ try:
81
+ model._parse_field_tags()
82
+ except NameError:
83
+ return False
84
+
85
+ # Step 3: Compute column_types for IR
86
+ if model._db_meta.column_types is None:
87
+ model._compute_column_types()
88
+
89
+ # Step 4: Cache PK field
90
+ if model._db_meta.pk_field is None:
91
+ for field_name, meta in model._db_meta.field_metadata.items():
92
+ if meta.primary_key:
93
+ model._db_meta.pk_field = field_name
94
+ model._db_meta.pk_column = meta.db_column
95
+ break
96
+
97
+ return True
98
+
99
+
100
+ def finalize_pending() -> None:
101
+ """Finalize all pending models: FK resolve + metadata parse + PK cache.
102
+
103
+ Called from OxydeModelMeta.__new__ after Pydantic completes model creation.
104
+ Models that can't be finalized (forward refs not yet available) stay in
105
+ _PENDING_MODELS and are retried on next class definition.
106
+ """
107
+ for model in list(_PENDING_MODELS):
108
+ if _finalize_model(model):
109
+ _PENDING_MODELS.discard(model)
110
+
111
+
112
+ def register_table(model: type[Model], *, overwrite: bool = False) -> None:
113
+ """Register an ORM model that represents a database table.
114
+
115
+ All table models are added to _PENDING_MODELS for finalization.
116
+ Finalization is triggered from OxydeModelMeta.__new__ after Pydantic completes.
117
+ """
118
+ key = _model_key(model)
119
+ existing = _TABLES.get(key)
120
+ if existing is model:
121
+ return
122
+ if existing is not None:
123
+ if not overwrite:
124
+ raise ValueError(f"Table '{key}' is already registered")
125
+ _PENDING_MODELS.discard(existing)
126
+ _TABLES[key] = model
127
+ _PENDING_MODELS.add(model)
128
+
129
+
130
+ def unregister_table(model: type[Model]) -> None:
131
+ """Remove a model from the registry if present."""
132
+ _TABLES.pop(_model_key(model), None)
133
+ _PENDING_MODELS.discard(model)
134
+
135
+
136
+ def registered_tables() -> dict[str, type[Model]]:
137
+ """Return a copy of the registered table mapping."""
138
+ return dict(_TABLES)
139
+
140
+
141
+ def iter_tables() -> tuple[type[Model], ...]:
142
+ """Return tuple of registered model classes."""
143
+ return tuple(_TABLES.values())
144
+
145
+
146
+ def clear_registry() -> None:
147
+ """Reset the registry (intended for tests)."""
148
+ _TABLES.clear()
149
+ _PENDING_MODELS.clear()
150
+
151
+
152
+ def assert_no_pending_models() -> None:
153
+ """Verify all models are finalized. Call at startup after all imports.
154
+
155
+ Raises RuntimeError if any models are still pending finalization.
156
+ """
157
+ if _PENDING_MODELS:
158
+ names = ", ".join(m.__name__ for m in _PENDING_MODELS)
159
+ raise RuntimeError(f"Models not finalized (unresolved forward refs?): {names}")
160
+
161
+
162
+ __all__ = [
163
+ "register_table",
164
+ "unregister_table",
165
+ "registered_tables",
166
+ "iter_tables",
167
+ "clear_registry",
168
+ "finalize_pending",
169
+ "assert_no_pending_models",
170
+ ]
@@ -0,0 +1,144 @@
1
+ """Serialization utilities for INSERT/UPDATE operations.
2
+
3
+ This module handles conversion of Model instances to dict payloads
4
+ suitable for database operations. It filters out virtual fields and
5
+ handles Pydantic's model_dump() options.
6
+
7
+ Virtual Fields:
8
+ Fields with db_reverse_fk or db_m2m are "virtual" - they represent
9
+ relations loaded via JOINs but don't have actual database columns.
10
+ These must be excluded from INSERT/UPDATE payloads.
11
+
12
+ class Post(Model):
13
+ id: int = Field(db_pk=True)
14
+ author_id: int # Real column
15
+ comments: list[Comment] = Field(db_reverse_fk="post") # Virtual
16
+
17
+ Functions:
18
+ _get_virtual_fields(model_class) -> set[str]:
19
+ Return names of virtual (relation) fields.
20
+
21
+ _dump_insert_data(instance) -> dict:
22
+ Serialize instance for INSERT. Includes default/default_factory
23
+ values; omits a column only when it is None and was not set
24
+ explicitly. Excludes virtual fields.
25
+
26
+ _dump_update_data(instance, fields) -> dict:
27
+ Serialize specific fields for UPDATE. Includes None values.
28
+ Excludes virtual fields.
29
+
30
+ _derive_create_data(filters, defaults) -> dict:
31
+ Merge filter kwargs with defaults for get_or_create().
32
+ Only includes exact lookups (no __gte, __contains, etc.).
33
+
34
+ _normalize_instance(model_class, payload) -> Model:
35
+ Convert dict to model instance, or return instance as-is.
36
+ Used by bulk_create() to accept mixed input.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ from collections.abc import Iterable
42
+ from typing import TYPE_CHECKING, Any, TypeVar
43
+
44
+ from oxyde.exceptions import ManagerError
45
+ from oxyde.models.metadata import is_column_field
46
+
47
+ if TYPE_CHECKING:
48
+ from oxyde.models.base import Model
49
+
50
+
51
+ TModel = TypeVar("TModel", bound="Model")
52
+
53
+
54
+ def _get_virtual_fields(model_class: type[Model]) -> set[str]:
55
+ """Get field names that are virtual and don't correspond to database columns.
56
+
57
+ Virtual fields include:
58
+ - db_reverse_fk: reverse FK relations (e.g., posts: list[Post])
59
+ - db_m2m: many-to-many relations (e.g., tags: list[Tag])
60
+ - FK model fields: (e.g., author: Author) — real column is author_id
61
+ """
62
+ return {
63
+ name
64
+ for name, meta in model_class._db_meta.field_metadata.items()
65
+ if not is_column_field(meta)
66
+ }
67
+
68
+
69
+ def _dump_insert_data(instance: Model) -> dict[str, Any]:
70
+ """Serialize model instance for INSERT operation.
71
+
72
+ Excludes virtual relation fields (db_reverse_fk, db_m2m) and Pydantic
73
+ computed fields that don't correspond to actual database columns.
74
+
75
+ Values filled from ``default=`` / ``default_factory`` are included. A
76
+ column is omitted only when its value is None AND the field was not set
77
+ explicitly: an untouched ``Field(default=None, db_pk=True)`` pk stays out
78
+ of the INSERT so the database sequence fills it, while an explicit
79
+ ``field=None`` reaches the database as NULL (e.g. to override a
80
+ server-side default).
81
+ """
82
+ virtual_fields = _get_virtual_fields(instance.__class__)
83
+ computed_fields = set(instance.__class__.model_computed_fields.keys())
84
+ data = instance.model_dump(mode="python", exclude=virtual_fields | computed_fields)
85
+ explicitly_set = instance.model_fields_set
86
+ return {
87
+ name: value
88
+ for name, value in data.items()
89
+ if value is not None or name in explicitly_set
90
+ }
91
+
92
+
93
+ def _dump_update_data(instance: Model, fields: Iterable[str]) -> dict[str, Any]:
94
+ """Serialize specific fields of model instance for UPDATE operation.
95
+
96
+ Excludes virtual relation fields (db_reverse_fk, db_m2m) and Pydantic
97
+ computed fields that don't correspond to actual database columns.
98
+ """
99
+ virtual_fields = _get_virtual_fields(instance.__class__)
100
+ computed_fields = set(instance.__class__.model_computed_fields.keys())
101
+ excluded = virtual_fields | computed_fields
102
+ snapshot = instance.model_dump(mode="python", exclude_none=False)
103
+ return {
104
+ field: snapshot[field]
105
+ for field in fields
106
+ if field in snapshot and field not in excluded
107
+ }
108
+
109
+
110
+ def _derive_create_data(
111
+ filters: dict[str, Any],
112
+ defaults: dict[str, Any] | None,
113
+ ) -> dict[str, Any]:
114
+ """Derive data for create operation from filters and defaults."""
115
+ data: dict[str, Any] = {}
116
+ for key, value in filters.items():
117
+ if "__" not in key:
118
+ data[key] = value
119
+ if defaults:
120
+ data.update(defaults)
121
+ return data
122
+
123
+
124
+ def _normalize_instance(
125
+ model_class: type[TModel],
126
+ payload: Any,
127
+ ) -> TModel:
128
+ """Normalize payload to model instance."""
129
+ if isinstance(payload, model_class):
130
+ return payload
131
+ if isinstance(payload, dict):
132
+ return model_class(**payload)
133
+ raise ManagerError(
134
+ f"Unsupported payload type for {model_class.__name__}: {type(payload).__name__}"
135
+ )
136
+
137
+
138
+ __all__ = [
139
+ "_get_virtual_fields",
140
+ "_dump_insert_data",
141
+ "_dump_update_data",
142
+ "_derive_create_data",
143
+ "_normalize_instance",
144
+ ]
oxyde/models/utils.py ADDED
@@ -0,0 +1,116 @@
1
+ """Type introspection utilities for Model field parsing.
2
+
3
+ This module provides helper functions for extracting type information
4
+ from Python type hints. Used during model metadata parsing.
5
+
6
+ Functions:
7
+ _unpack_annotated(hint) -> (base_type, metadata_tuple):
8
+ Extract base type from Annotated[T, ...].
9
+ Returns (hint, ()) if not Annotated.
10
+
11
+ Annotated[int, Field(ge=0)] → (int, (Field(ge=0),))
12
+
13
+ _unwrap_optional(hint) -> (inner_type, is_optional):
14
+ Check if type is Optional[T] or T | None.
15
+ Returns (T, True) if nullable, (hint, False) otherwise.
16
+
17
+ int | None → (int, True)
18
+ str → (str, False)
19
+
20
+ _extract_constraints(field_info) -> dict[str, int]:
21
+ Extract type constraints from top-level FieldInfo.
22
+ Used for VARCHAR(n), DECIMAL(m,d) type inference.
23
+
24
+ Field(max_length=100) → {"max_length": 100}
25
+ Field(max_digits=10, decimal_places=2) → {"max_digits": 10, "decimal_places": 2}
26
+
27
+ _extract_inner_constraints(field_info) -> dict[str, int]:
28
+ Extract constraints from inner Annotated for list[Annotated[T, Field(...)]].
29
+
30
+ list[Annotated[str, Field(max_length=100)]] → {"max_length": 100}
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from types import NoneType, UnionType
36
+ from typing import Annotated, Any, Union, get_args, get_origin
37
+
38
+ from pydantic.fields import FieldInfo
39
+
40
+
41
+ def _unpack_annotated(hint: Any) -> tuple[Any, tuple[Any, ...]]:
42
+ """Extract base type and metadata from Annotated type hint."""
43
+ if get_origin(hint) is Annotated:
44
+ args = get_args(hint)
45
+ if args:
46
+ return args[0], tuple(args[1:])
47
+ return hint, ()
48
+
49
+
50
+ def _unwrap_optional(hint: Any) -> tuple[Any, bool]:
51
+ """Check if type is Optional/Union with None and extract base type."""
52
+ origin = get_origin(hint)
53
+ if origin in (Union, UnionType):
54
+ args = []
55
+ nullable = False
56
+ for arg in get_args(hint):
57
+ if arg is NoneType:
58
+ nullable = True
59
+ else:
60
+ args.append(arg)
61
+ if nullable and len(args) == 1:
62
+ return args[0], True
63
+ return hint, False
64
+
65
+
66
+ _CONSTRAINT_ATTRS = ("max_length", "max_digits", "decimal_places")
67
+
68
+
69
+ def _collect_attrs(sources: list[Any]) -> dict[str, int]:
70
+ """Collect constraint attributes from a list of metadata sources."""
71
+ result: dict[str, int] = {}
72
+ for attr in _CONSTRAINT_ATTRS:
73
+ for source in sources:
74
+ val = getattr(source, attr, None)
75
+ if val is not None:
76
+ try:
77
+ result[attr] = int(val)
78
+ except (TypeError, ValueError):
79
+ continue
80
+ break
81
+ return result
82
+
83
+
84
+ def _extract_constraints(field_info: FieldInfo) -> dict[str, int]:
85
+ """Extract type constraints from top-level FieldInfo."""
86
+ return _collect_attrs([field_info, *getattr(field_info, "metadata", ())])
87
+
88
+
89
+ def _extract_inner_constraints(field_info: FieldInfo) -> dict[str, int]:
90
+ """Extract constraints from inner Annotated type for list fields.
91
+
92
+ For list[Annotated[T, Field(max_length=100)]], extracts max_length=100
93
+ from the inner FieldInfo metadata.
94
+ """
95
+ annotation = getattr(field_info, "annotation", None)
96
+ if annotation is None:
97
+ return {}
98
+ unwrapped, _ = _unwrap_optional(annotation)
99
+ if get_origin(unwrapped) is not list:
100
+ return {}
101
+ inner_args = get_args(unwrapped)
102
+ if not inner_args or get_origin(inner_args[0]) is not Annotated:
103
+ return {}
104
+ sources: list[Any] = []
105
+ for ann in get_args(inner_args[0])[1:]:
106
+ sources.append(ann)
107
+ sources.extend(getattr(ann, "metadata", ()))
108
+ return _collect_attrs(sources)
109
+
110
+
111
+ __all__ = [
112
+ "_unpack_annotated",
113
+ "_unwrap_optional",
114
+ "_extract_constraints",
115
+ "_extract_inner_constraints",
116
+ ]
oxyde/py.typed ADDED
File without changes
@@ -0,0 +1,79 @@
1
+ """Query building system for Oxyde ORM.
2
+
3
+ This module provides Django-style query building via Model.objects interface.
4
+ Queries are converted to IR (Intermediate Representation) and sent to the
5
+ Rust core for SQL generation.
6
+
7
+ Query Building:
8
+ Use Model.objects for all queries:
9
+ - filter(), exclude(): Add WHERE conditions
10
+ - order_by(), limit(), offset(): Pagination
11
+ - join(), prefetch(): Related objects
12
+ - values(), values_list(): Dict/tuple results
13
+ - annotate(), group_by(), having(): Aggregation
14
+
15
+ Execution Methods:
16
+ - all(): Get all matching objects
17
+ - first(), last(): Get single object
18
+ - get(): Get exactly one object (raises if not found)
19
+ - get_or_none(): Get one or None
20
+ - get_or_create(): Get existing object or create a new one
21
+ - update_or_create(): Get existing object and update it, or create a new one
22
+ - count(), exists(): Aggregate queries
23
+ - create(), bulk_create(): Insert objects
24
+ - update(), delete(): Modify/remove objects
25
+
26
+ Query Expressions:
27
+ F: Database-side field reference (F("price") * F("quantity")).
28
+ Q: Boolean expression combinator (Q(a=1) | Q(b=2), ~Q(deleted=True)).
29
+
30
+ Aggregates:
31
+ Count, Sum, Avg, Max, Min: SQL aggregate functions.
32
+ Concat: String concatenation.
33
+ Coalesce: NULL handling (COALESCE(a, b, c)).
34
+ RawSQL: Escape hatch for raw SQL expressions.
35
+
36
+ Example:
37
+ # Get active adult users
38
+ users = await User.objects.filter(age__gte=18, status="active").all()
39
+
40
+ # Complex query
41
+ users = await (
42
+ User.objects
43
+ .filter(Q(role="admin") | Q(is_superuser=True))
44
+ .exclude(deleted=True)
45
+ .order_by("-created_at")
46
+ .limit(10)
47
+ .all()
48
+ )
49
+ """
50
+
51
+ from .aggregates import Aggregate, Avg, Coalesce, Concat, Count, Max, Min, RawSQL, Sum
52
+ from .base import SupportsExecute
53
+ from .expressions import F
54
+ from .manager import QueryManager
55
+ from .q import Q
56
+ from .raw import execute_raw
57
+ from .select import Query
58
+ from .typed import FlatValuesListQuery, ValuesListQuery, ValuesQuery
59
+
60
+ __all__ = [
61
+ "Query",
62
+ "F",
63
+ "Q",
64
+ "SupportsExecute",
65
+ "QueryManager",
66
+ "Aggregate",
67
+ "Count",
68
+ "Sum",
69
+ "Avg",
70
+ "Max",
71
+ "Min",
72
+ "Concat",
73
+ "Coalesce",
74
+ "RawSQL",
75
+ "execute_raw",
76
+ "ValuesQuery",
77
+ "ValuesListQuery",
78
+ "FlatValuesListQuery",
79
+ ]