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.
- oxyde/__init__.py +151 -0
- oxyde/_msgpack.py +3 -0
- oxyde/cli/__init__.py +7 -0
- oxyde/cli/app.py +107 -0
- oxyde/cli/codegen.py +66 -0
- oxyde/cli/config.py +84 -0
- oxyde/cli/migrations.py +536 -0
- oxyde/codegen/__init__.py +15 -0
- oxyde/codegen/stub_generator.py +1229 -0
- oxyde/core/__init__.py +90 -0
- oxyde/core/column_types.py +367 -0
- oxyde/core/ir.py +380 -0
- oxyde/core/types.py +69 -0
- oxyde/core/wrapper.py +242 -0
- oxyde/db/__init__.py +230 -0
- oxyde/db/pool.py +305 -0
- oxyde/db/registry.py +113 -0
- oxyde/db/schema.py +51 -0
- oxyde/db/transaction.py +392 -0
- oxyde/exceptions.py +89 -0
- oxyde/migrations/__init__.py +49 -0
- oxyde/migrations/config.py +187 -0
- oxyde/migrations/context.py +598 -0
- oxyde/migrations/executor.py +456 -0
- oxyde/migrations/extract.py +316 -0
- oxyde/migrations/generator.py +534 -0
- oxyde/migrations/replay.py +367 -0
- oxyde/migrations/squash.py +109 -0
- oxyde/migrations/tracker.py +162 -0
- oxyde/migrations/utils.py +145 -0
- oxyde/models/__init__.py +83 -0
- oxyde/models/base.py +871 -0
- oxyde/models/decorators.py +111 -0
- oxyde/models/field.py +204 -0
- oxyde/models/lookups.py +667 -0
- oxyde/models/metadata.py +174 -0
- oxyde/models/registry.py +170 -0
- oxyde/models/serializers.py +144 -0
- oxyde/models/utils.py +116 -0
- oxyde/py.typed +0 -0
- oxyde/queries/__init__.py +79 -0
- oxyde/queries/aggregates.py +145 -0
- oxyde/queries/base.py +249 -0
- oxyde/queries/conditions.py +92 -0
- oxyde/queries/expressions.py +171 -0
- oxyde/queries/insert.py +124 -0
- oxyde/queries/joins.py +66 -0
- oxyde/queries/manager.py +409 -0
- oxyde/queries/mixins/__init__.py +68 -0
- oxyde/queries/mixins/aggregation.py +335 -0
- oxyde/queries/mixins/debug.py +154 -0
- oxyde/queries/mixins/execution.py +701 -0
- oxyde/queries/mixins/filtering.py +144 -0
- oxyde/queries/mixins/joining.py +160 -0
- oxyde/queries/mixins/mutation.py +508 -0
- oxyde/queries/mixins/pagination.py +145 -0
- oxyde/queries/q.py +250 -0
- oxyde/queries/raw.py +114 -0
- oxyde/queries/select.py +345 -0
- oxyde/queries/typed.py +213 -0
- oxyde_postgres-0.1.0.dist-info/METADATA +141 -0
- oxyde_postgres-0.1.0.dist-info/RECORD +66 -0
- oxyde_postgres-0.1.0.dist-info/WHEEL +5 -0
- oxyde_postgres-0.1.0.dist-info/entry_points.txt +2 -0
- oxyde_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
- oxyde_postgres-0.1.0.dist-info/top_level.txt +1 -0
oxyde/models/metadata.py
ADDED
|
@@ -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
|
+
]
|
oxyde/models/registry.py
ADDED
|
@@ -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
|
+
]
|