ferrum-orm 0.1.3__tar.gz → 0.1.5__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.3 → ferrum_orm-0.1.5}/Cargo.lock +4 -4
  2. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/Cargo.toml +1 -1
  3. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/PKG-INFO +56 -7
  4. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/README.md +45 -1
  5. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/benches/hydrate.rs +9 -0
  6. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/src/compile/mod.rs +219 -0
  7. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/src/hydrate/mod.rs +7 -0
  8. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/src/ir/metadata.rs +25 -0
  9. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/src/ir/mod.rs +27 -1
  10. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-sql/benches/compile.rs +10 -0
  11. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-sql/src/emit.rs +45 -11
  12. ferrum_orm-0.1.5/crates/ferrum-sql/src/fts/mod.rs +318 -0
  13. ferrum_orm-0.1.5/crates/ferrum-sql/src/fts/mssql.rs +60 -0
  14. ferrum_orm-0.1.5/crates/ferrum-sql/src/fts/mysql.rs +62 -0
  15. ferrum_orm-0.1.5/crates/ferrum-sql/src/fts/postgres.rs +58 -0
  16. ferrum_orm-0.1.5/crates/ferrum-sql/src/fts/sqlite.rs +51 -0
  17. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-sql/src/lib.rs +1 -0
  18. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/pyproject.toml +24 -6
  19. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/__init__.py +3 -1
  20. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/inspectdb_cmd.py +3 -8
  21. ferrum_orm-0.1.5/python/ferrum/contrib/fastapi.py +75 -0
  22. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/drivers/mysql.py +11 -1
  23. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/drivers/sqlite.py +14 -3
  24. ferrum_orm-0.1.5/python/ferrum/ext/fts.py +95 -0
  25. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/__init__.py +6 -0
  26. ferrum_orm-0.1.5/python/ferrum/migrations/fts/__init__.py +57 -0
  27. ferrum_orm-0.1.5/python/ferrum/migrations/fts/mssql.py +31 -0
  28. ferrum_orm-0.1.5/python/ferrum/migrations/fts/mysql.py +22 -0
  29. ferrum_orm-0.1.5/python/ferrum/migrations/fts/postgres.py +27 -0
  30. ferrum_orm-0.1.5/python/ferrum/migrations/fts/sqlite.py +54 -0
  31. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/operations.py +82 -0
  32. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/orchestrator.py +5 -0
  33. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/models.py +119 -8
  34. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/queryset.py +76 -1
  35. ferrum_orm-0.1.3/python/ferrum/contrib/fastapi.py +0 -40
  36. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/LICENSE +0 -0
  37. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/Cargo.toml +0 -0
  38. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/src/error.rs +0 -0
  39. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/src/lib.rs +0 -0
  40. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/src/migrate/mod.rs +0 -0
  41. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-pyo3/Cargo.toml +0 -0
  42. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-pyo3/src/lib.rs +0 -0
  43. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-sql/Cargo.toml +0 -0
  44. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-sql/src/dialect.rs +0 -0
  45. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/_native.pyi +0 -0
  46. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/__init__.py +0 -0
  47. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/app.py +0 -0
  48. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/bootstrap.py +0 -0
  49. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/init.py +0 -0
  50. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/makemigrations_cmd.py +0 -0
  51. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/migrate_cmd.py +0 -0
  52. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/migrations_cmd.py +0 -0
  53. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/resetdb_cmd.py +0 -0
  54. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/revert_cmd.py +0 -0
  55. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/showmigrations_cmd.py +0 -0
  56. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
  57. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/config.py +0 -0
  58. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/connection.py +0 -0
  59. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/contrib/__init__.py +0 -0
  60. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/drivers/__init__.py +0 -0
  61. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/drivers/mssql.py +0 -0
  62. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/drivers/postgres.py +0 -0
  63. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/drivers/protocol.py +0 -0
  64. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/errors.py +0 -0
  65. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/expressions.py +0 -0
  66. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/ext/__init__.py +0 -0
  67. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/ext/pgvector.py +0 -0
  68. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/hooks.py +0 -0
  69. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/base.py +0 -0
  70. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/drift.py +0 -0
  71. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/gates.py +0 -0
  72. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/introspect.py +0 -0
  73. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/ledger.py +0 -0
  74. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/loader.py +0 -0
  75. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/tokens.py +0 -0
  76. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/observability.py +0 -0
  77. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/py.typed +0 -0
  78. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/registry.py +0 -0
  79. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/relations.py +0 -0
  80. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/runtime.py +0 -0
  81. {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/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.3"
226
+ version = "0.1.5"
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.3"
237
+ version = "0.1.5"
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.3"
246
+ version = "0.1.5"
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.3"
258
+ version = "0.1.5"
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.3"
6
+ version = "0.1.5"
7
7
  edition = "2021"
8
8
  authors = ["Ferrum Contributors"]
9
9
  license = "Apache-2.0"
@@ -1,14 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferrum-orm
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Framework :: AsyncIO
6
6
  Classifier: Intended Audience :: Developers
7
7
  Classifier: License :: OSI Approved :: Apache Software License
8
+ Classifier: Operating System :: OS Independent
8
9
  Classifier: Programming Language :: Python :: 3
9
10
  Classifier: Programming Language :: Python :: 3.11
10
11
  Classifier: Programming Language :: Python :: 3.12
11
12
  Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
12
14
  Classifier: Programming Language :: Rust
13
15
  Classifier: Topic :: Database
14
16
  Classifier: Topic :: Software Development :: Libraries
@@ -34,6 +36,7 @@ Requires-Dist: ty>=0.0.49 ; extra == 'dev'
34
36
  Requires-Dist: import-linter>=2 ; extra == 'dev'
35
37
  Requires-Dist: testcontainers[postgres]>=4 ; extra == 'dev'
36
38
  Requires-Dist: pip-audit>=2 ; extra == 'dev'
39
+ Requires-Dist: fastapi>=0.110 ; extra == 'dev'
37
40
  Requires-Dist: python-dotenv>=1.0 ; extra == 'dotenv'
38
41
  Requires-Dist: msgpack>=1.0 ; extra == 'msgpack'
39
42
  Requires-Dist: aioodbc>=0.5 ; extra == 'mssql'
@@ -56,14 +59,16 @@ Provides-Extra: sqlite
56
59
  Provides-Extra: uuid7
57
60
  License-File: LICENSE
58
61
  Summary: Next-generation async ORM for Python with a Rust-powered core
59
- Keywords: orm,async,postgresql,mysql,sqlite,mssql,pydantic,rust
62
+ Keywords: orm,async,postgresql,mysql,sqlite,mssql,pydantic,rust,asyncpg,query-builder
63
+ Author: Ferrum Contributors
60
64
  License: Apache-2.0
61
65
  Requires-Python: >=3.11
62
66
  Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
63
- Project-URL: Bug Tracker, https://github.com/ferrumdb/ferrum/issues
64
- Project-URL: Documentation, https://ferrumdb.github.io/ferrum
65
- Project-URL: Homepage, https://github.com/ferrumdb/ferrum
66
- Project-URL: Repository, https://github.com/ferrumdb/ferrum
67
+ Project-URL: Changelog, https://github.com/ferrum-orm/ferrum/blob/main/CHANGELOG.md
68
+ Project-URL: Documentation, https://github.com/ferrum-orm/ferrum/tree/main/docs
69
+ Project-URL: Homepage, https://github.com/ferrum-orm/ferrum
70
+ Project-URL: Issues, https://github.com/ferrum-orm/ferrum/issues
71
+ Project-URL: Repository, https://github.com/ferrum-orm/ferrum
67
72
 
68
73
  # Ferrum
69
74
 
@@ -180,6 +185,50 @@ Performance-critical components are implemented in Rust:
180
185
 
181
186
  This allows Ferrum to maintain a Pythonic API without sacrificing performance.
182
187
 
188
+ ### Cross-Driver Full-Text Search
189
+
190
+ Native full-text search across PostgreSQL, MySQL, SQLite FTS5, and SQL Server — one
191
+ QuerySet API, dialect-specific SQL emit and migration DDL.
192
+
193
+ **Query modes** (filter lookups and ranking):
194
+
195
+ | Mode | Lookup operator | Typical use |
196
+ | ----------- | ----------------- | ------------------------------------ |
197
+ | `plain` | `__match` | Natural-language terms |
198
+ | `phrase` | `__match_phrase` | Exact phrase |
199
+ | `websearch` | `__match_websearch` | Web-style quotes, `-` negation |
200
+ | `boolean` | `__match_boolean` | Boolean operators (`&`, `\|`, `!`) |
201
+
202
+ **Convenience methods:**
203
+
204
+ ```python
205
+ # Filter + relevance ranking in one call
206
+ hits = await Article.objects.search(
207
+ "python async orm", field="body", mode="websearch"
208
+ ).limit(10).all(conn)
209
+
210
+ # Rank without an implicit filter
211
+ ranked = await Article.objects.rank_by("body", "rust", mode="plain").all(conn)
212
+ ```
213
+
214
+ **Index declaration** — PostgreSQL uses `TSVector` columns; other drivers index base
215
+ `text` columns via `Meta.full_text_indexes`:
216
+
217
+ ```python
218
+ from ferrum.models import Field, FullTextIndex
219
+
220
+ class Article(Model):
221
+ search_vector: Annotated[TSVector, Field(fts_config="english")] | None = None
222
+ body: str = ""
223
+
224
+ class Meta:
225
+ full_text_indexes = [FullTextIndex(fields=("body",), config="english")]
226
+ ```
227
+
228
+ Query strings are always bound parameters; `fts_config` and index names come from
229
+ model-metadata allowlists only. See [Getting Started → Vector and full-text columns](docs/getting-started.md)
230
+ and [API Reference](docs/api-reference.md) for per-dialect DDL and operator mapping.
231
+
183
232
  ## Architecture
184
233
 
185
234
  ```text
@@ -220,7 +269,7 @@ This allows Ferrum to maintain a Pythonic API without sacrificing performance.
220
269
  - [x] Migrations (schema diff, apply, revert, CLI)
221
270
  - [x] Relationships (ForeignKey, OneToOne, ManyToMany)
222
271
  - [x] pgvector KNN search and HNSW/IVFFLAT index DDL
223
- - [x] Full-text search (TSVector / `plainto_tsquery`)
272
+ - [x] Full-text search (cross-dialect: PostgreSQL, MySQL, SQLite FTS5, SQL Server)
224
273
  - [x] Observability hooks (Tier A/B/C)
225
274
  - [x] CLI (`makemigrations`, `migrate`, `revert`, `showmigrations`, `inspectdb`, `resetdb`)
226
275
 
@@ -113,6 +113,50 @@ Performance-critical components are implemented in Rust:
113
113
 
114
114
  This allows Ferrum to maintain a Pythonic API without sacrificing performance.
115
115
 
116
+ ### Cross-Driver Full-Text Search
117
+
118
+ Native full-text search across PostgreSQL, MySQL, SQLite FTS5, and SQL Server — one
119
+ QuerySet API, dialect-specific SQL emit and migration DDL.
120
+
121
+ **Query modes** (filter lookups and ranking):
122
+
123
+ | Mode | Lookup operator | Typical use |
124
+ | ----------- | ----------------- | ------------------------------------ |
125
+ | `plain` | `__match` | Natural-language terms |
126
+ | `phrase` | `__match_phrase` | Exact phrase |
127
+ | `websearch` | `__match_websearch` | Web-style quotes, `-` negation |
128
+ | `boolean` | `__match_boolean` | Boolean operators (`&`, `\|`, `!`) |
129
+
130
+ **Convenience methods:**
131
+
132
+ ```python
133
+ # Filter + relevance ranking in one call
134
+ hits = await Article.objects.search(
135
+ "python async orm", field="body", mode="websearch"
136
+ ).limit(10).all(conn)
137
+
138
+ # Rank without an implicit filter
139
+ ranked = await Article.objects.rank_by("body", "rust", mode="plain").all(conn)
140
+ ```
141
+
142
+ **Index declaration** — PostgreSQL uses `TSVector` columns; other drivers index base
143
+ `text` columns via `Meta.full_text_indexes`:
144
+
145
+ ```python
146
+ from ferrum.models import Field, FullTextIndex
147
+
148
+ class Article(Model):
149
+ search_vector: Annotated[TSVector, Field(fts_config="english")] | None = None
150
+ body: str = ""
151
+
152
+ class Meta:
153
+ full_text_indexes = [FullTextIndex(fields=("body",), config="english")]
154
+ ```
155
+
156
+ Query strings are always bound parameters; `fts_config` and index names come from
157
+ model-metadata allowlists only. See [Getting Started → Vector and full-text columns](docs/getting-started.md)
158
+ and [API Reference](docs/api-reference.md) for per-dialect DDL and operator mapping.
159
+
116
160
  ## Architecture
117
161
 
118
162
  ```text
@@ -153,7 +197,7 @@ This allows Ferrum to maintain a Pythonic API without sacrificing performance.
153
197
  - [x] Migrations (schema diff, apply, revert, CLI)
154
198
  - [x] Relationships (ForeignKey, OneToOne, ManyToMany)
155
199
  - [x] pgvector KNN search and HNSW/IVFFLAT index DDL
156
- - [x] Full-text search (TSVector / `plainto_tsquery`)
200
+ - [x] Full-text search (cross-dialect: PostgreSQL, MySQL, SQLite FTS5, SQL Server)
157
201
  - [x] Observability hooks (Tier A/B/C)
158
202
  - [x] CLI (`makemigrations`, `migrate`, `revert`, `showmigrations`, `inspectdb`, `resetdb`)
159
203
 
@@ -20,6 +20,8 @@ fn bench_metadata() -> ModelMetadata {
20
20
  allowed_operators: vec!["eq".into()],
21
21
  nullable: false,
22
22
  vector_dimensions: None,
23
+ fts_config: None,
24
+ fts_source_columns: None,
23
25
  },
24
26
  FieldMeta {
25
27
  name: "title".into(),
@@ -28,6 +30,8 @@ fn bench_metadata() -> ModelMetadata {
28
30
  allowed_operators: vec!["eq".into(), "icontains".into()],
29
31
  nullable: false,
30
32
  vector_dimensions: None,
33
+ fts_config: None,
34
+ fts_source_columns: None,
31
35
  },
32
36
  FieldMeta {
33
37
  name: "body".into(),
@@ -36,6 +40,8 @@ fn bench_metadata() -> ModelMetadata {
36
40
  allowed_operators: vec!["eq".into()],
37
41
  nullable: true,
38
42
  vector_dimensions: None,
43
+ fts_config: None,
44
+ fts_source_columns: None,
39
45
  },
40
46
  FieldMeta {
41
47
  name: "published".into(),
@@ -44,10 +50,13 @@ fn bench_metadata() -> ModelMetadata {
44
50
  allowed_operators: vec!["eq".into()],
45
51
  nullable: false,
46
52
  vector_dimensions: None,
53
+ fts_config: None,
54
+ fts_source_columns: None,
47
55
  },
48
56
  ],
49
57
  pk_index: 0,
50
58
  pk_fields: vec![0],
59
+ full_text_indexes: vec![],
51
60
  }
52
61
  }
53
62
 
@@ -139,6 +139,7 @@ pub fn compile(metadata: &ModelMetadata, ir: &QuerySetIR) -> Result<CompiledQuer
139
139
  validate_joins(metadata, ir)?;
140
140
  validate_order_by(metadata, ir)?;
141
141
  validate_vector_order_by(metadata, ir)?;
142
+ validate_text_search(metadata, ir)?;
142
143
 
143
144
  // Validation passed. Return empty CompiledQuery — sql_text and bound_params
144
145
  // are populated by the SQL emitter in `ferrum-sql::emit`.
@@ -331,6 +332,147 @@ fn validate_vector_order_by(metadata: &ModelMetadata, ir: &QuerySetIR) -> Result
331
332
  Ok(())
332
333
  }
333
334
 
335
+ /// Ferrum full-text filter operators (validated against ``tsvector`` fields).
336
+ pub const FTS_OPERATORS: &[&str] = &["match", "match_phrase", "match_websearch", "match_boolean"];
337
+
338
+ fn is_valid_fts_identifier(value: &str) -> bool {
339
+ !value.is_empty() && value.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
340
+ }
341
+
342
+ fn validate_fts_field_metadata(
343
+ field_meta: &crate::ir::metadata::FieldMeta,
344
+ ) -> Result<(), CompileError> {
345
+ if let Some(config) = &field_meta.fts_config {
346
+ if !is_valid_fts_identifier(config) {
347
+ return Err(CompileError::MalformedIr {
348
+ reason: format!("invalid fts_config identifier: {config:?}"),
349
+ });
350
+ }
351
+ }
352
+ if let Some(cols) = &field_meta.fts_source_columns {
353
+ for col in cols {
354
+ if !is_valid_fts_identifier(col) {
355
+ return Err(CompileError::MalformedIr {
356
+ reason: format!("invalid fts_source_columns identifier: {col:?}"),
357
+ });
358
+ }
359
+ }
360
+ }
361
+ Ok(())
362
+ }
363
+
364
+ fn field_supports_fts(
365
+ metadata: &ModelMetadata,
366
+ field_meta: &crate::ir::metadata::FieldMeta,
367
+ ) -> bool {
368
+ if field_meta.field_type == FieldType::TsVector {
369
+ return true;
370
+ }
371
+ if field_meta.field_type == FieldType::Text {
372
+ let in_index = metadata
373
+ .full_text_indexes
374
+ .iter()
375
+ .any(|idx| idx.fields.iter().any(|f| f == &field_meta.name));
376
+ return in_index || field_meta.fts_source_columns.is_some();
377
+ }
378
+ false
379
+ }
380
+
381
+ fn validate_fts_filter(
382
+ metadata: &ModelMetadata,
383
+ filter: &crate::ir::Filter,
384
+ ) -> Result<(), CompileError> {
385
+ let field_meta =
386
+ metadata
387
+ .fields
388
+ .get(filter.field.index)
389
+ .ok_or_else(|| CompileError::UnknownField {
390
+ model: metadata.model_name.clone(),
391
+ field: filter.field.name.clone(),
392
+ })?;
393
+ if !field_supports_fts(metadata, field_meta) {
394
+ return Err(CompileError::UnsupportedOperator {
395
+ model: metadata.model_name.clone(),
396
+ field: filter.field.name.clone(),
397
+ operator: filter.operator.clone(),
398
+ });
399
+ }
400
+ if !matches!(filter.value, BindValue::Text(_)) {
401
+ return Err(CompileError::MalformedIr {
402
+ reason: format!(
403
+ "FTS operator {} requires a text bind value",
404
+ filter.operator
405
+ ),
406
+ });
407
+ }
408
+ validate_fts_field_metadata(field_meta)
409
+ }
410
+
411
+ fn validate_text_rank_by(
412
+ metadata: &ModelMetadata,
413
+ rank: &crate::ir::TextRankBy,
414
+ ) -> Result<(), CompileError> {
415
+ let field_meta =
416
+ metadata
417
+ .fields
418
+ .get(rank.field.index)
419
+ .ok_or_else(|| CompileError::UnknownField {
420
+ model: metadata.model_name.clone(),
421
+ field: rank.field.name.clone(),
422
+ })?;
423
+ if !field_supports_fts(metadata, field_meta) {
424
+ return Err(CompileError::UnsupportedOperator {
425
+ model: metadata.model_name.clone(),
426
+ field: rank.field.name.clone(),
427
+ operator: "rank_by".into(),
428
+ });
429
+ }
430
+ if !matches!(rank.query, BindValue::Text(_)) {
431
+ return Err(CompileError::MalformedIr {
432
+ reason: "text_rank_by.query must be a text bind value".into(),
433
+ });
434
+ }
435
+ validate_fts_field_metadata(field_meta)?;
436
+ // Mode is enum-validated at deserialization; no per-query config strings.
437
+ let _ = rank.mode;
438
+ Ok(())
439
+ }
440
+
441
+ fn validate_text_search(metadata: &ModelMetadata, ir: &QuerySetIR) -> Result<(), CompileError> {
442
+ for filter in &ir.filters {
443
+ if FTS_OPERATORS.contains(&filter.operator.as_str()) {
444
+ validate_fts_filter(metadata, filter)?;
445
+ }
446
+ }
447
+ if let Some(predicate) = &ir.predicate {
448
+ validate_predicate_fts(metadata, predicate)?;
449
+ }
450
+ if let Some(rank) = &ir.text_rank_by {
451
+ validate_text_rank_by(metadata, rank)?;
452
+ }
453
+ Ok(())
454
+ }
455
+
456
+ fn validate_predicate_fts(
457
+ metadata: &ModelMetadata,
458
+ predicate: &Predicate,
459
+ ) -> Result<(), CompileError> {
460
+ match predicate {
461
+ Predicate::And { children } | Predicate::Or { children } => {
462
+ for child in children {
463
+ validate_predicate_fts(metadata, child)?;
464
+ }
465
+ }
466
+ Predicate::Not { child } => validate_predicate_fts(metadata, child)?,
467
+ Predicate::Filter { filter } => {
468
+ if FTS_OPERATORS.contains(&filter.operator.as_str()) {
469
+ validate_fts_filter(metadata, filter)?;
470
+ }
471
+ }
472
+ }
473
+ Ok(())
474
+ }
475
+
334
476
  /// True when the IR carries at least one WHERE constraint (flat filters or a predicate tree).
335
477
  #[must_use]
336
478
  pub fn ir_has_where_clause(ir: &QuerySetIR) -> bool {
@@ -393,6 +535,8 @@ mod tests {
393
535
  allowed_operators: vec!["eq".into(), "gt".into(), "lt".into()],
394
536
  nullable: false,
395
537
  vector_dimensions: None,
538
+ fts_config: None,
539
+ fts_source_columns: None,
396
540
  },
397
541
  FieldMeta {
398
542
  name: "email".into(),
@@ -401,10 +545,13 @@ mod tests {
401
545
  allowed_operators: vec!["eq".into(), "icontains".into()],
402
546
  nullable: false,
403
547
  vector_dimensions: None,
548
+ fts_config: None,
549
+ fts_source_columns: None,
404
550
  },
405
551
  ],
406
552
  pk_index: 0,
407
553
  pk_fields: vec![0],
554
+ full_text_indexes: vec![],
408
555
  }
409
556
  }
410
557
 
@@ -429,6 +576,7 @@ mod tests {
429
576
  limit: None,
430
577
  offset: None,
431
578
  vector_order_by: None,
579
+ text_rank_by: None,
432
580
  predicate: None,
433
581
  distinct: false,
434
582
  exists: false,
@@ -520,6 +668,73 @@ mod tests {
520
668
  assert_eq!(dir, SortDirection::Unknown);
521
669
  }
522
670
 
671
+ #[test]
672
+ fn rejects_fts_operator_on_non_tsvector_field() {
673
+ let meta = make_metadata();
674
+ let mut ir = base_ir("User");
675
+ ir.filters.push(Filter {
676
+ field: FieldRef {
677
+ name: "email".into(),
678
+ index: 1,
679
+ },
680
+ operator: "match".into(),
681
+ value: BindValue::Text("hello".into()),
682
+ });
683
+ let err = compile(&meta, &ir).unwrap_err();
684
+ assert!(matches!(err, CompileError::UnsupportedOperator { .. }));
685
+ }
686
+
687
+ #[test]
688
+ fn accepts_tsvector_match_and_text_rank_by() {
689
+ let meta = ModelMetadata {
690
+ model_name: "Doc".into(),
691
+ table_name: "docs".into(),
692
+ fields: vec![FieldMeta {
693
+ name: "search_vector".into(),
694
+ column_name: "search_vector".into(),
695
+ field_type: FieldType::TsVector,
696
+ allowed_operators: vec![
697
+ "match".into(),
698
+ "match_phrase".into(),
699
+ "match_websearch".into(),
700
+ "match_boolean".into(),
701
+ "is_null".into(),
702
+ ],
703
+ nullable: true,
704
+ vector_dimensions: None,
705
+ fts_config: Some("english".into()),
706
+ fts_source_columns: None,
707
+ }],
708
+ pk_index: 0,
709
+ pk_fields: vec![0],
710
+ full_text_indexes: vec![],
711
+ };
712
+ let mut ir = base_ir("Doc");
713
+ ir.operation = Operation::Select {
714
+ fields: vec![FieldRef {
715
+ name: "search_vector".into(),
716
+ index: 0,
717
+ }],
718
+ };
719
+ ir.filters.push(Filter {
720
+ field: FieldRef {
721
+ name: "search_vector".into(),
722
+ index: 0,
723
+ },
724
+ operator: "match_phrase".into(),
725
+ value: BindValue::Text("hello world".into()),
726
+ });
727
+ ir.text_rank_by = Some(crate::ir::TextRankBy {
728
+ field: FieldRef {
729
+ name: "search_vector".into(),
730
+ index: 0,
731
+ },
732
+ query: BindValue::Text("hello".into()),
733
+ mode: crate::ir::TextSearchMode::Phrase,
734
+ });
735
+ assert!(compile(&meta, &ir).is_ok());
736
+ }
737
+
523
738
  #[test]
524
739
  fn accepts_valid_ir() {
525
740
  let meta = make_metadata();
@@ -570,6 +785,7 @@ mod tests {
570
785
  limit: None,
571
786
  offset: None,
572
787
  vector_order_by: None,
788
+ text_rank_by: None,
573
789
  predicate: None,
574
790
  distinct: false,
575
791
  exists: false,
@@ -598,6 +814,7 @@ mod tests {
598
814
  limit: None,
599
815
  offset: None,
600
816
  vector_order_by: None,
817
+ text_rank_by: None,
601
818
  predicate: None,
602
819
  distinct: false,
603
820
  exists: false,
@@ -621,6 +838,7 @@ mod tests {
621
838
  limit: None,
622
839
  offset: None,
623
840
  vector_order_by: None,
841
+ text_rank_by: None,
624
842
  predicate: None,
625
843
  distinct: false,
626
844
  exists: false,
@@ -653,6 +871,7 @@ mod tests {
653
871
  limit: None,
654
872
  offset: None,
655
873
  vector_order_by: None,
874
+ text_rank_by: None,
656
875
  predicate: None,
657
876
  distinct: false,
658
877
  exists: false,
@@ -89,6 +89,8 @@ mod tests {
89
89
  allowed_operators: vec!["eq".into()],
90
90
  nullable: false,
91
91
  vector_dimensions: None,
92
+ fts_config: None,
93
+ fts_source_columns: None,
92
94
  },
93
95
  FieldMeta {
94
96
  name: "title".into(),
@@ -97,6 +99,8 @@ mod tests {
97
99
  allowed_operators: vec!["eq".into(), "icontains".into()],
98
100
  nullable: false,
99
101
  vector_dimensions: None,
102
+ fts_config: None,
103
+ fts_source_columns: None,
100
104
  },
101
105
  FieldMeta {
102
106
  name: "bio".into(),
@@ -105,10 +109,13 @@ mod tests {
105
109
  allowed_operators: vec!["eq".into()],
106
110
  nullable: true, // nullable column
107
111
  vector_dimensions: None,
112
+ fts_config: None,
113
+ fts_source_columns: None,
108
114
  },
109
115
  ],
110
116
  pk_index: 0,
111
117
  pk_fields: vec![0],
118
+ full_text_indexes: vec![],
112
119
  }
113
120
  }
114
121
 
@@ -29,6 +29,10 @@ pub struct ModelMetadata {
29
29
  /// Empty vec is treated as `[pk_index]` for backward compatibility.
30
30
  #[serde(default)]
31
31
  pub pk_fields: Vec<usize>,
32
+
33
+ /// Declarative full-text indexes from model ``Meta.full_text_indexes``.
34
+ #[serde(default)]
35
+ pub full_text_indexes: Vec<FullTextIndexMeta>,
32
36
  }
33
37
 
34
38
  /// Metadata for a single model field.
@@ -53,6 +57,27 @@ pub struct FieldMeta {
53
57
  /// pgvector dimensionality (DDL-only metadata; optional).
54
58
  #[serde(default)]
55
59
  pub vector_dimensions: Option<u32>,
60
+
61
+ /// `PostgreSQL` text-search configuration (regconfig), e.g. `english`.
62
+ /// Validated as an allowlisted identifier before SQL emission.
63
+ #[serde(default)]
64
+ pub fts_config: Option<String>,
65
+
66
+ /// Source columns backing a generated FTS column (MySQL/SQLite/MSSQL).
67
+ #[serde(default)]
68
+ pub fts_source_columns: Option<Vec<String>>,
69
+ }
70
+
71
+ /// Declarative full-text index metadata (``Meta.full_text_indexes``).
72
+ #[derive(Debug, Clone, Serialize, Deserialize)]
73
+ pub struct FullTextIndexMeta {
74
+ /// Index or FTS5 virtual-table name (metadata allowlist).
75
+ pub name: String,
76
+ /// Model field names covered by this index.
77
+ pub fields: Vec<String>,
78
+ /// Optional language/regconfig identifier (allowlisted).
79
+ #[serde(default)]
80
+ pub config: Option<String>,
56
81
  }
57
82
 
58
83
  /// Ferrum-level type tag for a field. Corresponds to Pydantic / Python types.
@@ -15,7 +15,7 @@ pub mod metadata;
15
15
  pub use metadata::ModelMetadata;
16
16
 
17
17
  /// Version of the IR contract this crate implements.
18
- pub const IR_VERSION: u32 = 2;
18
+ pub const IR_VERSION: u32 = 3;
19
19
 
20
20
  /// The root IR node produced by `QuerySet._build_ir()` on the Python side.
21
21
  #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -45,6 +45,10 @@ pub struct QuerySetIR {
45
45
  #[serde(default)]
46
46
  pub vector_order_by: Option<VectorOrderBy>,
47
47
 
48
+ /// Optional full-text relevance ordering (``QuerySet.rank_by`` / ``QuerySet.search``).
49
+ #[serde(default)]
50
+ pub text_rank_by: Option<TextRankBy>,
51
+
48
52
  /// Optional boolean predicate tree (``Q`` objects). When present, combined with
49
53
  /// ``filters`` (AND). When absent, ``filters`` are AND-ed as in IR v1.
50
54
  #[serde(default)]
@@ -232,6 +236,28 @@ pub struct VectorOrderBy {
232
236
  pub value: BindValue,
233
237
  }
234
238
 
239
+ /// Full-text query parsing mode for ``match_*`` filters and ``text_rank_by``.
240
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
241
+ #[serde(rename_all = "snake_case")]
242
+ pub enum TextSearchMode {
243
+ /// Natural-language / plain terms (``plainto_tsquery``, ``FREETEXT``, etc.).
244
+ Plain,
245
+ /// Exact phrase (``phraseto_tsquery``, phrase mode).
246
+ Phrase,
247
+ /// Web-style search syntax (``websearch_to_tsquery``).
248
+ Websearch,
249
+ /// Boolean / prefix query DSL (``to_tsquery``, ``IN BOOLEAN MODE``).
250
+ Boolean,
251
+ }
252
+
253
+ /// Full-text relevance ordering (``ORDER BY <rank> DESC``).
254
+ #[derive(Debug, Clone, Serialize, Deserialize)]
255
+ pub struct TextRankBy {
256
+ pub field: FieldRef,
257
+ pub query: BindValue,
258
+ pub mode: TextSearchMode,
259
+ }
260
+
235
261
  /// Sort direction — only `Asc` and `Desc` are valid; anything else deserialized
236
262
  /// from Python/JSON becomes `Unknown`, which the compiler rejects with
237
263
  /// `CompileError::InvalidSortDirection` before any SQL is produced.