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.
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/Cargo.lock +4 -4
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/Cargo.toml +1 -1
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/PKG-INFO +31 -1
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/README.md +30 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/src/compile/mod.rs +250 -13
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/src/ir/mod.rs +127 -12
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/benches/compile.rs +3 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/emit.rs +397 -50
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/pyproject.toml +1 -1
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/__init__.py +6 -1
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/connection.py +114 -4
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/drivers/postgres.py +47 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/drivers/protocol.py +40 -0
- ferrum_orm-0.1.13/python/ferrum/drivers/streaming.py +118 -0
- ferrum_orm-0.1.13/python/ferrum/echo.py +114 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/ext/pgvector.py +23 -7
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/__init__.py +10 -0
- ferrum_orm-0.1.13/python/ferrum/migrations/drift.py +377 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/models.py +178 -46
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/queryset.py +884 -88
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/relations.py +20 -2
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/runtime.py +53 -1
- ferrum_orm-0.1.9/python/ferrum/migrations/drift.py +0 -115
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/LICENSE +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/Cargo.toml +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/benches/hydrate.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/src/error.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/src/ir/metadata.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/src/lib.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-core/src/migrate/mod.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-pyo3/Cargo.toml +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-pyo3/src/lib.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/Cargo.toml +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/dialect.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/fts/mod.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/crates/ferrum-sql/src/lib.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/_native.pyi +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/__init__.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/app.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/bootstrap.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/init.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/inspectdb_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/makemigrations_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/migrate_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/migrations_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/resetdb_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/revert_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/showmigrations_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/config.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/contrib/__init__.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/contrib/fastapi.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/drivers/__init__.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/drivers/mssql.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/drivers/mysql.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/drivers/sqlite.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/errors.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/expressions.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/ext/__init__.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/ext/fts.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/hooks.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/base.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/fts/__init__.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/fts/mssql.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/fts/mysql.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/fts/postgres.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/fts/sqlite.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/gates.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/introspect.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/ledger.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/loader.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/operations.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/orchestrator.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/migrations/tokens.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/observability.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/py.typed +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.13}/python/ferrum/registry.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
258
|
+
version = "0.1.13"
|
|
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.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,
|
|
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
|
-
//
|
|
323
|
-
//
|
|
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(
|
|
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(),
|