ferrum-orm 0.1.11__tar.gz → 0.1.14__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.11 → ferrum_orm-0.1.14}/Cargo.lock +4 -4
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/Cargo.toml +1 -1
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/PKG-INFO +31 -1
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/README.md +30 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/src/compile/mod.rs +193 -2
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/src/ir/mod.rs +88 -1
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/benches/compile.rs +1 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/emit.rs +324 -19
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/pyproject.toml +1 -1
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/__init__.py +3 -1
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/connection.py +92 -2
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/drivers/postgres.py +47 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/drivers/protocol.py +40 -0
- ferrum_orm-0.1.14/python/ferrum/drivers/streaming.py +118 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/ext/pgvector.py +36 -8
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/__init__.py +10 -0
- ferrum_orm-0.1.14/python/ferrum/migrations/drift.py +377 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/models.py +207 -65
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/queryset.py +655 -59
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/relations.py +1 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/runtime.py +53 -1
- ferrum_orm-0.1.11/python/ferrum/migrations/drift.py +0 -115
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/LICENSE +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/Cargo.toml +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/benches/hydrate.rs +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/src/error.rs +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/src/ir/metadata.rs +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/src/lib.rs +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/src/migrate/mod.rs +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-pyo3/Cargo.toml +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-pyo3/src/lib.rs +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/Cargo.toml +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/dialect.rs +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/fts/mod.rs +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/lib.rs +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/_native.pyi +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/__init__.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/app.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/bootstrap.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/init.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/inspectdb_cmd.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/makemigrations_cmd.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/migrate_cmd.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/migrations_cmd.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/resetdb_cmd.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/revert_cmd.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/showmigrations_cmd.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/config.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/contrib/__init__.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/contrib/fastapi.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/drivers/__init__.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/drivers/mssql.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/drivers/mysql.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/drivers/sqlite.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/echo.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/errors.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/expressions.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/ext/__init__.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/ext/fts.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/hooks.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/base.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/fts/__init__.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/fts/mssql.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/fts/mysql.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/fts/postgres.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/fts/sqlite.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/gates.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/introspect.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/ledger.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/loader.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/operations.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/orchestrator.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/tokens.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/observability.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/py.typed +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/registry.py +0 -0
- {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/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.14"
|
|
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.14"
|
|
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.14"
|
|
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.14"
|
|
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.14
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Framework :: AsyncIO
|
|
6
6
|
Classifier: Intended Audience :: Developers
|
|
@@ -184,6 +184,9 @@ No duplicate schema definitions.
|
|
|
184
184
|
| `max_length` | `int \| None` | Emit `VARCHAR(n)` instead of `TEXT`. |
|
|
185
185
|
| `max_digits` / `decimal_places` | `int \| None` | Emit `NUMERIC(p,s)`. |
|
|
186
186
|
| `vector_dimensions` | `int \| None` | Required for `Vector` columns; emits `VECTOR(n)`. |
|
|
187
|
+
| `jsonb_list` | `bool` | Store `list[T]` as JSONB instead of a PostgreSQL ARRAY. |
|
|
188
|
+
| `generated` | `bool` | Select/hydrate the DB-generated column, but exclude it from writes. |
|
|
189
|
+
| `read_only` | `bool` | Select/hydrate the column, but reject explicit write assignments. |
|
|
187
190
|
|
|
188
191
|
```python
|
|
189
192
|
from datetime import datetime
|
|
@@ -224,6 +227,31 @@ users = await (
|
|
|
224
227
|
)
|
|
225
228
|
```
|
|
226
229
|
|
|
230
|
+
Typed aggregates and bounded PostgreSQL streaming use the same metadata allowlists
|
|
231
|
+
and bound-parameter compiler path:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
from ferrum.queryset import Aggregate
|
|
235
|
+
|
|
236
|
+
counts = await (
|
|
237
|
+
Ticket.objects
|
|
238
|
+
.group_by("team_id")
|
|
239
|
+
.date_trunc("created_at", "day", alias="day")
|
|
240
|
+
.having(total__gte=10)
|
|
241
|
+
.aggregate(conn, total=Aggregate.count())
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
async with Ticket.objects.filter(active=True).stream(conn, chunk_size=500) as chunks:
|
|
245
|
+
async for tickets in chunks:
|
|
246
|
+
await process(tickets)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`stream()` preserves projection/deferred/value-queryset materialization and
|
|
250
|
+
deterministically releases its cursor on exhaustion, early break, cancellation, or
|
|
251
|
+
error. It rejects `prefetch_related()` because cross-chunk relationship batching is
|
|
252
|
+
not safe. Filtered `update_returning()` provides atomic compare-and-set semantics:
|
|
253
|
+
encode the expected state in `filter()` and treat an empty returned list as a lost race.
|
|
254
|
+
|
|
227
255
|
### First-Class IDE Support
|
|
228
256
|
|
|
229
257
|
Ferrum ships a PEP 561 `py.typed` marker, so editors and type checkers (`mypy`, `pyright`,
|
|
@@ -354,6 +382,8 @@ and [API Reference](docs/api-reference.md) for per-dialect DDL and operator mapp
|
|
|
354
382
|
- [x] pgvector similarity score projection (`vector_search` helper)
|
|
355
383
|
- [x] `Field(db_default=..., nullable=...)` — first-class DB-side defaults and nullability override
|
|
356
384
|
- [x] `makemigrations` autodiff for index / default / nullability changes on existing tables
|
|
385
|
+
- [x] Typed aggregates, filtered `UPDATE … RETURNING`, and bounded QuerySet streaming
|
|
386
|
+
- [x] JSONB-list/generated/read-only metadata and read-only PostgreSQL schema drift reports
|
|
357
387
|
- [ ] Query optimization (deferred fields, prefetch tuning)
|
|
358
388
|
- [ ] Advanced relationship loading
|
|
359
389
|
|
|
@@ -112,6 +112,9 @@ No duplicate schema definitions.
|
|
|
112
112
|
| `max_length` | `int \| None` | Emit `VARCHAR(n)` instead of `TEXT`. |
|
|
113
113
|
| `max_digits` / `decimal_places` | `int \| None` | Emit `NUMERIC(p,s)`. |
|
|
114
114
|
| `vector_dimensions` | `int \| None` | Required for `Vector` columns; emits `VECTOR(n)`. |
|
|
115
|
+
| `jsonb_list` | `bool` | Store `list[T]` as JSONB instead of a PostgreSQL ARRAY. |
|
|
116
|
+
| `generated` | `bool` | Select/hydrate the DB-generated column, but exclude it from writes. |
|
|
117
|
+
| `read_only` | `bool` | Select/hydrate the column, but reject explicit write assignments. |
|
|
115
118
|
|
|
116
119
|
```python
|
|
117
120
|
from datetime import datetime
|
|
@@ -152,6 +155,31 @@ users = await (
|
|
|
152
155
|
)
|
|
153
156
|
```
|
|
154
157
|
|
|
158
|
+
Typed aggregates and bounded PostgreSQL streaming use the same metadata allowlists
|
|
159
|
+
and bound-parameter compiler path:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from ferrum.queryset import Aggregate
|
|
163
|
+
|
|
164
|
+
counts = await (
|
|
165
|
+
Ticket.objects
|
|
166
|
+
.group_by("team_id")
|
|
167
|
+
.date_trunc("created_at", "day", alias="day")
|
|
168
|
+
.having(total__gte=10)
|
|
169
|
+
.aggregate(conn, total=Aggregate.count())
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async with Ticket.objects.filter(active=True).stream(conn, chunk_size=500) as chunks:
|
|
173
|
+
async for tickets in chunks:
|
|
174
|
+
await process(tickets)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`stream()` preserves projection/deferred/value-queryset materialization and
|
|
178
|
+
deterministically releases its cursor on exhaustion, early break, cancellation, or
|
|
179
|
+
error. It rejects `prefetch_related()` because cross-chunk relationship batching is
|
|
180
|
+
not safe. Filtered `update_returning()` provides atomic compare-and-set semantics:
|
|
181
|
+
encode the expected state in `filter()` and treat an empty returned list as a lost race.
|
|
182
|
+
|
|
155
183
|
### First-Class IDE Support
|
|
156
184
|
|
|
157
185
|
Ferrum ships a PEP 561 `py.typed` marker, so editors and type checkers (`mypy`, `pyright`,
|
|
@@ -282,6 +310,8 @@ and [API Reference](docs/api-reference.md) for per-dialect DDL and operator mapp
|
|
|
282
310
|
- [x] pgvector similarity score projection (`vector_search` helper)
|
|
283
311
|
- [x] `Field(db_default=..., nullable=...)` — first-class DB-side defaults and nullability override
|
|
284
312
|
- [x] `makemigrations` autodiff for index / default / nullability changes on existing tables
|
|
313
|
+
- [x] Typed aggregates, filtered `UPDATE … RETURNING`, and bounded QuerySet streaming
|
|
314
|
+
- [x] JSONB-list/generated/read-only metadata and read-only PostgreSQL schema drift reports
|
|
285
315
|
- [ ] Query optimization (deferred fields, prefetch tuning)
|
|
286
316
|
- [ ] Advanced relationship loading
|
|
287
317
|
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
use crate::{
|
|
19
19
|
error::CompileError,
|
|
20
20
|
ir::{
|
|
21
|
-
metadata::FieldType,
|
|
22
|
-
SortDirection, IR_VERSION,
|
|
21
|
+
metadata::FieldType, AggregateFunction, BindValue, GroupExpression, JoinSpec,
|
|
22
|
+
ModelMetadata, Operation, Predicate, QuerySetIR, SortDirection, IR_VERSION,
|
|
23
23
|
},
|
|
24
24
|
};
|
|
25
25
|
|
|
@@ -142,6 +142,7 @@ pub fn compile(metadata: &ModelMetadata, ir: &QuerySetIR) -> Result<CompiledQuer
|
|
|
142
142
|
validate_order_by(metadata, ir)?;
|
|
143
143
|
validate_vector_order_by(metadata, ir)?;
|
|
144
144
|
validate_text_search(metadata, ir)?;
|
|
145
|
+
validate_aggregation(metadata, ir)?;
|
|
145
146
|
|
|
146
147
|
// Validation passed. Return empty CompiledQuery — sql_text and bound_params
|
|
147
148
|
// are populated by the SQL emitter in `ferrum-sql::emit`.
|
|
@@ -153,6 +154,144 @@ pub fn compile(metadata: &ModelMetadata, ir: &QuerySetIR) -> Result<CompiledQuer
|
|
|
153
154
|
})
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
fn validate_field_ref<'a>(
|
|
158
|
+
metadata: &'a ModelMetadata,
|
|
159
|
+
field_ref: &crate::ir::FieldRef,
|
|
160
|
+
) -> Result<&'a crate::ir::metadata::FieldMeta, CompileError> {
|
|
161
|
+
metadata
|
|
162
|
+
.fields
|
|
163
|
+
.get(field_ref.index)
|
|
164
|
+
.ok_or_else(|| CompileError::UnknownField {
|
|
165
|
+
model: metadata.model_name.clone(),
|
|
166
|
+
field: field_ref.name.clone(),
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
fn validate_aggregation(metadata: &ModelMetadata, ir: &QuerySetIR) -> Result<(), CompileError> {
|
|
171
|
+
let Some(aggregation) = &ir.aggregation else {
|
|
172
|
+
return Ok(());
|
|
173
|
+
};
|
|
174
|
+
if !matches!(ir.operation, Operation::Select { .. }) {
|
|
175
|
+
return Err(CompileError::MalformedIr {
|
|
176
|
+
reason: "aggregation is only valid for select operations".into(),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if aggregation.aggregates.is_empty() {
|
|
180
|
+
return Err(CompileError::MalformedIr {
|
|
181
|
+
reason: "aggregation requires at least one aggregate expression".into(),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if !ir.joins.is_empty()
|
|
185
|
+
|| ir.vector_order_by.is_some()
|
|
186
|
+
|| ir.text_rank_by.is_some()
|
|
187
|
+
|| ir.distinct
|
|
188
|
+
|| ir.exists
|
|
189
|
+
|| !ir.order_by.is_empty()
|
|
190
|
+
{
|
|
191
|
+
return Err(CompileError::MalformedIr {
|
|
192
|
+
reason:
|
|
193
|
+
"aggregation cannot be combined with joins, ranking, distinct, exists, or order_by"
|
|
194
|
+
.into(),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for group in &aggregation.groups {
|
|
199
|
+
let field_meta = match group {
|
|
200
|
+
GroupExpression::Field { field } => validate_field_ref(metadata, field)?,
|
|
201
|
+
GroupExpression::DateTrunc { field, .. } => {
|
|
202
|
+
let field_meta = validate_field_ref(metadata, field)?;
|
|
203
|
+
if !matches!(field_meta.field_type, FieldType::Date | FieldType::Datetime) {
|
|
204
|
+
return Err(CompileError::UnsupportedOperator {
|
|
205
|
+
model: metadata.model_name.clone(),
|
|
206
|
+
field: field.name.clone(),
|
|
207
|
+
operator: "date_trunc".into(),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
field_meta
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
let _ = field_meta;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for aggregate in &aggregation.aggregates {
|
|
217
|
+
validate_aggregate(metadata, aggregate, &ir.joins)?;
|
|
218
|
+
}
|
|
219
|
+
for having in &aggregation.having {
|
|
220
|
+
if having.aggregate_index >= aggregation.aggregates.len() {
|
|
221
|
+
return Err(CompileError::MalformedIr {
|
|
222
|
+
reason: "having aggregate_index is out of range".into(),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
Ok(())
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
fn validate_aggregate(
|
|
230
|
+
metadata: &ModelMetadata,
|
|
231
|
+
aggregate: &crate::ir::AggregateExpression,
|
|
232
|
+
joins: &[JoinSpec],
|
|
233
|
+
) -> Result<(), CompileError> {
|
|
234
|
+
let field_meta = aggregate
|
|
235
|
+
.field
|
|
236
|
+
.as_ref()
|
|
237
|
+
.map(|field| validate_field_ref(metadata, field))
|
|
238
|
+
.transpose()?;
|
|
239
|
+
match aggregate.function {
|
|
240
|
+
AggregateFunction::Count => {}
|
|
241
|
+
AggregateFunction::Sum | AggregateFunction::Avg => {
|
|
242
|
+
let Some(field_meta) = field_meta else {
|
|
243
|
+
return Err(CompileError::MalformedIr {
|
|
244
|
+
reason: "sum and avg require a field".into(),
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
if !matches!(
|
|
248
|
+
field_meta.field_type,
|
|
249
|
+
FieldType::Int | FieldType::BigInt | FieldType::Float | FieldType::Decimal
|
|
250
|
+
) {
|
|
251
|
+
return Err(CompileError::UnsupportedOperator {
|
|
252
|
+
model: metadata.model_name.clone(),
|
|
253
|
+
field: field_meta.name.clone(),
|
|
254
|
+
operator: format!("{:?}", aggregate.function).to_lowercase(),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
AggregateFunction::Min | AggregateFunction::Max => {
|
|
259
|
+
let Some(field_meta) = field_meta else {
|
|
260
|
+
return Err(CompileError::MalformedIr {
|
|
261
|
+
reason: "min and max require a field".into(),
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
if matches!(
|
|
265
|
+
field_meta.field_type,
|
|
266
|
+
FieldType::Json
|
|
267
|
+
| FieldType::Bytes
|
|
268
|
+
| FieldType::Vector
|
|
269
|
+
| FieldType::ArrayText
|
|
270
|
+
| FieldType::ArrayUuid
|
|
271
|
+
| FieldType::ArrayInt
|
|
272
|
+
| FieldType::ArrayFloat
|
|
273
|
+
| FieldType::TsVector
|
|
274
|
+
) {
|
|
275
|
+
return Err(CompileError::UnsupportedOperator {
|
|
276
|
+
model: metadata.model_name.clone(),
|
|
277
|
+
field: field_meta.name.clone(),
|
|
278
|
+
operator: format!("{:?}", aggregate.function).to_lowercase(),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if aggregate.function != AggregateFunction::Count && aggregate.field.is_none() {
|
|
284
|
+
return Err(CompileError::MalformedIr {
|
|
285
|
+
reason: "only count may omit a field".into(),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if let Some(predicate) = &aggregate.filter {
|
|
289
|
+
validate_predicate(metadata, predicate, joins)?;
|
|
290
|
+
validate_predicate_fts(metadata, predicate)?;
|
|
291
|
+
}
|
|
292
|
+
Ok(())
|
|
293
|
+
}
|
|
294
|
+
|
|
156
295
|
fn validate_joins(metadata: &ModelMetadata, ir: &QuerySetIR) -> Result<(), CompileError> {
|
|
157
296
|
for join in &ir.joins {
|
|
158
297
|
metadata
|
|
@@ -622,6 +761,7 @@ mod tests {
|
|
|
622
761
|
distinct: false,
|
|
623
762
|
exists: false,
|
|
624
763
|
joins: vec![],
|
|
764
|
+
aggregation: None,
|
|
625
765
|
}
|
|
626
766
|
}
|
|
627
767
|
|
|
@@ -803,6 +943,53 @@ mod tests {
|
|
|
803
943
|
assert!(compile(&meta, &ir).is_ok());
|
|
804
944
|
}
|
|
805
945
|
|
|
946
|
+
#[test]
|
|
947
|
+
fn rejects_sum_on_text_field() {
|
|
948
|
+
let meta = make_metadata();
|
|
949
|
+
let mut ir = base_ir("User");
|
|
950
|
+
ir.operation = Operation::Select { fields: vec![] };
|
|
951
|
+
ir.aggregation = Some(crate::ir::Aggregation {
|
|
952
|
+
groups: vec![],
|
|
953
|
+
aggregates: vec![crate::ir::AggregateExpression {
|
|
954
|
+
function: crate::ir::AggregateFunction::Sum,
|
|
955
|
+
field: Some(FieldRef {
|
|
956
|
+
name: "email".into(),
|
|
957
|
+
index: 1,
|
|
958
|
+
}),
|
|
959
|
+
filter: None,
|
|
960
|
+
}],
|
|
961
|
+
having: vec![],
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
let error = compile(&meta, &ir).unwrap_err();
|
|
965
|
+
|
|
966
|
+
assert!(matches!(error, CompileError::UnsupportedOperator { .. }));
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
#[test]
|
|
970
|
+
fn rejects_having_unknown_aggregate_index() {
|
|
971
|
+
let meta = make_metadata();
|
|
972
|
+
let mut ir = base_ir("User");
|
|
973
|
+
ir.operation = Operation::Select { fields: vec![] };
|
|
974
|
+
ir.aggregation = Some(crate::ir::Aggregation {
|
|
975
|
+
groups: vec![],
|
|
976
|
+
aggregates: vec![crate::ir::AggregateExpression {
|
|
977
|
+
function: crate::ir::AggregateFunction::Count,
|
|
978
|
+
field: None,
|
|
979
|
+
filter: None,
|
|
980
|
+
}],
|
|
981
|
+
having: vec![crate::ir::Having {
|
|
982
|
+
aggregate_index: 4,
|
|
983
|
+
operator: crate::ir::HavingOperator::Gt,
|
|
984
|
+
value: BindValue::Int(0),
|
|
985
|
+
}],
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
let error = compile(&meta, &ir).unwrap_err();
|
|
989
|
+
|
|
990
|
+
assert!(matches!(error, CompileError::MalformedIr { .. }));
|
|
991
|
+
}
|
|
992
|
+
|
|
806
993
|
#[test]
|
|
807
994
|
fn query_fingerprint_contains_operation_and_model() {
|
|
808
995
|
let meta = make_metadata();
|
|
@@ -836,6 +1023,7 @@ mod tests {
|
|
|
836
1023
|
distinct: false,
|
|
837
1024
|
exists: false,
|
|
838
1025
|
joins: vec![],
|
|
1026
|
+
aggregation: None,
|
|
839
1027
|
};
|
|
840
1028
|
assert!(compile(&meta, &ir).is_ok());
|
|
841
1029
|
}
|
|
@@ -865,6 +1053,7 @@ mod tests {
|
|
|
865
1053
|
distinct: false,
|
|
866
1054
|
exists: false,
|
|
867
1055
|
joins: vec![],
|
|
1056
|
+
aggregation: None,
|
|
868
1057
|
};
|
|
869
1058
|
assert!(matches!(
|
|
870
1059
|
compile(&meta, &ir).unwrap_err(),
|
|
@@ -889,6 +1078,7 @@ mod tests {
|
|
|
889
1078
|
distinct: false,
|
|
890
1079
|
exists: false,
|
|
891
1080
|
joins: vec![],
|
|
1081
|
+
aggregation: None,
|
|
892
1082
|
};
|
|
893
1083
|
assert!(matches!(
|
|
894
1084
|
compile(&meta, &ir).unwrap_err(),
|
|
@@ -922,6 +1112,7 @@ mod tests {
|
|
|
922
1112
|
distinct: false,
|
|
923
1113
|
exists: false,
|
|
924
1114
|
joins: vec![],
|
|
1115
|
+
aggregation: None,
|
|
925
1116
|
};
|
|
926
1117
|
assert!(matches!(
|
|
927
1118
|
compile(&meta, &ir).unwrap_err(),
|
|
@@ -15,7 +15,7 @@ pub mod metadata;
|
|
|
15
15
|
pub use metadata::ModelMetadata;
|
|
16
16
|
|
|
17
17
|
/// Version of the IR contract this crate implements.
|
|
18
|
-
pub const IR_VERSION: u32 =
|
|
18
|
+
pub const IR_VERSION: u32 = 4;
|
|
19
19
|
|
|
20
20
|
/// The root IR node produced by `QuerySet._build_ir()` on the Python side.
|
|
21
21
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
@@ -65,6 +65,90 @@ pub struct QuerySetIR {
|
|
|
65
65
|
/// To-one JOINs for ``select_related()`` (validated on the Python side).
|
|
66
66
|
#[serde(default)]
|
|
67
67
|
pub joins: Vec<JoinSpec>,
|
|
68
|
+
|
|
69
|
+
/// Optional typed aggregate projection.
|
|
70
|
+
#[serde(default)]
|
|
71
|
+
pub aggregation: Option<Aggregation>,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Aggregate projection for grouped and scalar aggregate queries.
|
|
75
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
76
|
+
pub struct Aggregation {
|
|
77
|
+
/// GROUP BY expressions, emitted in this order.
|
|
78
|
+
pub groups: Vec<GroupExpression>,
|
|
79
|
+
/// Aggregate expressions, emitted after grouping keys.
|
|
80
|
+
pub aggregates: Vec<AggregateExpression>,
|
|
81
|
+
/// HAVING constraints referencing aggregate positions.
|
|
82
|
+
#[serde(default)]
|
|
83
|
+
pub having: Vec<Having>,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// A metadata-backed GROUP BY expression.
|
|
87
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
88
|
+
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
89
|
+
pub enum GroupExpression {
|
|
90
|
+
/// Group directly by a model field.
|
|
91
|
+
Field { field: FieldRef },
|
|
92
|
+
/// Group a date/datetime field by a fixed truncation bucket.
|
|
93
|
+
DateTrunc {
|
|
94
|
+
field: FieldRef,
|
|
95
|
+
granularity: DateTruncGranularity,
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Fixed `DATE_TRUNC` buckets. Unknown input is rejected during deserialization.
|
|
100
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
101
|
+
#[serde(rename_all = "snake_case")]
|
|
102
|
+
pub enum DateTruncGranularity {
|
|
103
|
+
Minute,
|
|
104
|
+
Hour,
|
|
105
|
+
Day,
|
|
106
|
+
Week,
|
|
107
|
+
Month,
|
|
108
|
+
Quarter,
|
|
109
|
+
Year,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// One typed aggregate projection.
|
|
113
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
114
|
+
pub struct AggregateExpression {
|
|
115
|
+
pub function: AggregateFunction,
|
|
116
|
+
/// COUNT may omit a field to emit COUNT(*); all other functions require one.
|
|
117
|
+
pub field: Option<FieldRef>,
|
|
118
|
+
/// `PostgreSQL` FILTER predicate, validated through the normal predicate path.
|
|
119
|
+
#[serde(default)]
|
|
120
|
+
pub filter: Option<Predicate>,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Aggregate functions supported by Ferrum.
|
|
124
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
125
|
+
#[serde(rename_all = "snake_case")]
|
|
126
|
+
pub enum AggregateFunction {
|
|
127
|
+
Count,
|
|
128
|
+
Sum,
|
|
129
|
+
Avg,
|
|
130
|
+
Min,
|
|
131
|
+
Max,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// A HAVING constraint against an aggregate by zero-based projection index.
|
|
135
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
136
|
+
pub struct Having {
|
|
137
|
+
pub aggregate_index: usize,
|
|
138
|
+
pub operator: HavingOperator,
|
|
139
|
+
pub value: BindValue,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// Fixed HAVING comparison operators.
|
|
143
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
144
|
+
#[serde(rename_all = "snake_case")]
|
|
145
|
+
pub enum HavingOperator {
|
|
146
|
+
Eq,
|
|
147
|
+
Ne,
|
|
148
|
+
Gt,
|
|
149
|
+
Gte,
|
|
150
|
+
Lt,
|
|
151
|
+
Lte,
|
|
68
152
|
}
|
|
69
153
|
|
|
70
154
|
/// JOIN kind for ``select_related`` (LEFT) vs relation-filter (INNER) joins.
|
|
@@ -124,6 +208,9 @@ pub struct JoinFieldRef {
|
|
|
124
208
|
/// Operator allowlist copied from the remote field's ``FieldMeta`` (SQL-2).
|
|
125
209
|
#[serde(default)]
|
|
126
210
|
pub allowed_operators: Vec<String>,
|
|
211
|
+
/// Remote field type used for type-specific SQL operator emission.
|
|
212
|
+
#[serde(default)]
|
|
213
|
+
pub field_type: Option<metadata::FieldType>,
|
|
127
214
|
}
|
|
128
215
|
|
|
129
216
|
/// The SQL operation this IR node represents.
|