ferrum-orm 0.1.8__tar.gz → 0.1.11__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.
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/Cargo.lock +4 -4
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/Cargo.toml +1 -1
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/PKG-INFO +46 -1
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/README.md +45 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/src/compile/mod.rs +58 -12
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/src/ir/mod.rs +42 -11
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/benches/compile.rs +2 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/emit.rs +129 -50
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/pyproject.toml +1 -1
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/__init__.py +4 -1
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/makemigrations_cmd.py +64 -15
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/connection.py +22 -2
- ferrum_orm-0.1.11/python/ferrum/echo.py +114 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/__init__.py +12 -1
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/orchestrator.py +196 -13
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/models.py +45 -4
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/queryset.py +274 -34
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/relations.py +20 -2
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/LICENSE +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/Cargo.toml +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/benches/hydrate.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/src/error.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/src/ir/metadata.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/src/lib.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/src/migrate/mod.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-pyo3/Cargo.toml +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-pyo3/src/lib.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/Cargo.toml +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/dialect.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/mod.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/lib.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/_native.pyi +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/__init__.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/app.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/bootstrap.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/init.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/inspectdb_cmd.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/migrate_cmd.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/migrations_cmd.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/resetdb_cmd.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/revert_cmd.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/showmigrations_cmd.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/config.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/contrib/__init__.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/contrib/fastapi.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/drivers/__init__.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/drivers/mssql.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/drivers/mysql.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/drivers/postgres.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/drivers/protocol.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/drivers/sqlite.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/errors.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/expressions.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/ext/__init__.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/ext/fts.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/ext/pgvector.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/hooks.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/base.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/drift.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/__init__.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/mssql.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/mysql.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/postgres.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/sqlite.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/gates.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/introspect.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/ledger.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/loader.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/operations.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/tokens.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/observability.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/py.typed +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/registry.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/runtime.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/session.py +0 -0
|
@@ -223,7 +223,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
|
|
223
223
|
|
|
224
224
|
[[package]]
|
|
225
225
|
name = "ferrum-core"
|
|
226
|
-
version = "0.1.
|
|
226
|
+
version = "0.1.11"
|
|
227
227
|
dependencies = [
|
|
228
228
|
"criterion",
|
|
229
229
|
"proptest",
|
|
@@ -234,7 +234,7 @@ dependencies = [
|
|
|
234
234
|
|
|
235
235
|
[[package]]
|
|
236
236
|
name = "ferrum-migrate"
|
|
237
|
-
version = "0.1.
|
|
237
|
+
version = "0.1.11"
|
|
238
238
|
dependencies = [
|
|
239
239
|
"ferrum-core",
|
|
240
240
|
"serde",
|
|
@@ -243,7 +243,7 @@ dependencies = [
|
|
|
243
243
|
|
|
244
244
|
[[package]]
|
|
245
245
|
name = "ferrum-pyo3"
|
|
246
|
-
version = "0.1.
|
|
246
|
+
version = "0.1.11"
|
|
247
247
|
dependencies = [
|
|
248
248
|
"ferrum-core",
|
|
249
249
|
"ferrum-sql",
|
|
@@ -255,7 +255,7 @@ dependencies = [
|
|
|
255
255
|
|
|
256
256
|
[[package]]
|
|
257
257
|
name = "ferrum-sql"
|
|
258
|
-
version = "0.1.
|
|
258
|
+
version = "0.1.11"
|
|
259
259
|
dependencies = [
|
|
260
260
|
"criterion",
|
|
261
261
|
"ferrum-core",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ferrum-orm
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.11
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Framework :: AsyncIO
|
|
6
6
|
Classifier: Intended Audience :: Developers
|
|
@@ -168,6 +168,49 @@ class User(Model):
|
|
|
168
168
|
|
|
169
169
|
No duplicate schema definitions.
|
|
170
170
|
|
|
171
|
+
### Field Options
|
|
172
|
+
|
|
173
|
+
`ferrum.Field()` accepts column-level constraints that are carried through to DDL:
|
|
174
|
+
|
|
175
|
+
| Parameter | Type | Description |
|
|
176
|
+
| --------- | ---- | ----------- |
|
|
177
|
+
| `db_default` | `str \| None` | DB-side `DEFAULT` expression (e.g. `"NOW()"`, `"''"`, `"GEN_RANDOM_UUID()"`). Token casing is normalised (`now()` → `NOW()`). |
|
|
178
|
+
| `nullable` | `bool \| None` | Override annotation-derived nullability. Use `nullable=False` to force `NOT NULL` even on a `T \| None`-annotated field. |
|
|
179
|
+
| `db_index` | `bool` | Emit a `CREATE INDEX` on this column (auto-named `idx_{table}_{field}`). |
|
|
180
|
+
| `unique` | `bool` | Emit a `UNIQUE` constraint. |
|
|
181
|
+
| `db_column` | `str \| None` | Override the database column name. |
|
|
182
|
+
| `primary_key` | `bool` | Declare this field as the primary key. |
|
|
183
|
+
| `uuid_generate` | `"v4" \| "v7" \| None` | Set `GEN_RANDOM_UUID()` / `UUIDV7()` as the DB default for UUID PK columns. |
|
|
184
|
+
| `max_length` | `int \| None` | Emit `VARCHAR(n)` instead of `TEXT`. |
|
|
185
|
+
| `max_digits` / `decimal_places` | `int \| None` | Emit `NUMERIC(p,s)`. |
|
|
186
|
+
| `vector_dimensions` | `int \| None` | Required for `Vector` columns; emits `VECTOR(n)`. |
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from datetime import datetime
|
|
190
|
+
from typing import Annotated
|
|
191
|
+
from ferrum import Model, Field
|
|
192
|
+
|
|
193
|
+
class Event(Model):
|
|
194
|
+
id: int
|
|
195
|
+
# DB-side timestamp default; Python annotation allows None but column is NOT NULL
|
|
196
|
+
created_at: Annotated[datetime | None, Field(db_default="now()", nullable=False)]
|
|
197
|
+
# Indexed text column
|
|
198
|
+
kind: Annotated[str, Field(db_index=True, max_length=64)]
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`makemigrations` honours these field attributes:
|
|
202
|
+
- `db_default` / `nullable` are written into `CREATE TABLE` and `ADD COLUMN` DDL.
|
|
203
|
+
- `db_index=True` emits `CREATE INDEX` after the table op.
|
|
204
|
+
- For **already-existing tables**, `makemigrations` now **autodiffs** index and column
|
|
205
|
+
attribute changes against the state projected from prior migration files:
|
|
206
|
+
- Adding `db_index=True` to an existing field → `AddIndex` op.
|
|
207
|
+
- Removing `db_index=True` → `DropIndex` op.
|
|
208
|
+
- Adding or changing `db_default` → `AlterColumn … SET DEFAULT …` op.
|
|
209
|
+
- Removing `db_default` → `AlterColumn … DROP DEFAULT` op.
|
|
210
|
+
- Changing nullability → `AlterColumn … SET / DROP NOT NULL` op (SET NOT NULL is
|
|
211
|
+
classified destructive and requires `--confirm`).
|
|
212
|
+
- Column type changes, renames, and drops remain manual (out of scope for v0.1).
|
|
213
|
+
|
|
171
214
|
### Django-Inspired API
|
|
172
215
|
|
|
173
216
|
Familiar query interface.
|
|
@@ -309,6 +352,8 @@ and [API Reference](docs/api-reference.md) for per-dialect DDL and operator mapp
|
|
|
309
352
|
- [x] `call_function` for allowlisted stored-procedure calls
|
|
310
353
|
- [x] Migration ops for extensions, RLS policies, and function DDL
|
|
311
354
|
- [x] pgvector similarity score projection (`vector_search` helper)
|
|
355
|
+
- [x] `Field(db_default=..., nullable=...)` — first-class DB-side defaults and nullability override
|
|
356
|
+
- [x] `makemigrations` autodiff for index / default / nullability changes on existing tables
|
|
312
357
|
- [ ] Query optimization (deferred fields, prefetch tuning)
|
|
313
358
|
- [ ] Advanced relationship loading
|
|
314
359
|
|
|
@@ -96,6 +96,49 @@ class User(Model):
|
|
|
96
96
|
|
|
97
97
|
No duplicate schema definitions.
|
|
98
98
|
|
|
99
|
+
### Field Options
|
|
100
|
+
|
|
101
|
+
`ferrum.Field()` accepts column-level constraints that are carried through to DDL:
|
|
102
|
+
|
|
103
|
+
| Parameter | Type | Description |
|
|
104
|
+
| --------- | ---- | ----------- |
|
|
105
|
+
| `db_default` | `str \| None` | DB-side `DEFAULT` expression (e.g. `"NOW()"`, `"''"`, `"GEN_RANDOM_UUID()"`). Token casing is normalised (`now()` → `NOW()`). |
|
|
106
|
+
| `nullable` | `bool \| None` | Override annotation-derived nullability. Use `nullable=False` to force `NOT NULL` even on a `T \| None`-annotated field. |
|
|
107
|
+
| `db_index` | `bool` | Emit a `CREATE INDEX` on this column (auto-named `idx_{table}_{field}`). |
|
|
108
|
+
| `unique` | `bool` | Emit a `UNIQUE` constraint. |
|
|
109
|
+
| `db_column` | `str \| None` | Override the database column name. |
|
|
110
|
+
| `primary_key` | `bool` | Declare this field as the primary key. |
|
|
111
|
+
| `uuid_generate` | `"v4" \| "v7" \| None` | Set `GEN_RANDOM_UUID()` / `UUIDV7()` as the DB default for UUID PK columns. |
|
|
112
|
+
| `max_length` | `int \| None` | Emit `VARCHAR(n)` instead of `TEXT`. |
|
|
113
|
+
| `max_digits` / `decimal_places` | `int \| None` | Emit `NUMERIC(p,s)`. |
|
|
114
|
+
| `vector_dimensions` | `int \| None` | Required for `Vector` columns; emits `VECTOR(n)`. |
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from datetime import datetime
|
|
118
|
+
from typing import Annotated
|
|
119
|
+
from ferrum import Model, Field
|
|
120
|
+
|
|
121
|
+
class Event(Model):
|
|
122
|
+
id: int
|
|
123
|
+
# DB-side timestamp default; Python annotation allows None but column is NOT NULL
|
|
124
|
+
created_at: Annotated[datetime | None, Field(db_default="now()", nullable=False)]
|
|
125
|
+
# Indexed text column
|
|
126
|
+
kind: Annotated[str, Field(db_index=True, max_length=64)]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`makemigrations` honours these field attributes:
|
|
130
|
+
- `db_default` / `nullable` are written into `CREATE TABLE` and `ADD COLUMN` DDL.
|
|
131
|
+
- `db_index=True` emits `CREATE INDEX` after the table op.
|
|
132
|
+
- For **already-existing tables**, `makemigrations` now **autodiffs** index and column
|
|
133
|
+
attribute changes against the state projected from prior migration files:
|
|
134
|
+
- Adding `db_index=True` to an existing field → `AddIndex` op.
|
|
135
|
+
- Removing `db_index=True` → `DropIndex` op.
|
|
136
|
+
- Adding or changing `db_default` → `AlterColumn … SET DEFAULT …` op.
|
|
137
|
+
- Removing `db_default` → `AlterColumn … DROP DEFAULT` op.
|
|
138
|
+
- Changing nullability → `AlterColumn … SET / DROP NOT NULL` op (SET NOT NULL is
|
|
139
|
+
classified destructive and requires `--confirm`).
|
|
140
|
+
- Column type changes, renames, and drops remain manual (out of scope for v0.1).
|
|
141
|
+
|
|
99
142
|
### Django-Inspired API
|
|
100
143
|
|
|
101
144
|
Familiar query interface.
|
|
@@ -237,6 +280,8 @@ and [API Reference](docs/api-reference.md) for per-dialect DDL and operator mapp
|
|
|
237
280
|
- [x] `call_function` for allowlisted stored-procedure calls
|
|
238
281
|
- [x] Migration ops for extensions, RLS policies, and function DDL
|
|
239
282
|
- [x] pgvector similarity score projection (`vector_search` helper)
|
|
283
|
+
- [x] `Field(db_default=..., nullable=...)` — first-class DB-side defaults and nullability override
|
|
284
|
+
- [x] `makemigrations` autodiff for index / default / nullability changes on existing tables
|
|
240
285
|
- [ ] Query optimization (deferred fields, prefetch tuning)
|
|
241
286
|
- [ ] Advanced relationship loading
|
|
242
287
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
use crate::{
|
|
19
19
|
error::CompileError,
|
|
20
20
|
ir::{
|
|
21
|
-
metadata::FieldType, BindValue, ModelMetadata, Operation, Predicate, QuerySetIR,
|
|
21
|
+
metadata::FieldType, BindValue, JoinSpec, ModelMetadata, Operation, Predicate, QuerySetIR,
|
|
22
22
|
SortDirection, IR_VERSION,
|
|
23
23
|
},
|
|
24
24
|
};
|
|
@@ -127,16 +127,18 @@ pub fn compile(metadata: &ModelMetadata, ir: &QuerySetIR) -> Result<CompiledQuer
|
|
|
127
127
|
Operation::BulkDelete { pk_fields, ids } => validate_bulk_delete(metadata, pk_fields, ids)?,
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
// Joins first so filter ``join_alias`` references can be checked against them.
|
|
131
|
+
validate_joins(metadata, ir)?;
|
|
132
|
+
|
|
130
133
|
// Validate all field references in filters before touching SQL.
|
|
131
134
|
for filter in &ir.filters {
|
|
132
|
-
validate_filter(metadata, filter)?;
|
|
135
|
+
validate_filter(metadata, filter, &ir.joins)?;
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
if let Some(predicate) = &ir.predicate {
|
|
136
|
-
validate_predicate(metadata, predicate)?;
|
|
139
|
+
validate_predicate(metadata, predicate, &ir.joins)?;
|
|
137
140
|
}
|
|
138
141
|
|
|
139
|
-
validate_joins(metadata, ir)?;
|
|
140
142
|
validate_order_by(metadata, ir)?;
|
|
141
143
|
validate_vector_order_by(metadata, ir)?;
|
|
142
144
|
validate_text_search(metadata, ir)?;
|
|
@@ -319,14 +321,15 @@ fn validate_vector_order_by(metadata: &ModelMetadata, ir: &QuerySetIR) -> Result
|
|
|
319
321
|
operator: "nearest_to".into(),
|
|
320
322
|
});
|
|
321
323
|
}
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
+
// Prefer text (pgvector literal ``[f,f,...]`` + SQL ``::vector`` cast) so
|
|
325
|
+
// asyncpg does not bind ``list[float]`` as ``float[]`` (DataError). Still
|
|
326
|
+
// accept float_array / int_array for backwards-compatible IR producers.
|
|
324
327
|
if !matches!(
|
|
325
328
|
vector_order.value,
|
|
326
|
-
BindValue::FloatArray(_) | BindValue::IntArray(_)
|
|
329
|
+
BindValue::Text(_) | BindValue::FloatArray(_) | BindValue::IntArray(_)
|
|
327
330
|
) {
|
|
328
331
|
return Err(CompileError::MalformedIr {
|
|
329
|
-
reason: "vector_order_by.value must be float_array or int_array".into(),
|
|
332
|
+
reason: "vector_order_by.value must be text, float_array, or int_array".into(),
|
|
330
333
|
});
|
|
331
334
|
}
|
|
332
335
|
Ok(())
|
|
@@ -466,6 +469,11 @@ fn validate_predicate_fts(
|
|
|
466
469
|
Predicate::Not { child } => validate_predicate_fts(metadata, child)?,
|
|
467
470
|
Predicate::Filter { filter } => {
|
|
468
471
|
if FTS_OPERATORS.contains(&filter.operator.as_str()) {
|
|
472
|
+
if filter.join_alias.is_some() {
|
|
473
|
+
return Err(CompileError::MalformedIr {
|
|
474
|
+
reason: "full-text operators are not supported on relation lookups".into(),
|
|
475
|
+
});
|
|
476
|
+
}
|
|
469
477
|
validate_fts_filter(metadata, filter)?;
|
|
470
478
|
}
|
|
471
479
|
}
|
|
@@ -482,7 +490,36 @@ pub fn ir_has_where_clause(ir: &QuerySetIR) -> bool {
|
|
|
482
490
|
fn validate_filter(
|
|
483
491
|
metadata: &ModelMetadata,
|
|
484
492
|
filter: &crate::ir::Filter,
|
|
493
|
+
joins: &[JoinSpec],
|
|
485
494
|
) -> Result<(), CompileError> {
|
|
495
|
+
if let Some(alias) = &filter.join_alias {
|
|
496
|
+
let join =
|
|
497
|
+
joins
|
|
498
|
+
.iter()
|
|
499
|
+
.find(|j| j.alias == *alias)
|
|
500
|
+
.ok_or_else(|| CompileError::MalformedIr {
|
|
501
|
+
reason: format!("filter join_alias {alias:?} has no matching join"),
|
|
502
|
+
})?;
|
|
503
|
+
let remote = join
|
|
504
|
+
.remote_fields
|
|
505
|
+
.iter()
|
|
506
|
+
.find(|f| f.name == filter.field.name || f.index == filter.field.index)
|
|
507
|
+
.ok_or_else(|| CompileError::UnknownField {
|
|
508
|
+
model: metadata.model_name.clone(),
|
|
509
|
+
field: format!("{alias}__{}", filter.field.name),
|
|
510
|
+
})?;
|
|
511
|
+
if !remote.allowed_operators.is_empty()
|
|
512
|
+
&& !remote.allowed_operators.contains(&filter.operator)
|
|
513
|
+
{
|
|
514
|
+
return Err(CompileError::UnsupportedOperator {
|
|
515
|
+
model: metadata.model_name.clone(),
|
|
516
|
+
field: format!("{alias}__{}", filter.field.name),
|
|
517
|
+
operator: filter.operator.clone(),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
return Ok(());
|
|
521
|
+
}
|
|
522
|
+
|
|
486
523
|
let field_meta =
|
|
487
524
|
metadata
|
|
488
525
|
.fields
|
|
@@ -502,15 +539,19 @@ fn validate_filter(
|
|
|
502
539
|
Ok(())
|
|
503
540
|
}
|
|
504
541
|
|
|
505
|
-
fn validate_predicate(
|
|
542
|
+
fn validate_predicate(
|
|
543
|
+
metadata: &ModelMetadata,
|
|
544
|
+
predicate: &Predicate,
|
|
545
|
+
joins: &[JoinSpec],
|
|
546
|
+
) -> Result<(), CompileError> {
|
|
506
547
|
match predicate {
|
|
507
548
|
Predicate::And { children } | Predicate::Or { children } => {
|
|
508
549
|
for child in children {
|
|
509
|
-
validate_predicate(metadata, child)?;
|
|
550
|
+
validate_predicate(metadata, child, joins)?;
|
|
510
551
|
}
|
|
511
552
|
}
|
|
512
|
-
Predicate::Not { child } => validate_predicate(metadata, child)?,
|
|
513
|
-
Predicate::Filter { filter } => validate_filter(metadata, filter)?,
|
|
553
|
+
Predicate::Not { child } => validate_predicate(metadata, child, joins)?,
|
|
554
|
+
Predicate::Filter { filter } => validate_filter(metadata, filter, joins)?,
|
|
514
555
|
}
|
|
515
556
|
Ok(())
|
|
516
557
|
}
|
|
@@ -604,6 +645,7 @@ mod tests {
|
|
|
604
645
|
},
|
|
605
646
|
operator: "eq".into(),
|
|
606
647
|
value: BindValue::Int(1),
|
|
648
|
+
join_alias: None,
|
|
607
649
|
});
|
|
608
650
|
let err = compile(&meta, &ir).unwrap_err();
|
|
609
651
|
assert!(matches!(err, CompileError::UnknownField { .. }));
|
|
@@ -634,6 +676,7 @@ mod tests {
|
|
|
634
676
|
},
|
|
635
677
|
operator: "regex".into(), // not in allowed_operators
|
|
636
678
|
value: BindValue::Int(1),
|
|
679
|
+
join_alias: None,
|
|
637
680
|
});
|
|
638
681
|
let err = compile(&meta, &ir).unwrap_err();
|
|
639
682
|
assert!(matches!(err, CompileError::UnsupportedOperator { .. }));
|
|
@@ -679,6 +722,7 @@ mod tests {
|
|
|
679
722
|
},
|
|
680
723
|
operator: "match".into(),
|
|
681
724
|
value: BindValue::Text("hello".into()),
|
|
725
|
+
join_alias: None,
|
|
682
726
|
});
|
|
683
727
|
let err = compile(&meta, &ir).unwrap_err();
|
|
684
728
|
assert!(matches!(err, CompileError::UnsupportedOperator { .. }));
|
|
@@ -723,6 +767,7 @@ mod tests {
|
|
|
723
767
|
},
|
|
724
768
|
operator: "match_phrase".into(),
|
|
725
769
|
value: BindValue::Text("hello world".into()),
|
|
770
|
+
join_alias: None,
|
|
726
771
|
});
|
|
727
772
|
ir.text_rank_by = Some(crate::ir::TextRankBy {
|
|
728
773
|
field: FieldRef {
|
|
@@ -746,6 +791,7 @@ mod tests {
|
|
|
746
791
|
},
|
|
747
792
|
operator: "eq".into(),
|
|
748
793
|
value: BindValue::Text("test@example.com".into()),
|
|
794
|
+
join_alias: None,
|
|
749
795
|
});
|
|
750
796
|
ir.order_by.push(OrderBy {
|
|
751
797
|
field: FieldRef {
|
|
@@ -67,9 +67,20 @@ pub struct QuerySetIR {
|
|
|
67
67
|
pub joins: Vec<JoinSpec>,
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/// JOIN
|
|
70
|
+
/// JOIN kind for ``select_related`` (LEFT) vs relation-filter (INNER) joins.
|
|
71
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
72
|
+
#[serde(rename_all = "snake_case")]
|
|
73
|
+
pub enum JoinKind {
|
|
74
|
+
/// ``LEFT JOIN`` — used by ``select_related()`` (preserve parent rows).
|
|
75
|
+
#[default]
|
|
76
|
+
Left,
|
|
77
|
+
/// ``INNER JOIN`` — used by relation-filter lookups (``team__slug=...``).
|
|
78
|
+
Inner,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// JOIN metadata for ``select_related`` and relation-filter lookups.
|
|
71
82
|
///
|
|
72
|
-
/// The emitter produces
|
|
83
|
+
/// The emitter produces `<JOIN KIND> <remote_table> AS <alias> ON <base_table>.<local_col> = <alias>.<remote_pk_column>`.
|
|
73
84
|
/// All identifier fields are validated on the Python side before this struct is serialized into the IR.
|
|
74
85
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
75
86
|
pub struct JoinSpec {
|
|
@@ -83,13 +94,25 @@ pub struct JoinSpec {
|
|
|
83
94
|
pub remote_table: String,
|
|
84
95
|
/// Remote table PK column name (from model metadata).
|
|
85
96
|
pub remote_pk_column: String,
|
|
86
|
-
/// Columns
|
|
97
|
+
/// Columns available on the joined table (for SELECT projection and/or filter allowlisting).
|
|
87
98
|
pub remote_fields: Vec<JoinFieldRef>,
|
|
99
|
+
/// ``left`` (default) or ``inner``.
|
|
100
|
+
#[serde(default)]
|
|
101
|
+
pub join_kind: JoinKind,
|
|
102
|
+
/// When ``true`` (default), project ``remote_fields`` into ``alias__col`` SELECT aliases.
|
|
103
|
+
/// Filter-only joins set this ``false`` so hydration is not polluted.
|
|
104
|
+
#[serde(default = "default_true")]
|
|
105
|
+
pub project_remote: bool,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fn default_true() -> bool {
|
|
109
|
+
true
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
/// A field reference into a joined (remote) model's metadata.
|
|
91
113
|
///
|
|
92
|
-
///
|
|
114
|
+
/// When ``JoinSpec::project_remote`` is true, projected as
|
|
115
|
+
/// `<alias>.<column> AS "<alias>__<column>"` in the SELECT list.
|
|
93
116
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
94
117
|
pub struct JoinFieldRef {
|
|
95
118
|
/// Index into the remote model's `ModelMetadata::fields`.
|
|
@@ -98,6 +121,9 @@ pub struct JoinFieldRef {
|
|
|
98
121
|
pub name: String,
|
|
99
122
|
/// Database column name of the remote field.
|
|
100
123
|
pub column: String,
|
|
124
|
+
/// Operator allowlist copied from the remote field's ``FieldMeta`` (SQL-2).
|
|
125
|
+
#[serde(default)]
|
|
126
|
+
pub allowed_operators: Vec<String>,
|
|
101
127
|
}
|
|
102
128
|
|
|
103
129
|
/// The SQL operation this IR node represents.
|
|
@@ -149,10 +175,6 @@ pub enum Operation {
|
|
|
149
175
|
},
|
|
150
176
|
}
|
|
151
177
|
|
|
152
|
-
fn default_true() -> bool {
|
|
153
|
-
true
|
|
154
|
-
}
|
|
155
|
-
|
|
156
178
|
/// One row payload for :variant:`Operation::BulkUpdate`.
|
|
157
179
|
/// ``pk_values`` has one entry per PK field (single entry for single-PK models).
|
|
158
180
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
@@ -173,17 +195,26 @@ pub struct FieldRef {
|
|
|
173
195
|
|
|
174
196
|
/// A filter predicate (one leaf of the WHERE clause).
|
|
175
197
|
///
|
|
176
|
-
/// `operator` must be in `FieldMeta::allowed_operators` for `field
|
|
177
|
-
///
|
|
198
|
+
/// `operator` must be in `FieldMeta::allowed_operators` for `field` (base model)
|
|
199
|
+
/// or in the matching ``JoinFieldRef::allowed_operators`` when ``join_alias`` is
|
|
200
|
+
/// set. The compiler rejects any string not in that allowlist before SQL is
|
|
201
|
+
/// produced (SQL-1 / SQL-2).
|
|
178
202
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
179
203
|
pub struct Filter {
|
|
180
204
|
/// The model field being filtered.
|
|
205
|
+
///
|
|
206
|
+
/// When ``join_alias`` is set, ``index``/``name`` refer to the **remote**
|
|
207
|
+
/// model's field (matched against ``JoinSpec::remote_fields``).
|
|
181
208
|
pub field: FieldRef,
|
|
182
209
|
/// Ferrum operator string, e.g. `"eq"`, `"gt"`, `"icontains"`, `"is_null"`.
|
|
183
|
-
/// Validated against
|
|
210
|
+
/// Validated against the field's operator allowlist before SQL emission.
|
|
184
211
|
pub operator: String,
|
|
185
212
|
/// Bound value for the filter — never interpolated into SQL text.
|
|
186
213
|
pub value: BindValue,
|
|
214
|
+
/// When set, the filter targets a column on the JOIN with this alias
|
|
215
|
+
/// (Django-style ``relation__field`` lookups). ``None`` = base table.
|
|
216
|
+
#[serde(default)]
|
|
217
|
+
pub join_alias: Option<String>,
|
|
187
218
|
}
|
|
188
219
|
|
|
189
220
|
/// Composable boolean predicate tree (``Q`` objects on the Python side).
|
|
@@ -91,6 +91,7 @@ fn bench_select_ir() -> QuerySetIR {
|
|
|
91
91
|
},
|
|
92
92
|
operator: "eq".into(),
|
|
93
93
|
value: BindValue::Bool(true),
|
|
94
|
+
join_alias: None,
|
|
94
95
|
},
|
|
95
96
|
Filter {
|
|
96
97
|
field: FieldRef {
|
|
@@ -99,6 +100,7 @@ fn bench_select_ir() -> QuerySetIR {
|
|
|
99
100
|
},
|
|
100
101
|
operator: "icontains".into(),
|
|
101
102
|
value: BindValue::Text("example.com".into()),
|
|
103
|
+
join_alias: None,
|
|
102
104
|
},
|
|
103
105
|
],
|
|
104
106
|
order_by: vec![
|