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.
Files changed (83) hide show
  1. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/Cargo.lock +4 -4
  2. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/Cargo.toml +1 -1
  3. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/PKG-INFO +31 -1
  4. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/README.md +30 -0
  5. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/src/compile/mod.rs +193 -2
  6. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/src/ir/mod.rs +88 -1
  7. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/benches/compile.rs +1 -0
  8. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/emit.rs +324 -19
  9. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/pyproject.toml +1 -1
  10. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/__init__.py +3 -1
  11. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/connection.py +92 -2
  12. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/drivers/postgres.py +47 -0
  13. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/drivers/protocol.py +40 -0
  14. ferrum_orm-0.1.14/python/ferrum/drivers/streaming.py +118 -0
  15. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/ext/pgvector.py +36 -8
  16. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/__init__.py +10 -0
  17. ferrum_orm-0.1.14/python/ferrum/migrations/drift.py +377 -0
  18. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/models.py +207 -65
  19. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/queryset.py +655 -59
  20. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/relations.py +1 -0
  21. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/runtime.py +53 -1
  22. ferrum_orm-0.1.11/python/ferrum/migrations/drift.py +0 -115
  23. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/LICENSE +0 -0
  24. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/Cargo.toml +0 -0
  25. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/benches/hydrate.rs +0 -0
  26. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/src/error.rs +0 -0
  27. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
  28. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/src/ir/metadata.rs +0 -0
  29. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/src/lib.rs +0 -0
  30. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-core/src/migrate/mod.rs +0 -0
  31. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-pyo3/Cargo.toml +0 -0
  32. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-pyo3/src/lib.rs +0 -0
  33. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/Cargo.toml +0 -0
  34. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/dialect.rs +0 -0
  35. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/fts/mod.rs +0 -0
  36. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
  37. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
  38. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
  39. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
  40. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/lib.rs +0 -0
  41. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/_native.pyi +0 -0
  42. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/__init__.py +0 -0
  43. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/app.py +0 -0
  44. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/bootstrap.py +0 -0
  45. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/init.py +0 -0
  46. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/inspectdb_cmd.py +0 -0
  47. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/makemigrations_cmd.py +0 -0
  48. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/migrate_cmd.py +0 -0
  49. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/migrations_cmd.py +0 -0
  50. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/resetdb_cmd.py +0 -0
  51. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/revert_cmd.py +0 -0
  52. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/showmigrations_cmd.py +0 -0
  53. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
  54. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/config.py +0 -0
  55. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/contrib/__init__.py +0 -0
  56. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/contrib/fastapi.py +0 -0
  57. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/drivers/__init__.py +0 -0
  58. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/drivers/mssql.py +0 -0
  59. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/drivers/mysql.py +0 -0
  60. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/drivers/sqlite.py +0 -0
  61. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/echo.py +0 -0
  62. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/errors.py +0 -0
  63. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/expressions.py +0 -0
  64. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/ext/__init__.py +0 -0
  65. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/ext/fts.py +0 -0
  66. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/hooks.py +0 -0
  67. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/base.py +0 -0
  68. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/fts/__init__.py +0 -0
  69. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/fts/mssql.py +0 -0
  70. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/fts/mysql.py +0 -0
  71. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/fts/postgres.py +0 -0
  72. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/fts/sqlite.py +0 -0
  73. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/gates.py +0 -0
  74. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/introspect.py +0 -0
  75. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/ledger.py +0 -0
  76. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/loader.py +0 -0
  77. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/operations.py +0 -0
  78. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/orchestrator.py +0 -0
  79. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/migrations/tokens.py +0 -0
  80. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/observability.py +0 -0
  81. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/py.typed +0 -0
  82. {ferrum_orm-0.1.11 → ferrum_orm-0.1.14}/python/ferrum/registry.py +0 -0
  83. {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.11"
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.11"
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.11"
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.11"
258
+ version = "0.1.14"
259
259
  dependencies = [
260
260
  "criterion",
261
261
  "ferrum-core",
@@ -3,7 +3,7 @@ members = ["crates/ferrum-core", "crates/ferrum-sql", "crates/ferrum-pyo3"]
3
3
  resolver = "2"
4
4
 
5
5
  [workspace.package]
6
- version = "0.1.11"
6
+ version = "0.1.14"
7
7
  edition = "2021"
8
8
  authors = ["Ferrum Contributors"]
9
9
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferrum-orm
3
- Version: 0.1.11
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, BindValue, JoinSpec, ModelMetadata, Operation, Predicate, QuerySetIR,
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 = 3;
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.
@@ -127,6 +127,7 @@ fn bench_select_ir() -> QuerySetIR {
127
127
  distinct: false,
128
128
  exists: false,
129
129
  joins: vec![],
130
+ aggregation: None,
130
131
  }
131
132
  }
132
133