ferrum-orm 0.1.9__tar.gz → 0.1.13__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.9 → ferrum_orm-0.1.13}/Cargo.lock +4 -4
  2. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/Cargo.toml +1 -1
  3. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/PKG-INFO +31 -1
  4. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/README.md +30 -0
  5. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/src/compile/mod.rs +250 -13
  6. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/src/ir/mod.rs +127 -12
  7. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/benches/compile.rs +3 -0
  8. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/emit.rs +397 -50
  9. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/pyproject.toml +1 -1
  10. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/__init__.py +6 -1
  11. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/connection.py +114 -4
  12. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/drivers/postgres.py +47 -0
  13. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/drivers/protocol.py +40 -0
  14. ferrum_orm-0.1.13/python/ferrum/drivers/streaming.py +118 -0
  15. ferrum_orm-0.1.13/python/ferrum/echo.py +114 -0
  16. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/ext/pgvector.py +23 -7
  17. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/__init__.py +10 -0
  18. ferrum_orm-0.1.13/python/ferrum/migrations/drift.py +377 -0
  19. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/models.py +178 -46
  20. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/queryset.py +884 -88
  21. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/relations.py +20 -2
  22. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/runtime.py +53 -1
  23. ferrum_orm-0.1.9/python/ferrum/migrations/drift.py +0 -115
  24. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/LICENSE +0 -0
  25. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/Cargo.toml +0 -0
  26. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/benches/hydrate.rs +0 -0
  27. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/src/error.rs +0 -0
  28. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
  29. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/src/ir/metadata.rs +0 -0
  30. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/src/lib.rs +0 -0
  31. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/src/migrate/mod.rs +0 -0
  32. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-pyo3/Cargo.toml +0 -0
  33. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-pyo3/src/lib.rs +0 -0
  34. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/Cargo.toml +0 -0
  35. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/dialect.rs +0 -0
  36. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/fts/mod.rs +0 -0
  37. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
  38. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
  39. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
  40. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
  41. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/lib.rs +0 -0
  42. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/_native.pyi +0 -0
  43. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/__init__.py +0 -0
  44. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/app.py +0 -0
  45. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/bootstrap.py +0 -0
  46. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/init.py +0 -0
  47. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/inspectdb_cmd.py +0 -0
  48. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/makemigrations_cmd.py +0 -0
  49. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/migrate_cmd.py +0 -0
  50. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/migrations_cmd.py +0 -0
  51. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/resetdb_cmd.py +0 -0
  52. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/revert_cmd.py +0 -0
  53. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/showmigrations_cmd.py +0 -0
  54. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
  55. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/config.py +0 -0
  56. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/contrib/__init__.py +0 -0
  57. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/contrib/fastapi.py +0 -0
  58. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/drivers/__init__.py +0 -0
  59. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/drivers/mssql.py +0 -0
  60. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/drivers/mysql.py +0 -0
  61. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/drivers/sqlite.py +0 -0
  62. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/errors.py +0 -0
  63. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/expressions.py +0 -0
  64. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/ext/__init__.py +0 -0
  65. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/ext/fts.py +0 -0
  66. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/hooks.py +0 -0
  67. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/base.py +0 -0
  68. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/fts/__init__.py +0 -0
  69. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/fts/mssql.py +0 -0
  70. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/fts/mysql.py +0 -0
  71. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/fts/postgres.py +0 -0
  72. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/fts/sqlite.py +0 -0
  73. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/gates.py +0 -0
  74. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/introspect.py +0 -0
  75. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/ledger.py +0 -0
  76. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/loader.py +0 -0
  77. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/operations.py +0 -0
  78. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/orchestrator.py +0 -0
  79. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/tokens.py +0 -0
  80. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/observability.py +0 -0
  81. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/py.typed +0 -0
  82. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/registry.py +0 -0
  83. {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/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.9"
226
+ version = "0.1.13"
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.9"
237
+ version = "0.1.13"
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.9"
246
+ version = "0.1.13"
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.9"
258
+ version = "0.1.13"
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.9"
6
+ version = "0.1.13"
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.9
3
+ Version: 0.1.13
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, 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
 
@@ -127,19 +127,22 @@ 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)?;
145
+ validate_aggregation(metadata, ir)?;
143
146
 
144
147
  // Validation passed. Return empty CompiledQuery — sql_text and bound_params
145
148
  // are populated by the SQL emitter in `ferrum-sql::emit`.
@@ -151,6 +154,144 @@ pub fn compile(metadata: &ModelMetadata, ir: &QuerySetIR) -> Result<CompiledQuer
151
154
  })
152
155
  }
153
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
+
154
295
  fn validate_joins(metadata: &ModelMetadata, ir: &QuerySetIR) -> Result<(), CompileError> {
155
296
  for join in &ir.joins {
156
297
  metadata
@@ -319,14 +460,15 @@ fn validate_vector_order_by(metadata: &ModelMetadata, ir: &QuerySetIR) -> Result
319
460
  operator: "nearest_to".into(),
320
461
  });
321
462
  }
322
- // Accept both float_array (canonical vector type) and int_array (integer
323
- // dims encoded by _encode_bind_value when the caller passes a list[int]).
463
+ // Prefer text (pgvector literal ``[f,f,...]`` + SQL ``::vector`` cast) so
464
+ // asyncpg does not bind ``list[float]`` as ``float[]`` (DataError). Still
465
+ // accept float_array / int_array for backwards-compatible IR producers.
324
466
  if !matches!(
325
467
  vector_order.value,
326
- BindValue::FloatArray(_) | BindValue::IntArray(_)
468
+ BindValue::Text(_) | BindValue::FloatArray(_) | BindValue::IntArray(_)
327
469
  ) {
328
470
  return Err(CompileError::MalformedIr {
329
- reason: "vector_order_by.value must be float_array or int_array".into(),
471
+ reason: "vector_order_by.value must be text, float_array, or int_array".into(),
330
472
  });
331
473
  }
332
474
  Ok(())
@@ -466,6 +608,11 @@ fn validate_predicate_fts(
466
608
  Predicate::Not { child } => validate_predicate_fts(metadata, child)?,
467
609
  Predicate::Filter { filter } => {
468
610
  if FTS_OPERATORS.contains(&filter.operator.as_str()) {
611
+ if filter.join_alias.is_some() {
612
+ return Err(CompileError::MalformedIr {
613
+ reason: "full-text operators are not supported on relation lookups".into(),
614
+ });
615
+ }
469
616
  validate_fts_filter(metadata, filter)?;
470
617
  }
471
618
  }
@@ -482,7 +629,36 @@ pub fn ir_has_where_clause(ir: &QuerySetIR) -> bool {
482
629
  fn validate_filter(
483
630
  metadata: &ModelMetadata,
484
631
  filter: &crate::ir::Filter,
632
+ joins: &[JoinSpec],
485
633
  ) -> Result<(), CompileError> {
634
+ if let Some(alias) = &filter.join_alias {
635
+ let join =
636
+ joins
637
+ .iter()
638
+ .find(|j| j.alias == *alias)
639
+ .ok_or_else(|| CompileError::MalformedIr {
640
+ reason: format!("filter join_alias {alias:?} has no matching join"),
641
+ })?;
642
+ let remote = join
643
+ .remote_fields
644
+ .iter()
645
+ .find(|f| f.name == filter.field.name || f.index == filter.field.index)
646
+ .ok_or_else(|| CompileError::UnknownField {
647
+ model: metadata.model_name.clone(),
648
+ field: format!("{alias}__{}", filter.field.name),
649
+ })?;
650
+ if !remote.allowed_operators.is_empty()
651
+ && !remote.allowed_operators.contains(&filter.operator)
652
+ {
653
+ return Err(CompileError::UnsupportedOperator {
654
+ model: metadata.model_name.clone(),
655
+ field: format!("{alias}__{}", filter.field.name),
656
+ operator: filter.operator.clone(),
657
+ });
658
+ }
659
+ return Ok(());
660
+ }
661
+
486
662
  let field_meta =
487
663
  metadata
488
664
  .fields
@@ -502,15 +678,19 @@ fn validate_filter(
502
678
  Ok(())
503
679
  }
504
680
 
505
- fn validate_predicate(metadata: &ModelMetadata, predicate: &Predicate) -> Result<(), CompileError> {
681
+ fn validate_predicate(
682
+ metadata: &ModelMetadata,
683
+ predicate: &Predicate,
684
+ joins: &[JoinSpec],
685
+ ) -> Result<(), CompileError> {
506
686
  match predicate {
507
687
  Predicate::And { children } | Predicate::Or { children } => {
508
688
  for child in children {
509
- validate_predicate(metadata, child)?;
689
+ validate_predicate(metadata, child, joins)?;
510
690
  }
511
691
  }
512
- Predicate::Not { child } => validate_predicate(metadata, child)?,
513
- Predicate::Filter { filter } => validate_filter(metadata, filter)?,
692
+ Predicate::Not { child } => validate_predicate(metadata, child, joins)?,
693
+ Predicate::Filter { filter } => validate_filter(metadata, filter, joins)?,
514
694
  }
515
695
  Ok(())
516
696
  }
@@ -581,6 +761,7 @@ mod tests {
581
761
  distinct: false,
582
762
  exists: false,
583
763
  joins: vec![],
764
+ aggregation: None,
584
765
  }
585
766
  }
586
767
 
@@ -604,6 +785,7 @@ mod tests {
604
785
  },
605
786
  operator: "eq".into(),
606
787
  value: BindValue::Int(1),
788
+ join_alias: None,
607
789
  });
608
790
  let err = compile(&meta, &ir).unwrap_err();
609
791
  assert!(matches!(err, CompileError::UnknownField { .. }));
@@ -634,6 +816,7 @@ mod tests {
634
816
  },
635
817
  operator: "regex".into(), // not in allowed_operators
636
818
  value: BindValue::Int(1),
819
+ join_alias: None,
637
820
  });
638
821
  let err = compile(&meta, &ir).unwrap_err();
639
822
  assert!(matches!(err, CompileError::UnsupportedOperator { .. }));
@@ -679,6 +862,7 @@ mod tests {
679
862
  },
680
863
  operator: "match".into(),
681
864
  value: BindValue::Text("hello".into()),
865
+ join_alias: None,
682
866
  });
683
867
  let err = compile(&meta, &ir).unwrap_err();
684
868
  assert!(matches!(err, CompileError::UnsupportedOperator { .. }));
@@ -723,6 +907,7 @@ mod tests {
723
907
  },
724
908
  operator: "match_phrase".into(),
725
909
  value: BindValue::Text("hello world".into()),
910
+ join_alias: None,
726
911
  });
727
912
  ir.text_rank_by = Some(crate::ir::TextRankBy {
728
913
  field: FieldRef {
@@ -746,6 +931,7 @@ mod tests {
746
931
  },
747
932
  operator: "eq".into(),
748
933
  value: BindValue::Text("test@example.com".into()),
934
+ join_alias: None,
749
935
  });
750
936
  ir.order_by.push(OrderBy {
751
937
  field: FieldRef {
@@ -757,6 +943,53 @@ mod tests {
757
943
  assert!(compile(&meta, &ir).is_ok());
758
944
  }
759
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
+
760
993
  #[test]
761
994
  fn query_fingerprint_contains_operation_and_model() {
762
995
  let meta = make_metadata();
@@ -790,6 +1023,7 @@ mod tests {
790
1023
  distinct: false,
791
1024
  exists: false,
792
1025
  joins: vec![],
1026
+ aggregation: None,
793
1027
  };
794
1028
  assert!(compile(&meta, &ir).is_ok());
795
1029
  }
@@ -819,6 +1053,7 @@ mod tests {
819
1053
  distinct: false,
820
1054
  exists: false,
821
1055
  joins: vec![],
1056
+ aggregation: None,
822
1057
  };
823
1058
  assert!(matches!(
824
1059
  compile(&meta, &ir).unwrap_err(),
@@ -843,6 +1078,7 @@ mod tests {
843
1078
  distinct: false,
844
1079
  exists: false,
845
1080
  joins: vec![],
1081
+ aggregation: None,
846
1082
  };
847
1083
  assert!(matches!(
848
1084
  compile(&meta, &ir).unwrap_err(),
@@ -876,6 +1112,7 @@ mod tests {
876
1112
  distinct: false,
877
1113
  exists: false,
878
1114
  joins: vec![],
1115
+ aggregation: None,
879
1116
  };
880
1117
  assert!(matches!(
881
1118
  compile(&meta, &ir).unwrap_err(),