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.
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/Cargo.lock +4 -4
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/Cargo.toml +1 -1
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/PKG-INFO +56 -7
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/README.md +45 -1
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/benches/hydrate.rs +9 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/src/compile/mod.rs +219 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/src/hydrate/mod.rs +7 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/src/ir/metadata.rs +25 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/src/ir/mod.rs +27 -1
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-sql/benches/compile.rs +10 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-sql/src/emit.rs +45 -11
- ferrum_orm-0.1.5/crates/ferrum-sql/src/fts/mod.rs +318 -0
- ferrum_orm-0.1.5/crates/ferrum-sql/src/fts/mssql.rs +60 -0
- ferrum_orm-0.1.5/crates/ferrum-sql/src/fts/mysql.rs +62 -0
- ferrum_orm-0.1.5/crates/ferrum-sql/src/fts/postgres.rs +58 -0
- ferrum_orm-0.1.5/crates/ferrum-sql/src/fts/sqlite.rs +51 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-sql/src/lib.rs +1 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/pyproject.toml +24 -6
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/__init__.py +3 -1
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/inspectdb_cmd.py +3 -8
- ferrum_orm-0.1.5/python/ferrum/contrib/fastapi.py +75 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/drivers/mysql.py +11 -1
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/drivers/sqlite.py +14 -3
- ferrum_orm-0.1.5/python/ferrum/ext/fts.py +95 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/__init__.py +6 -0
- ferrum_orm-0.1.5/python/ferrum/migrations/fts/__init__.py +57 -0
- ferrum_orm-0.1.5/python/ferrum/migrations/fts/mssql.py +31 -0
- ferrum_orm-0.1.5/python/ferrum/migrations/fts/mysql.py +22 -0
- ferrum_orm-0.1.5/python/ferrum/migrations/fts/postgres.py +27 -0
- ferrum_orm-0.1.5/python/ferrum/migrations/fts/sqlite.py +54 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/operations.py +82 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/orchestrator.py +5 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/models.py +119 -8
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/queryset.py +76 -1
- ferrum_orm-0.1.3/python/ferrum/contrib/fastapi.py +0 -40
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/LICENSE +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/Cargo.toml +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/src/error.rs +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/src/lib.rs +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-core/src/migrate/mod.rs +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-pyo3/Cargo.toml +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-pyo3/src/lib.rs +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-sql/Cargo.toml +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/crates/ferrum-sql/src/dialect.rs +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/_native.pyi +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/__init__.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/app.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/bootstrap.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/init.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/makemigrations_cmd.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/migrate_cmd.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/migrations_cmd.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/resetdb_cmd.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/revert_cmd.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/showmigrations_cmd.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/config.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/connection.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/contrib/__init__.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/drivers/__init__.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/drivers/mssql.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/drivers/postgres.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/drivers/protocol.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/errors.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/expressions.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/ext/__init__.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/ext/pgvector.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/hooks.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/base.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/drift.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/gates.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/introspect.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/ledger.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/loader.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/migrations/tokens.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/observability.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/py.typed +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/registry.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/relations.py +0 -0
- {ferrum_orm-0.1.3 → ferrum_orm-0.1.5}/python/ferrum/runtime.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
258
|
+
version = "0.1.5"
|
|
259
259
|
dependencies = [
|
|
260
260
|
"criterion",
|
|
261
261
|
"ferrum-core",
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ferrum-orm
|
|
3
|
-
Version: 0.1.
|
|
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:
|
|
64
|
-
Project-URL: Documentation, https://
|
|
65
|
-
Project-URL: Homepage, https://github.com/
|
|
66
|
-
Project-URL:
|
|
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 (
|
|
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 (
|
|
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 =
|
|
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.
|