ferrum-orm 0.1.8__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.8 → ferrum_orm-0.1.11}/Cargo.lock +4 -4
  2. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/Cargo.toml +1 -1
  3. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/PKG-INFO +46 -1
  4. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/README.md +45 -0
  5. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/src/compile/mod.rs +58 -12
  6. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/src/ir/mod.rs +42 -11
  7. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/benches/compile.rs +2 -0
  8. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/emit.rs +129 -50
  9. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/pyproject.toml +1 -1
  10. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/__init__.py +4 -1
  11. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/makemigrations_cmd.py +64 -15
  12. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/connection.py +22 -2
  13. ferrum_orm-0.1.11/python/ferrum/echo.py +114 -0
  14. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/__init__.py +12 -1
  15. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/orchestrator.py +196 -13
  16. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/models.py +45 -4
  17. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/queryset.py +274 -34
  18. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/relations.py +20 -2
  19. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/LICENSE +0 -0
  20. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/Cargo.toml +0 -0
  21. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/benches/hydrate.rs +0 -0
  22. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/src/error.rs +0 -0
  23. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
  24. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/src/ir/metadata.rs +0 -0
  25. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/src/lib.rs +0 -0
  26. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-core/src/migrate/mod.rs +0 -0
  27. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-pyo3/Cargo.toml +0 -0
  28. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-pyo3/src/lib.rs +0 -0
  29. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/Cargo.toml +0 -0
  30. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/dialect.rs +0 -0
  31. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/mod.rs +0 -0
  32. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
  33. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
  34. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
  35. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
  36. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/crates/ferrum-sql/src/lib.rs +0 -0
  37. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/_native.pyi +0 -0
  38. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/__init__.py +0 -0
  39. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/app.py +0 -0
  40. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/bootstrap.py +0 -0
  41. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/init.py +0 -0
  42. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/inspectdb_cmd.py +0 -0
  43. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/migrate_cmd.py +0 -0
  44. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/migrations_cmd.py +0 -0
  45. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/resetdb_cmd.py +0 -0
  46. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/revert_cmd.py +0 -0
  47. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/showmigrations_cmd.py +0 -0
  48. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
  49. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/config.py +0 -0
  50. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/contrib/__init__.py +0 -0
  51. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/contrib/fastapi.py +0 -0
  52. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/drivers/__init__.py +0 -0
  53. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/drivers/mssql.py +0 -0
  54. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/drivers/mysql.py +0 -0
  55. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/drivers/postgres.py +0 -0
  56. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/drivers/protocol.py +0 -0
  57. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/drivers/sqlite.py +0 -0
  58. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/errors.py +0 -0
  59. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/expressions.py +0 -0
  60. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/ext/__init__.py +0 -0
  61. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/ext/fts.py +0 -0
  62. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/ext/pgvector.py +0 -0
  63. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/hooks.py +0 -0
  64. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/base.py +0 -0
  65. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/drift.py +0 -0
  66. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/__init__.py +0 -0
  67. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/mssql.py +0 -0
  68. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/mysql.py +0 -0
  69. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/postgres.py +0 -0
  70. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/fts/sqlite.py +0 -0
  71. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/gates.py +0 -0
  72. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/introspect.py +0 -0
  73. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/ledger.py +0 -0
  74. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/loader.py +0 -0
  75. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/operations.py +0 -0
  76. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/migrations/tokens.py +0 -0
  77. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/observability.py +0 -0
  78. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/py.typed +0 -0
  79. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/registry.py +0 -0
  80. {ferrum_orm-0.1.8 → ferrum_orm-0.1.11}/python/ferrum/runtime.py +0 -0
  81. {ferrum_orm-0.1.8 → 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.8"
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.8"
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.8"
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.8"
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.8"
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.8
3
+ Version: 0.1.11
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Framework :: AsyncIO
6
6
  Classifier: Intended Audience :: Developers
@@ -168,6 +168,49 @@ class User(Model):
168
168
 
169
169
  No duplicate schema definitions.
170
170
 
171
+ ### Field Options
172
+
173
+ `ferrum.Field()` accepts column-level constraints that are carried through to DDL:
174
+
175
+ | Parameter | Type | Description |
176
+ | --------- | ---- | ----------- |
177
+ | `db_default` | `str \| None` | DB-side `DEFAULT` expression (e.g. `"NOW()"`, `"''"`, `"GEN_RANDOM_UUID()"`). Token casing is normalised (`now()` → `NOW()`). |
178
+ | `nullable` | `bool \| None` | Override annotation-derived nullability. Use `nullable=False` to force `NOT NULL` even on a `T \| None`-annotated field. |
179
+ | `db_index` | `bool` | Emit a `CREATE INDEX` on this column (auto-named `idx_{table}_{field}`). |
180
+ | `unique` | `bool` | Emit a `UNIQUE` constraint. |
181
+ | `db_column` | `str \| None` | Override the database column name. |
182
+ | `primary_key` | `bool` | Declare this field as the primary key. |
183
+ | `uuid_generate` | `"v4" \| "v7" \| None` | Set `GEN_RANDOM_UUID()` / `UUIDV7()` as the DB default for UUID PK columns. |
184
+ | `max_length` | `int \| None` | Emit `VARCHAR(n)` instead of `TEXT`. |
185
+ | `max_digits` / `decimal_places` | `int \| None` | Emit `NUMERIC(p,s)`. |
186
+ | `vector_dimensions` | `int \| None` | Required for `Vector` columns; emits `VECTOR(n)`. |
187
+
188
+ ```python
189
+ from datetime import datetime
190
+ from typing import Annotated
191
+ from ferrum import Model, Field
192
+
193
+ class Event(Model):
194
+ id: int
195
+ # DB-side timestamp default; Python annotation allows None but column is NOT NULL
196
+ created_at: Annotated[datetime | None, Field(db_default="now()", nullable=False)]
197
+ # Indexed text column
198
+ kind: Annotated[str, Field(db_index=True, max_length=64)]
199
+ ```
200
+
201
+ `makemigrations` honours these field attributes:
202
+ - `db_default` / `nullable` are written into `CREATE TABLE` and `ADD COLUMN` DDL.
203
+ - `db_index=True` emits `CREATE INDEX` after the table op.
204
+ - For **already-existing tables**, `makemigrations` now **autodiffs** index and column
205
+ attribute changes against the state projected from prior migration files:
206
+ - Adding `db_index=True` to an existing field → `AddIndex` op.
207
+ - Removing `db_index=True` → `DropIndex` op.
208
+ - Adding or changing `db_default` → `AlterColumn … SET DEFAULT …` op.
209
+ - Removing `db_default` → `AlterColumn … DROP DEFAULT` op.
210
+ - Changing nullability → `AlterColumn … SET / DROP NOT NULL` op (SET NOT NULL is
211
+ classified destructive and requires `--confirm`).
212
+ - Column type changes, renames, and drops remain manual (out of scope for v0.1).
213
+
171
214
  ### Django-Inspired API
172
215
 
173
216
  Familiar query interface.
@@ -309,6 +352,8 @@ and [API Reference](docs/api-reference.md) for per-dialect DDL and operator mapp
309
352
  - [x] `call_function` for allowlisted stored-procedure calls
310
353
  - [x] Migration ops for extensions, RLS policies, and function DDL
311
354
  - [x] pgvector similarity score projection (`vector_search` helper)
355
+ - [x] `Field(db_default=..., nullable=...)` — first-class DB-side defaults and nullability override
356
+ - [x] `makemigrations` autodiff for index / default / nullability changes on existing tables
312
357
  - [ ] Query optimization (deferred fields, prefetch tuning)
313
358
  - [ ] Advanced relationship loading
314
359
 
@@ -96,6 +96,49 @@ class User(Model):
96
96
 
97
97
  No duplicate schema definitions.
98
98
 
99
+ ### Field Options
100
+
101
+ `ferrum.Field()` accepts column-level constraints that are carried through to DDL:
102
+
103
+ | Parameter | Type | Description |
104
+ | --------- | ---- | ----------- |
105
+ | `db_default` | `str \| None` | DB-side `DEFAULT` expression (e.g. `"NOW()"`, `"''"`, `"GEN_RANDOM_UUID()"`). Token casing is normalised (`now()` → `NOW()`). |
106
+ | `nullable` | `bool \| None` | Override annotation-derived nullability. Use `nullable=False` to force `NOT NULL` even on a `T \| None`-annotated field. |
107
+ | `db_index` | `bool` | Emit a `CREATE INDEX` on this column (auto-named `idx_{table}_{field}`). |
108
+ | `unique` | `bool` | Emit a `UNIQUE` constraint. |
109
+ | `db_column` | `str \| None` | Override the database column name. |
110
+ | `primary_key` | `bool` | Declare this field as the primary key. |
111
+ | `uuid_generate` | `"v4" \| "v7" \| None` | Set `GEN_RANDOM_UUID()` / `UUIDV7()` as the DB default for UUID PK columns. |
112
+ | `max_length` | `int \| None` | Emit `VARCHAR(n)` instead of `TEXT`. |
113
+ | `max_digits` / `decimal_places` | `int \| None` | Emit `NUMERIC(p,s)`. |
114
+ | `vector_dimensions` | `int \| None` | Required for `Vector` columns; emits `VECTOR(n)`. |
115
+
116
+ ```python
117
+ from datetime import datetime
118
+ from typing import Annotated
119
+ from ferrum import Model, Field
120
+
121
+ class Event(Model):
122
+ id: int
123
+ # DB-side timestamp default; Python annotation allows None but column is NOT NULL
124
+ created_at: Annotated[datetime | None, Field(db_default="now()", nullable=False)]
125
+ # Indexed text column
126
+ kind: Annotated[str, Field(db_index=True, max_length=64)]
127
+ ```
128
+
129
+ `makemigrations` honours these field attributes:
130
+ - `db_default` / `nullable` are written into `CREATE TABLE` and `ADD COLUMN` DDL.
131
+ - `db_index=True` emits `CREATE INDEX` after the table op.
132
+ - For **already-existing tables**, `makemigrations` now **autodiffs** index and column
133
+ attribute changes against the state projected from prior migration files:
134
+ - Adding `db_index=True` to an existing field → `AddIndex` op.
135
+ - Removing `db_index=True` → `DropIndex` op.
136
+ - Adding or changing `db_default` → `AlterColumn … SET DEFAULT …` op.
137
+ - Removing `db_default` → `AlterColumn … DROP DEFAULT` op.
138
+ - Changing nullability → `AlterColumn … SET / DROP NOT NULL` op (SET NOT NULL is
139
+ classified destructive and requires `--confirm`).
140
+ - Column type changes, renames, and drops remain manual (out of scope for v0.1).
141
+
99
142
  ### Django-Inspired API
100
143
 
101
144
  Familiar query interface.
@@ -237,6 +280,8 @@ and [API Reference](docs/api-reference.md) for per-dialect DDL and operator mapp
237
280
  - [x] `call_function` for allowlisted stored-procedure calls
238
281
  - [x] Migration ops for extensions, RLS policies, and function DDL
239
282
  - [x] pgvector similarity score projection (`vector_search` helper)
283
+ - [x] `Field(db_default=..., nullable=...)` — first-class DB-side defaults and nullability override
284
+ - [x] `makemigrations` autodiff for index / default / nullability changes on existing tables
240
285
  - [ ] Query optimization (deferred fields, prefetch tuning)
241
286
  - [ ] Advanced relationship loading
242
287
 
@@ -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![