ferrum-orm 0.1.9__tar.gz → 0.1.11__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/Cargo.lock +4 -4
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/Cargo.toml +1 -1
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/PKG-INFO +1 -1
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/src/compile/mod.rs +58 -12
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/src/ir/mod.rs +42 -11
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/benches/compile.rs +2 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/emit.rs +129 -50
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/pyproject.toml +1 -1
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/__init__.py +4 -1
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/connection.py +22 -2
- ferrum_orm-0.1.11/python/ferrum/echo.py +114 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/queryset.py +274 -34
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/relations.py +20 -2
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/LICENSE +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/README.md +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/Cargo.toml +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/benches/hydrate.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/src/error.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/src/ir/metadata.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/src/lib.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/src/migrate/mod.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-pyo3/Cargo.toml +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-pyo3/src/lib.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/Cargo.toml +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/dialect.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/mod.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/lib.rs +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/_native.pyi +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/__init__.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/app.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/bootstrap.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/init.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/inspectdb_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/makemigrations_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/migrate_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/migrations_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/resetdb_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/revert_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/showmigrations_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/config.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/contrib/__init__.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/contrib/fastapi.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/drivers/__init__.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/drivers/mssql.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/drivers/mysql.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/drivers/postgres.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/drivers/protocol.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/drivers/sqlite.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/errors.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/expressions.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/ext/__init__.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/ext/fts.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/ext/pgvector.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/hooks.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/__init__.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/base.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/drift.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/__init__.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/mssql.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/mysql.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/postgres.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/sqlite.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/gates.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/introspect.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/ledger.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/loader.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/operations.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/orchestrator.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/tokens.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/models.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/observability.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/py.typed +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/registry.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/runtime.py +0 -0
- {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/session.py +0 -0
|
@@ -223,7 +223,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
|
|
223
223
|
|
|
224
224
|
[[package]]
|
|
225
225
|
name = "ferrum-core"
|
|
226
|
-
version = "0.1.
|
|
226
|
+
version = "0.1.11"
|
|
227
227
|
dependencies = [
|
|
228
228
|
"criterion",
|
|
229
229
|
"proptest",
|
|
@@ -234,7 +234,7 @@ dependencies = [
|
|
|
234
234
|
|
|
235
235
|
[[package]]
|
|
236
236
|
name = "ferrum-migrate"
|
|
237
|
-
version = "0.1.
|
|
237
|
+
version = "0.1.11"
|
|
238
238
|
dependencies = [
|
|
239
239
|
"ferrum-core",
|
|
240
240
|
"serde",
|
|
@@ -243,7 +243,7 @@ dependencies = [
|
|
|
243
243
|
|
|
244
244
|
[[package]]
|
|
245
245
|
name = "ferrum-pyo3"
|
|
246
|
-
version = "0.1.
|
|
246
|
+
version = "0.1.11"
|
|
247
247
|
dependencies = [
|
|
248
248
|
"ferrum-core",
|
|
249
249
|
"ferrum-sql",
|
|
@@ -255,7 +255,7 @@ dependencies = [
|
|
|
255
255
|
|
|
256
256
|
[[package]]
|
|
257
257
|
name = "ferrum-sql"
|
|
258
|
-
version = "0.1.
|
|
258
|
+
version = "0.1.11"
|
|
259
259
|
dependencies = [
|
|
260
260
|
"criterion",
|
|
261
261
|
"ferrum-core",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
use crate::{
|
|
19
19
|
error::CompileError,
|
|
20
20
|
ir::{
|
|
21
|
-
metadata::FieldType, BindValue, ModelMetadata, Operation, Predicate, QuerySetIR,
|
|
21
|
+
metadata::FieldType, BindValue, JoinSpec, ModelMetadata, Operation, Predicate, QuerySetIR,
|
|
22
22
|
SortDirection, IR_VERSION,
|
|
23
23
|
},
|
|
24
24
|
};
|
|
@@ -127,16 +127,18 @@ pub fn compile(metadata: &ModelMetadata, ir: &QuerySetIR) -> Result<CompiledQuer
|
|
|
127
127
|
Operation::BulkDelete { pk_fields, ids } => validate_bulk_delete(metadata, pk_fields, ids)?,
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
// Joins first so filter ``join_alias`` references can be checked against them.
|
|
131
|
+
validate_joins(metadata, ir)?;
|
|
132
|
+
|
|
130
133
|
// Validate all field references in filters before touching SQL.
|
|
131
134
|
for filter in &ir.filters {
|
|
132
|
-
validate_filter(metadata, filter)?;
|
|
135
|
+
validate_filter(metadata, filter, &ir.joins)?;
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
if let Some(predicate) = &ir.predicate {
|
|
136
|
-
validate_predicate(metadata, predicate)?;
|
|
139
|
+
validate_predicate(metadata, predicate, &ir.joins)?;
|
|
137
140
|
}
|
|
138
141
|
|
|
139
|
-
validate_joins(metadata, ir)?;
|
|
140
142
|
validate_order_by(metadata, ir)?;
|
|
141
143
|
validate_vector_order_by(metadata, ir)?;
|
|
142
144
|
validate_text_search(metadata, ir)?;
|
|
@@ -319,14 +321,15 @@ fn validate_vector_order_by(metadata: &ModelMetadata, ir: &QuerySetIR) -> Result
|
|
|
319
321
|
operator: "nearest_to".into(),
|
|
320
322
|
});
|
|
321
323
|
}
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
+
// Prefer text (pgvector literal ``[f,f,...]`` + SQL ``::vector`` cast) so
|
|
325
|
+
// asyncpg does not bind ``list[float]`` as ``float[]`` (DataError). Still
|
|
326
|
+
// accept float_array / int_array for backwards-compatible IR producers.
|
|
324
327
|
if !matches!(
|
|
325
328
|
vector_order.value,
|
|
326
|
-
BindValue::FloatArray(_) | BindValue::IntArray(_)
|
|
329
|
+
BindValue::Text(_) | BindValue::FloatArray(_) | BindValue::IntArray(_)
|
|
327
330
|
) {
|
|
328
331
|
return Err(CompileError::MalformedIr {
|
|
329
|
-
reason: "vector_order_by.value must be float_array or int_array".into(),
|
|
332
|
+
reason: "vector_order_by.value must be text, float_array, or int_array".into(),
|
|
330
333
|
});
|
|
331
334
|
}
|
|
332
335
|
Ok(())
|
|
@@ -466,6 +469,11 @@ fn validate_predicate_fts(
|
|
|
466
469
|
Predicate::Not { child } => validate_predicate_fts(metadata, child)?,
|
|
467
470
|
Predicate::Filter { filter } => {
|
|
468
471
|
if FTS_OPERATORS.contains(&filter.operator.as_str()) {
|
|
472
|
+
if filter.join_alias.is_some() {
|
|
473
|
+
return Err(CompileError::MalformedIr {
|
|
474
|
+
reason: "full-text operators are not supported on relation lookups".into(),
|
|
475
|
+
});
|
|
476
|
+
}
|
|
469
477
|
validate_fts_filter(metadata, filter)?;
|
|
470
478
|
}
|
|
471
479
|
}
|
|
@@ -482,7 +490,36 @@ pub fn ir_has_where_clause(ir: &QuerySetIR) -> bool {
|
|
|
482
490
|
fn validate_filter(
|
|
483
491
|
metadata: &ModelMetadata,
|
|
484
492
|
filter: &crate::ir::Filter,
|
|
493
|
+
joins: &[JoinSpec],
|
|
485
494
|
) -> Result<(), CompileError> {
|
|
495
|
+
if let Some(alias) = &filter.join_alias {
|
|
496
|
+
let join =
|
|
497
|
+
joins
|
|
498
|
+
.iter()
|
|
499
|
+
.find(|j| j.alias == *alias)
|
|
500
|
+
.ok_or_else(|| CompileError::MalformedIr {
|
|
501
|
+
reason: format!("filter join_alias {alias:?} has no matching join"),
|
|
502
|
+
})?;
|
|
503
|
+
let remote = join
|
|
504
|
+
.remote_fields
|
|
505
|
+
.iter()
|
|
506
|
+
.find(|f| f.name == filter.field.name || f.index == filter.field.index)
|
|
507
|
+
.ok_or_else(|| CompileError::UnknownField {
|
|
508
|
+
model: metadata.model_name.clone(),
|
|
509
|
+
field: format!("{alias}__{}", filter.field.name),
|
|
510
|
+
})?;
|
|
511
|
+
if !remote.allowed_operators.is_empty()
|
|
512
|
+
&& !remote.allowed_operators.contains(&filter.operator)
|
|
513
|
+
{
|
|
514
|
+
return Err(CompileError::UnsupportedOperator {
|
|
515
|
+
model: metadata.model_name.clone(),
|
|
516
|
+
field: format!("{alias}__{}", filter.field.name),
|
|
517
|
+
operator: filter.operator.clone(),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
return Ok(());
|
|
521
|
+
}
|
|
522
|
+
|
|
486
523
|
let field_meta =
|
|
487
524
|
metadata
|
|
488
525
|
.fields
|
|
@@ -502,15 +539,19 @@ fn validate_filter(
|
|
|
502
539
|
Ok(())
|
|
503
540
|
}
|
|
504
541
|
|
|
505
|
-
fn validate_predicate(
|
|
542
|
+
fn validate_predicate(
|
|
543
|
+
metadata: &ModelMetadata,
|
|
544
|
+
predicate: &Predicate,
|
|
545
|
+
joins: &[JoinSpec],
|
|
546
|
+
) -> Result<(), CompileError> {
|
|
506
547
|
match predicate {
|
|
507
548
|
Predicate::And { children } | Predicate::Or { children } => {
|
|
508
549
|
for child in children {
|
|
509
|
-
validate_predicate(metadata, child)?;
|
|
550
|
+
validate_predicate(metadata, child, joins)?;
|
|
510
551
|
}
|
|
511
552
|
}
|
|
512
|
-
Predicate::Not { child } => validate_predicate(metadata, child)?,
|
|
513
|
-
Predicate::Filter { filter } => validate_filter(metadata, filter)?,
|
|
553
|
+
Predicate::Not { child } => validate_predicate(metadata, child, joins)?,
|
|
554
|
+
Predicate::Filter { filter } => validate_filter(metadata, filter, joins)?,
|
|
514
555
|
}
|
|
515
556
|
Ok(())
|
|
516
557
|
}
|
|
@@ -604,6 +645,7 @@ mod tests {
|
|
|
604
645
|
},
|
|
605
646
|
operator: "eq".into(),
|
|
606
647
|
value: BindValue::Int(1),
|
|
648
|
+
join_alias: None,
|
|
607
649
|
});
|
|
608
650
|
let err = compile(&meta, &ir).unwrap_err();
|
|
609
651
|
assert!(matches!(err, CompileError::UnknownField { .. }));
|
|
@@ -634,6 +676,7 @@ mod tests {
|
|
|
634
676
|
},
|
|
635
677
|
operator: "regex".into(), // not in allowed_operators
|
|
636
678
|
value: BindValue::Int(1),
|
|
679
|
+
join_alias: None,
|
|
637
680
|
});
|
|
638
681
|
let err = compile(&meta, &ir).unwrap_err();
|
|
639
682
|
assert!(matches!(err, CompileError::UnsupportedOperator { .. }));
|
|
@@ -679,6 +722,7 @@ mod tests {
|
|
|
679
722
|
},
|
|
680
723
|
operator: "match".into(),
|
|
681
724
|
value: BindValue::Text("hello".into()),
|
|
725
|
+
join_alias: None,
|
|
682
726
|
});
|
|
683
727
|
let err = compile(&meta, &ir).unwrap_err();
|
|
684
728
|
assert!(matches!(err, CompileError::UnsupportedOperator { .. }));
|
|
@@ -723,6 +767,7 @@ mod tests {
|
|
|
723
767
|
},
|
|
724
768
|
operator: "match_phrase".into(),
|
|
725
769
|
value: BindValue::Text("hello world".into()),
|
|
770
|
+
join_alias: None,
|
|
726
771
|
});
|
|
727
772
|
ir.text_rank_by = Some(crate::ir::TextRankBy {
|
|
728
773
|
field: FieldRef {
|
|
@@ -746,6 +791,7 @@ mod tests {
|
|
|
746
791
|
},
|
|
747
792
|
operator: "eq".into(),
|
|
748
793
|
value: BindValue::Text("test@example.com".into()),
|
|
794
|
+
join_alias: None,
|
|
749
795
|
});
|
|
750
796
|
ir.order_by.push(OrderBy {
|
|
751
797
|
field: FieldRef {
|
|
@@ -67,9 +67,20 @@ pub struct QuerySetIR {
|
|
|
67
67
|
pub joins: Vec<JoinSpec>,
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/// JOIN
|
|
70
|
+
/// JOIN kind for ``select_related`` (LEFT) vs relation-filter (INNER) joins.
|
|
71
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
72
|
+
#[serde(rename_all = "snake_case")]
|
|
73
|
+
pub enum JoinKind {
|
|
74
|
+
/// ``LEFT JOIN`` — used by ``select_related()`` (preserve parent rows).
|
|
75
|
+
#[default]
|
|
76
|
+
Left,
|
|
77
|
+
/// ``INNER JOIN`` — used by relation-filter lookups (``team__slug=...``).
|
|
78
|
+
Inner,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// JOIN metadata for ``select_related`` and relation-filter lookups.
|
|
71
82
|
///
|
|
72
|
-
/// The emitter produces
|
|
83
|
+
/// The emitter produces `<JOIN KIND> <remote_table> AS <alias> ON <base_table>.<local_col> = <alias>.<remote_pk_column>`.
|
|
73
84
|
/// All identifier fields are validated on the Python side before this struct is serialized into the IR.
|
|
74
85
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
75
86
|
pub struct JoinSpec {
|
|
@@ -83,13 +94,25 @@ pub struct JoinSpec {
|
|
|
83
94
|
pub remote_table: String,
|
|
84
95
|
/// Remote table PK column name (from model metadata).
|
|
85
96
|
pub remote_pk_column: String,
|
|
86
|
-
/// Columns
|
|
97
|
+
/// Columns available on the joined table (for SELECT projection and/or filter allowlisting).
|
|
87
98
|
pub remote_fields: Vec<JoinFieldRef>,
|
|
99
|
+
/// ``left`` (default) or ``inner``.
|
|
100
|
+
#[serde(default)]
|
|
101
|
+
pub join_kind: JoinKind,
|
|
102
|
+
/// When ``true`` (default), project ``remote_fields`` into ``alias__col`` SELECT aliases.
|
|
103
|
+
/// Filter-only joins set this ``false`` so hydration is not polluted.
|
|
104
|
+
#[serde(default = "default_true")]
|
|
105
|
+
pub project_remote: bool,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fn default_true() -> bool {
|
|
109
|
+
true
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
/// A field reference into a joined (remote) model's metadata.
|
|
91
113
|
///
|
|
92
|
-
///
|
|
114
|
+
/// When ``JoinSpec::project_remote`` is true, projected as
|
|
115
|
+
/// `<alias>.<column> AS "<alias>__<column>"` in the SELECT list.
|
|
93
116
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
94
117
|
pub struct JoinFieldRef {
|
|
95
118
|
/// Index into the remote model's `ModelMetadata::fields`.
|
|
@@ -98,6 +121,9 @@ pub struct JoinFieldRef {
|
|
|
98
121
|
pub name: String,
|
|
99
122
|
/// Database column name of the remote field.
|
|
100
123
|
pub column: String,
|
|
124
|
+
/// Operator allowlist copied from the remote field's ``FieldMeta`` (SQL-2).
|
|
125
|
+
#[serde(default)]
|
|
126
|
+
pub allowed_operators: Vec<String>,
|
|
101
127
|
}
|
|
102
128
|
|
|
103
129
|
/// The SQL operation this IR node represents.
|
|
@@ -149,10 +175,6 @@ pub enum Operation {
|
|
|
149
175
|
},
|
|
150
176
|
}
|
|
151
177
|
|
|
152
|
-
fn default_true() -> bool {
|
|
153
|
-
true
|
|
154
|
-
}
|
|
155
|
-
|
|
156
178
|
/// One row payload for :variant:`Operation::BulkUpdate`.
|
|
157
179
|
/// ``pk_values`` has one entry per PK field (single entry for single-PK models).
|
|
158
180
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
@@ -173,17 +195,26 @@ pub struct FieldRef {
|
|
|
173
195
|
|
|
174
196
|
/// A filter predicate (one leaf of the WHERE clause).
|
|
175
197
|
///
|
|
176
|
-
/// `operator` must be in `FieldMeta::allowed_operators` for `field
|
|
177
|
-
///
|
|
198
|
+
/// `operator` must be in `FieldMeta::allowed_operators` for `field` (base model)
|
|
199
|
+
/// or in the matching ``JoinFieldRef::allowed_operators`` when ``join_alias`` is
|
|
200
|
+
/// set. The compiler rejects any string not in that allowlist before SQL is
|
|
201
|
+
/// produced (SQL-1 / SQL-2).
|
|
178
202
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
179
203
|
pub struct Filter {
|
|
180
204
|
/// The model field being filtered.
|
|
205
|
+
///
|
|
206
|
+
/// When ``join_alias`` is set, ``index``/``name`` refer to the **remote**
|
|
207
|
+
/// model's field (matched against ``JoinSpec::remote_fields``).
|
|
181
208
|
pub field: FieldRef,
|
|
182
209
|
/// Ferrum operator string, e.g. `"eq"`, `"gt"`, `"icontains"`, `"is_null"`.
|
|
183
|
-
/// Validated against
|
|
210
|
+
/// Validated against the field's operator allowlist before SQL emission.
|
|
184
211
|
pub operator: String,
|
|
185
212
|
/// Bound value for the filter — never interpolated into SQL text.
|
|
186
213
|
pub value: BindValue,
|
|
214
|
+
/// When set, the filter targets a column on the JOIN with this alias
|
|
215
|
+
/// (Django-style ``relation__field`` lookups). ``None`` = base table.
|
|
216
|
+
#[serde(default)]
|
|
217
|
+
pub join_alias: Option<String>,
|
|
187
218
|
}
|
|
188
219
|
|
|
189
220
|
/// Composable boolean predicate tree (``Q`` objects on the Python side).
|
|
@@ -91,6 +91,7 @@ fn bench_select_ir() -> QuerySetIR {
|
|
|
91
91
|
},
|
|
92
92
|
operator: "eq".into(),
|
|
93
93
|
value: BindValue::Bool(true),
|
|
94
|
+
join_alias: None,
|
|
94
95
|
},
|
|
95
96
|
Filter {
|
|
96
97
|
field: FieldRef {
|
|
@@ -99,6 +100,7 @@ fn bench_select_ir() -> QuerySetIR {
|
|
|
99
100
|
},
|
|
100
101
|
operator: "icontains".into(),
|
|
101
102
|
value: BindValue::Text("example.com".into()),
|
|
103
|
+
join_alias: None,
|
|
102
104
|
},
|
|
103
105
|
],
|
|
104
106
|
order_by: vec![
|
|
@@ -15,7 +15,10 @@ use crate::fts;
|
|
|
15
15
|
use ferrum_core::{
|
|
16
16
|
compile::CompiledQuery,
|
|
17
17
|
error::CompileError,
|
|
18
|
-
ir::{
|
|
18
|
+
ir::{
|
|
19
|
+
BindValue, Filter, JoinKind, JoinSpec, ModelMetadata, Predicate, QuerySetIR, SortDirection,
|
|
20
|
+
VectorMetric,
|
|
21
|
+
},
|
|
19
22
|
};
|
|
20
23
|
use std::fmt::Write as _;
|
|
21
24
|
|
|
@@ -59,6 +62,9 @@ pub fn emit_select(
|
|
|
59
62
|
};
|
|
60
63
|
|
|
61
64
|
for join in &ir.joins {
|
|
65
|
+
if !join.project_remote {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
62
68
|
let alias = dialect.quote_ident(&join.alias);
|
|
63
69
|
for rf in &join.remote_fields {
|
|
64
70
|
let col = dialect.quote_ident(&rf.column);
|
|
@@ -85,6 +91,7 @@ pub fn emit_select(
|
|
|
85
91
|
|
|
86
92
|
if ir.exists {
|
|
87
93
|
let mut inner = format!("SELECT 1 FROM {table}");
|
|
94
|
+
append_joins(dialect, metadata, ir, &table, &mut inner);
|
|
88
95
|
if let Some(where_clause) = where_sql {
|
|
89
96
|
write!(inner, " WHERE {where_clause}").expect("write to String is infallible");
|
|
90
97
|
}
|
|
@@ -110,17 +117,7 @@ pub fn emit_select(
|
|
|
110
117
|
|
|
111
118
|
let distinct_kw = if ir.distinct { "DISTINCT " } else { "" };
|
|
112
119
|
let mut sql = format!("SELECT {distinct_kw}{fields} FROM {table}");
|
|
113
|
-
|
|
114
|
-
let alias = dialect.quote_ident(&join.alias);
|
|
115
|
-
let remote_table = dialect.quote_ident(&join.remote_table);
|
|
116
|
-
let local_col = dialect.quote_ident(&metadata.fields[join.local_field.index].column_name);
|
|
117
|
-
let remote_pk = dialect.quote_ident(&join.remote_pk_column);
|
|
118
|
-
write!(
|
|
119
|
-
sql,
|
|
120
|
-
" LEFT JOIN {remote_table} AS {alias} ON {table}.{local_col} = {alias}.{remote_pk}"
|
|
121
|
-
)
|
|
122
|
-
.expect("write to String is infallible");
|
|
123
|
-
}
|
|
120
|
+
append_joins(dialect, metadata, ir, &table, &mut sql);
|
|
124
121
|
if let Some(where_clause) = where_sql {
|
|
125
122
|
write!(sql, " WHERE {where_clause}").expect("write to String is infallible");
|
|
126
123
|
}
|
|
@@ -656,30 +653,12 @@ fn append_order_limit_offset(
|
|
|
656
653
|
bound_params: &mut Vec<BindValue>,
|
|
657
654
|
param_type_summary: &mut Vec<String>,
|
|
658
655
|
) -> Result<(), CompileError> {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
&metadata.fields[o.field.index].column_name,
|
|
666
|
-
qualify_columns,
|
|
667
|
-
);
|
|
668
|
-
let dir = match o.direction {
|
|
669
|
-
SortDirection::Asc => "ASC",
|
|
670
|
-
SortDirection::Desc => "DESC",
|
|
671
|
-
SortDirection::Unknown => {
|
|
672
|
-
return Err(CompileError::InvalidSortDirection {
|
|
673
|
-
model: metadata.model_name.clone(),
|
|
674
|
-
field: o.field.name.clone(),
|
|
675
|
-
direction: "unknown".into(),
|
|
676
|
-
})
|
|
677
|
-
}
|
|
678
|
-
};
|
|
679
|
-
order_parts.push(format!("{col} {dir}"));
|
|
680
|
-
}
|
|
681
|
-
write!(sql, " ORDER BY {}", order_parts.join(", ")).expect("write to String is infallible");
|
|
682
|
-
} else if let Some(vector_order) = &ir.vector_order_by {
|
|
656
|
+
// KNN / FTS ranking are primary ORDER BY keys; plain ``order_by()`` columns
|
|
657
|
+
// are secondary (tie-breakers). Previously an explicit ``order_by`` silently
|
|
658
|
+
// dropped ``vector_order_by`` / ``text_rank_by``.
|
|
659
|
+
let mut order_parts: Vec<String> = Vec::new();
|
|
660
|
+
|
|
661
|
+
if let Some(vector_order) = &ir.vector_order_by {
|
|
683
662
|
let col = qualify_base_column(
|
|
684
663
|
dialect,
|
|
685
664
|
table,
|
|
@@ -688,7 +667,15 @@ fn append_order_limit_offset(
|
|
|
688
667
|
);
|
|
689
668
|
let op = vector_metric_to_sql(vector_order.metric);
|
|
690
669
|
let placeholder = dialect.placeholder(bound_params.len() + 1);
|
|
691
|
-
|
|
670
|
+
// pgvector distance ops require a ``vector`` typed RHS. Binding a
|
|
671
|
+
// Python ``list[float]`` as ``float[]`` raises DataError; the IR carries
|
|
672
|
+
// a text literal ``'[f,f,...]'`` and we cast ``$N::vector`` on Postgres.
|
|
673
|
+
let rhs = if dialect == Dialect::Postgres {
|
|
674
|
+
format!("{placeholder}::vector")
|
|
675
|
+
} else {
|
|
676
|
+
placeholder
|
|
677
|
+
};
|
|
678
|
+
order_parts.push(format!("{col} {op} {rhs}"));
|
|
692
679
|
param_type_summary.push(format!("{}:nearest_to", vector_order.field.name));
|
|
693
680
|
bound_params.push(vector_order.value.clone());
|
|
694
681
|
} else if let Some(text_rank) = &ir.text_rank_by {
|
|
@@ -710,11 +697,36 @@ fn append_order_limit_offset(
|
|
|
710
697
|
table_name,
|
|
711
698
|
bound_params.len() + 1,
|
|
712
699
|
);
|
|
713
|
-
|
|
700
|
+
order_parts.push(rank_sql);
|
|
714
701
|
param_type_summary.push(format!("{}:text_rank", text_rank.field.name));
|
|
715
702
|
bound_params.push(text_rank.query.clone());
|
|
716
703
|
}
|
|
717
704
|
|
|
705
|
+
for o in &ir.order_by {
|
|
706
|
+
let col = qualify_base_column(
|
|
707
|
+
dialect,
|
|
708
|
+
table,
|
|
709
|
+
&metadata.fields[o.field.index].column_name,
|
|
710
|
+
qualify_columns,
|
|
711
|
+
);
|
|
712
|
+
let dir = match o.direction {
|
|
713
|
+
SortDirection::Asc => "ASC",
|
|
714
|
+
SortDirection::Desc => "DESC",
|
|
715
|
+
SortDirection::Unknown => {
|
|
716
|
+
return Err(CompileError::InvalidSortDirection {
|
|
717
|
+
model: metadata.model_name.clone(),
|
|
718
|
+
field: o.field.name.clone(),
|
|
719
|
+
direction: "unknown".into(),
|
|
720
|
+
})
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
order_parts.push(format!("{col} {dir}"));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if !order_parts.is_empty() {
|
|
727
|
+
write!(sql, " ORDER BY {}", order_parts.join(", ")).expect("write to String is infallible");
|
|
728
|
+
}
|
|
729
|
+
|
|
718
730
|
if dialect == Dialect::Mssql {
|
|
719
731
|
// T-SQL pagination: OFFSET … ROWS [FETCH NEXT … ROWS ONLY]. The OFFSET/FETCH
|
|
720
732
|
// clause requires an ORDER BY; inject a stable no-op order when none exists.
|
|
@@ -762,6 +774,67 @@ fn append_order_limit_offset(
|
|
|
762
774
|
/// Returns `None` when there are no constraints (caller omits the `WHERE` keyword).
|
|
763
775
|
/// All column references come from metadata allowlists; all values travel as
|
|
764
776
|
/// bound parameters (SQL-1 + SQL-2).
|
|
777
|
+
fn append_joins(
|
|
778
|
+
dialect: Dialect,
|
|
779
|
+
metadata: &ModelMetadata,
|
|
780
|
+
ir: &QuerySetIR,
|
|
781
|
+
table: &str,
|
|
782
|
+
sql: &mut String,
|
|
783
|
+
) {
|
|
784
|
+
for join in &ir.joins {
|
|
785
|
+
let join_kw = match join.join_kind {
|
|
786
|
+
JoinKind::Left => "LEFT JOIN",
|
|
787
|
+
JoinKind::Inner => "INNER JOIN",
|
|
788
|
+
};
|
|
789
|
+
let alias = dialect.quote_ident(&join.alias);
|
|
790
|
+
let remote_table = dialect.quote_ident(&join.remote_table);
|
|
791
|
+
let local_col = dialect.quote_ident(&metadata.fields[join.local_field.index].column_name);
|
|
792
|
+
let remote_pk = dialect.quote_ident(&join.remote_pk_column);
|
|
793
|
+
write!(
|
|
794
|
+
sql,
|
|
795
|
+
" {join_kw} {remote_table} AS {alias} ON {table}.{local_col} = {alias}.{remote_pk}"
|
|
796
|
+
)
|
|
797
|
+
.expect("write to String is infallible");
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
fn resolve_filter_column(
|
|
802
|
+
dialect: Dialect,
|
|
803
|
+
metadata: &ModelMetadata,
|
|
804
|
+
filter: &Filter,
|
|
805
|
+
joins: &[JoinSpec],
|
|
806
|
+
table: &str,
|
|
807
|
+
qualify_columns: bool,
|
|
808
|
+
) -> Result<String, CompileError> {
|
|
809
|
+
if let Some(alias) = &filter.join_alias {
|
|
810
|
+
let join =
|
|
811
|
+
joins
|
|
812
|
+
.iter()
|
|
813
|
+
.find(|j| j.alias == *alias)
|
|
814
|
+
.ok_or_else(|| CompileError::MalformedIr {
|
|
815
|
+
reason: format!("filter join_alias {alias:?} has no matching join"),
|
|
816
|
+
})?;
|
|
817
|
+
let remote = join
|
|
818
|
+
.remote_fields
|
|
819
|
+
.iter()
|
|
820
|
+
.find(|f| f.name == filter.field.name || f.index == filter.field.index)
|
|
821
|
+
.ok_or_else(|| CompileError::UnknownField {
|
|
822
|
+
model: metadata.model_name.clone(),
|
|
823
|
+
field: format!("{alias}__{}", filter.field.name),
|
|
824
|
+
})?;
|
|
825
|
+
let alias_q = dialect.quote_ident(&join.alias);
|
|
826
|
+
let col_q = dialect.quote_ident(&remote.column);
|
|
827
|
+
Ok(format!("{alias_q}.{col_q}"))
|
|
828
|
+
} else {
|
|
829
|
+
Ok(qualify_base_column(
|
|
830
|
+
dialect,
|
|
831
|
+
table,
|
|
832
|
+
&metadata.fields[filter.field.index].column_name,
|
|
833
|
+
qualify_columns,
|
|
834
|
+
))
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
765
838
|
fn build_where_sql(
|
|
766
839
|
dialect: Dialect,
|
|
767
840
|
metadata: &ModelMetadata,
|
|
@@ -774,12 +847,8 @@ fn build_where_sql(
|
|
|
774
847
|
let mut parts: Vec<String> = Vec::new();
|
|
775
848
|
|
|
776
849
|
for filter in &ir.filters {
|
|
777
|
-
let col =
|
|
778
|
-
dialect,
|
|
779
|
-
table,
|
|
780
|
-
&metadata.fields[filter.field.index].column_name,
|
|
781
|
-
qualify_columns,
|
|
782
|
-
);
|
|
850
|
+
let col =
|
|
851
|
+
resolve_filter_column(dialect, metadata, filter, &ir.joins, table, qualify_columns)?;
|
|
783
852
|
let (clause, param) = filter_clause(
|
|
784
853
|
dialect,
|
|
785
854
|
metadata,
|
|
@@ -801,6 +870,7 @@ fn build_where_sql(
|
|
|
801
870
|
dialect,
|
|
802
871
|
metadata,
|
|
803
872
|
predicate,
|
|
873
|
+
&ir.joins,
|
|
804
874
|
table,
|
|
805
875
|
qualify_columns,
|
|
806
876
|
bound_params,
|
|
@@ -822,10 +892,12 @@ fn build_where_sql(
|
|
|
822
892
|
/// `And`/`Or` wrap their children in parentheses to preserve grouping semantics
|
|
823
893
|
/// regardless of SQL operator precedence. `Not` wraps its child in `NOT (…)`.
|
|
824
894
|
/// Leaf `Filter` nodes delegate to `filter_clause`.
|
|
895
|
+
#[allow(clippy::too_many_arguments)]
|
|
825
896
|
fn emit_predicate(
|
|
826
897
|
dialect: Dialect,
|
|
827
898
|
metadata: &ModelMetadata,
|
|
828
899
|
predicate: &Predicate,
|
|
900
|
+
joins: &[JoinSpec],
|
|
829
901
|
table: &str,
|
|
830
902
|
qualify_columns: bool,
|
|
831
903
|
bound_params: &mut Vec<BindValue>,
|
|
@@ -840,6 +912,7 @@ fn emit_predicate(
|
|
|
840
912
|
dialect,
|
|
841
913
|
metadata,
|
|
842
914
|
child,
|
|
915
|
+
joins,
|
|
843
916
|
table,
|
|
844
917
|
qualify_columns,
|
|
845
918
|
bound_params,
|
|
@@ -857,6 +930,7 @@ fn emit_predicate(
|
|
|
857
930
|
dialect,
|
|
858
931
|
metadata,
|
|
859
932
|
child,
|
|
933
|
+
joins,
|
|
860
934
|
table,
|
|
861
935
|
qualify_columns,
|
|
862
936
|
bound_params,
|
|
@@ -872,6 +946,7 @@ fn emit_predicate(
|
|
|
872
946
|
dialect,
|
|
873
947
|
metadata,
|
|
874
948
|
child,
|
|
949
|
+
joins,
|
|
875
950
|
table,
|
|
876
951
|
qualify_columns,
|
|
877
952
|
bound_params,
|
|
@@ -879,12 +954,8 @@ fn emit_predicate(
|
|
|
879
954
|
)?
|
|
880
955
|
)),
|
|
881
956
|
Predicate::Filter { filter } => {
|
|
882
|
-
let col =
|
|
883
|
-
dialect,
|
|
884
|
-
table,
|
|
885
|
-
&metadata.fields[filter.field.index].column_name,
|
|
886
|
-
qualify_columns,
|
|
887
|
-
);
|
|
957
|
+
let col =
|
|
958
|
+
resolve_filter_column(dialect, metadata, filter, joins, table, qualify_columns)?;
|
|
888
959
|
let (clause, param) = filter_clause(
|
|
889
960
|
dialect,
|
|
890
961
|
metadata,
|
|
@@ -1105,6 +1176,7 @@ mod tests {
|
|
|
1105
1176
|
},
|
|
1106
1177
|
operator: "eq".into(),
|
|
1107
1178
|
value: BindValue::Text("x@example.com".into()),
|
|
1179
|
+
join_alias: None,
|
|
1108
1180
|
});
|
|
1109
1181
|
let q = emit_select(Dialect::Postgres, &meta, &ir).unwrap();
|
|
1110
1182
|
// Bound value must NOT appear in SQL text (SQL-2).
|
|
@@ -1184,6 +1256,7 @@ mod tests {
|
|
|
1184
1256
|
},
|
|
1185
1257
|
operator: "icontains".into(), // not allowed for Int field
|
|
1186
1258
|
value: BindValue::Int(42),
|
|
1259
|
+
join_alias: None,
|
|
1187
1260
|
});
|
|
1188
1261
|
let err = emit_select(Dialect::Postgres, &meta, &ir).unwrap_err();
|
|
1189
1262
|
assert!(matches!(err, CompileError::UnsupportedOperator { .. }));
|
|
@@ -1234,6 +1307,7 @@ mod tests {
|
|
|
1234
1307
|
},
|
|
1235
1308
|
operator: "eq".into(),
|
|
1236
1309
|
value: BindValue::Text("user@example.com".into()),
|
|
1310
|
+
join_alias: None,
|
|
1237
1311
|
});
|
|
1238
1312
|
ir.limit = Some(20);
|
|
1239
1313
|
|
|
@@ -1483,6 +1557,7 @@ mod tests {
|
|
|
1483
1557
|
},
|
|
1484
1558
|
operator: "eq".into(),
|
|
1485
1559
|
value: BindValue::Int(42),
|
|
1560
|
+
join_alias: None,
|
|
1486
1561
|
}],
|
|
1487
1562
|
);
|
|
1488
1563
|
let q = emit_update(Dialect::Postgres, &meta, &ir).unwrap();
|
|
@@ -1539,6 +1614,7 @@ mod tests {
|
|
|
1539
1614
|
},
|
|
1540
1615
|
operator: "eq".into(),
|
|
1541
1616
|
value: BindValue::Int(7),
|
|
1617
|
+
join_alias: None,
|
|
1542
1618
|
}]);
|
|
1543
1619
|
let q = emit_delete(Dialect::Postgres, &meta, &ir).unwrap();
|
|
1544
1620
|
// Filter value must not appear in sql_text.
|
|
@@ -1577,6 +1653,7 @@ mod tests {
|
|
|
1577
1653
|
},
|
|
1578
1654
|
operator: "eq".into(),
|
|
1579
1655
|
value: BindValue::Text("x@example.com".into()),
|
|
1656
|
+
join_alias: None,
|
|
1580
1657
|
});
|
|
1581
1658
|
let q = emit_select(Dialect::Mssql, &meta, &ir).unwrap();
|
|
1582
1659
|
assert!(q.sql_text.contains("[users]"), "bracket-quoted table");
|
|
@@ -1682,6 +1759,7 @@ mod tests {
|
|
|
1682
1759
|
},
|
|
1683
1760
|
operator: "eq".into(),
|
|
1684
1761
|
value: BindValue::Int(42),
|
|
1762
|
+
join_alias: None,
|
|
1685
1763
|
}],
|
|
1686
1764
|
);
|
|
1687
1765
|
let q = emit_update(Dialect::Mssql, &meta, &ir).unwrap();
|
|
@@ -1748,6 +1826,7 @@ mod tests {
|
|
|
1748
1826
|
},
|
|
1749
1827
|
operator: "eq".into(),
|
|
1750
1828
|
value: BindValue::Text("a@example.com".into()),
|
|
1829
|
+
join_alias: None,
|
|
1751
1830
|
});
|
|
1752
1831
|
ir.limit = Some(5);
|
|
1753
1832
|
|