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.
Files changed (81) hide show
  1. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/Cargo.lock +4 -4
  2. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/Cargo.toml +1 -1
  3. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/PKG-INFO +1 -1
  4. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/src/compile/mod.rs +58 -12
  5. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/src/ir/mod.rs +42 -11
  6. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/benches/compile.rs +2 -0
  7. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/emit.rs +129 -50
  8. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/pyproject.toml +1 -1
  9. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/__init__.py +4 -1
  10. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/connection.py +22 -2
  11. ferrum_orm-0.1.11/python/ferrum/echo.py +114 -0
  12. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/queryset.py +274 -34
  13. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/relations.py +20 -2
  14. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/LICENSE +0 -0
  15. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/README.md +0 -0
  16. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/Cargo.toml +0 -0
  17. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/benches/hydrate.rs +0 -0
  18. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/src/error.rs +0 -0
  19. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
  20. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/src/ir/metadata.rs +0 -0
  21. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/src/lib.rs +0 -0
  22. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-core/src/migrate/mod.rs +0 -0
  23. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-pyo3/Cargo.toml +0 -0
  24. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-pyo3/src/lib.rs +0 -0
  25. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/Cargo.toml +0 -0
  26. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/dialect.rs +0 -0
  27. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/mod.rs +0 -0
  28. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
  29. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
  30. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
  31. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
  32. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/lib.rs +0 -0
  33. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/_native.pyi +0 -0
  34. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/__init__.py +0 -0
  35. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/app.py +0 -0
  36. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/bootstrap.py +0 -0
  37. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/init.py +0 -0
  38. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/inspectdb_cmd.py +0 -0
  39. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/makemigrations_cmd.py +0 -0
  40. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/migrate_cmd.py +0 -0
  41. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/migrations_cmd.py +0 -0
  42. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/resetdb_cmd.py +0 -0
  43. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/revert_cmd.py +0 -0
  44. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/showmigrations_cmd.py +0 -0
  45. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
  46. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/config.py +0 -0
  47. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/contrib/__init__.py +0 -0
  48. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/contrib/fastapi.py +0 -0
  49. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/drivers/__init__.py +0 -0
  50. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/drivers/mssql.py +0 -0
  51. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/drivers/mysql.py +0 -0
  52. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/drivers/postgres.py +0 -0
  53. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/drivers/protocol.py +0 -0
  54. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/drivers/sqlite.py +0 -0
  55. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/errors.py +0 -0
  56. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/expressions.py +0 -0
  57. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/ext/__init__.py +0 -0
  58. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/ext/fts.py +0 -0
  59. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/ext/pgvector.py +0 -0
  60. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/hooks.py +0 -0
  61. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/__init__.py +0 -0
  62. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/base.py +0 -0
  63. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/drift.py +0 -0
  64. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/__init__.py +0 -0
  65. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/mssql.py +0 -0
  66. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/mysql.py +0 -0
  67. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/postgres.py +0 -0
  68. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/sqlite.py +0 -0
  69. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/gates.py +0 -0
  70. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/introspect.py +0 -0
  71. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/ledger.py +0 -0
  72. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/loader.py +0 -0
  73. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/operations.py +0 -0
  74. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/orchestrator.py +0 -0
  75. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/migrations/tokens.py +0 -0
  76. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/models.py +0 -0
  77. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/observability.py +0 -0
  78. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/py.typed +0 -0
  79. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/registry.py +0 -0
  80. {ferrum_orm-0.1.9 → ferrum_orm-0.1.11}/python/ferrum/runtime.py +0 -0
  81. {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.9"
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.9"
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.9"
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.9"
258
+ version = "0.1.11"
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.11"
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.11
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Framework :: AsyncIO
6
6
  Classifier: Intended Audience :: Developers
@@ -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
- // 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]).
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(metadata: &ModelMetadata, predicate: &Predicate) -> Result<(), CompileError> {
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 metadata for ``select_related`` (`PostgreSQL` LEFT 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 `LEFT JOIN <remote_table> AS <alias> ON <base_table>.<local_col> = <alias>.<remote_pk_column>`.
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 to project from the remote table into `<alias>__<column>` aliases.
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
- /// Projected as `<alias>.<column> AS "<alias>__<column>"` in the SELECT list.
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`; the compiler
177
- /// rejects any string not in that allowlist before SQL is produced (SQL-1).
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 `FieldMeta::allowed_operators` before SQL emission.
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::{BindValue, ModelMetadata, Predicate, QuerySetIR, SortDirection, VectorMetric},
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
- for join in &ir.joins {
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
- if !ir.order_by.is_empty() {
660
- let mut order_parts: Vec<String> = Vec::new();
661
- for o in &ir.order_by {
662
- let col = qualify_base_column(
663
- dialect,
664
- table,
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
- write!(sql, " ORDER BY {col} {op} {placeholder}").expect("write to String is infallible");
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
- write!(sql, " ORDER BY {rank_sql}").expect("write to String is infallible");
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 = qualify_base_column(
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 = qualify_base_column(
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
 
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "ferrum-orm"
7
- version = "0.1.9"
7
+ version = "0.1.11"
8
8
  description = "Next-generation async ORM for Python with a Rust-powered core"
9
9
  license = { text = "Apache-2.0" }
10
10
  readme = "README.md"