ferrum-orm 0.1.1__tar.gz → 0.1.2__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.1 → ferrum_orm-0.1.2}/Cargo.lock +16 -46
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/Cargo.toml +2 -2
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/PKG-INFO +6 -2
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/README.md +5 -1
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/Cargo.toml +1 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-pyo3/Cargo.toml +1 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-pyo3/src/lib.rs +22 -35
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-sql/Cargo.toml +1 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/pyproject.toml +1 -1
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/__init__.py +5 -2
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/inspectdb_cmd.py +65 -18
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/migrate_cmd.py +44 -29
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/revert_cmd.py +40 -22
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/connection.py +126 -3
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/drivers/postgres.py +75 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/drivers/protocol.py +10 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/errors.py +71 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/orchestrator.py +33 -3
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/models.py +36 -4
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/queryset.py +14 -10
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/LICENSE +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/benches/hydrate.rs +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/src/compile/mod.rs +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/src/error.rs +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/src/ir/metadata.rs +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/src/ir/mod.rs +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/src/lib.rs +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/src/migrate/mod.rs +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-sql/benches/compile.rs +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-sql/src/dialect.rs +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-sql/src/emit.rs +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-sql/src/lib.rs +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/_native.pyi +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/__init__.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/app.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/bootstrap.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/init.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/makemigrations_cmd.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/migrations_cmd.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/resetdb_cmd.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/showmigrations_cmd.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/config.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/contrib/__init__.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/contrib/fastapi.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/drivers/__init__.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/drivers/mysql.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/drivers/sqlite.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/ext/__init__.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/ext/pgvector.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/hooks.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/__init__.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/base.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/drift.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/gates.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/introspect.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/ledger.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/loader.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/operations.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/tokens.py +0 -0
- {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/py.typed +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.2"
|
|
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.2"
|
|
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.2"
|
|
247
247
|
dependencies = [
|
|
248
248
|
"ferrum-core",
|
|
249
249
|
"ferrum-sql",
|
|
@@ -254,7 +254,7 @@ dependencies = [
|
|
|
254
254
|
|
|
255
255
|
[[package]]
|
|
256
256
|
name = "ferrum-sql"
|
|
257
|
-
version = "0.1.
|
|
257
|
+
version = "0.1.2"
|
|
258
258
|
dependencies = [
|
|
259
259
|
"criterion",
|
|
260
260
|
"ferrum-core",
|
|
@@ -380,15 +380,6 @@ dependencies = [
|
|
|
380
380
|
"serde_core",
|
|
381
381
|
]
|
|
382
382
|
|
|
383
|
-
[[package]]
|
|
384
|
-
name = "indoc"
|
|
385
|
-
version = "2.0.7"
|
|
386
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
387
|
-
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
|
388
|
-
dependencies = [
|
|
389
|
-
"rustversion",
|
|
390
|
-
]
|
|
391
|
-
|
|
392
383
|
[[package]]
|
|
393
384
|
name = "is-terminal"
|
|
394
385
|
version = "0.4.17"
|
|
@@ -456,15 +447,6 @@ version = "2.8.2"
|
|
|
456
447
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
457
448
|
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
|
458
449
|
|
|
459
|
-
[[package]]
|
|
460
|
-
name = "memoffset"
|
|
461
|
-
version = "0.9.1"
|
|
462
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
463
|
-
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
|
464
|
-
dependencies = [
|
|
465
|
-
"autocfg",
|
|
466
|
-
]
|
|
467
|
-
|
|
468
450
|
[[package]]
|
|
469
451
|
name = "num-traits"
|
|
470
452
|
version = "0.2.19"
|
|
@@ -575,37 +557,32 @@ dependencies = [
|
|
|
575
557
|
|
|
576
558
|
[[package]]
|
|
577
559
|
name = "pyo3"
|
|
578
|
-
version = "0.
|
|
560
|
+
version = "0.29.0"
|
|
579
561
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
580
|
-
checksum = "
|
|
562
|
+
checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c"
|
|
581
563
|
dependencies = [
|
|
582
|
-
"cfg-if",
|
|
583
|
-
"indoc",
|
|
584
564
|
"libc",
|
|
585
|
-
"memoffset",
|
|
586
565
|
"once_cell",
|
|
587
566
|
"portable-atomic",
|
|
588
567
|
"pyo3-build-config",
|
|
589
568
|
"pyo3-ffi",
|
|
590
569
|
"pyo3-macros",
|
|
591
|
-
"unindent",
|
|
592
570
|
]
|
|
593
571
|
|
|
594
572
|
[[package]]
|
|
595
573
|
name = "pyo3-build-config"
|
|
596
|
-
version = "0.
|
|
574
|
+
version = "0.29.0"
|
|
597
575
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
598
|
-
checksum = "
|
|
576
|
+
checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078"
|
|
599
577
|
dependencies = [
|
|
600
|
-
"once_cell",
|
|
601
578
|
"target-lexicon",
|
|
602
579
|
]
|
|
603
580
|
|
|
604
581
|
[[package]]
|
|
605
582
|
name = "pyo3-ffi"
|
|
606
|
-
version = "0.
|
|
583
|
+
version = "0.29.0"
|
|
607
584
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
608
|
-
checksum = "
|
|
585
|
+
checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b"
|
|
609
586
|
dependencies = [
|
|
610
587
|
"libc",
|
|
611
588
|
"pyo3-build-config",
|
|
@@ -613,9 +590,9 @@ dependencies = [
|
|
|
613
590
|
|
|
614
591
|
[[package]]
|
|
615
592
|
name = "pyo3-macros"
|
|
616
|
-
version = "0.
|
|
593
|
+
version = "0.29.0"
|
|
617
594
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
618
|
-
checksum = "
|
|
595
|
+
checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771"
|
|
619
596
|
dependencies = [
|
|
620
597
|
"proc-macro2",
|
|
621
598
|
"pyo3-macros-backend",
|
|
@@ -625,13 +602,12 @@ dependencies = [
|
|
|
625
602
|
|
|
626
603
|
[[package]]
|
|
627
604
|
name = "pyo3-macros-backend"
|
|
628
|
-
version = "0.
|
|
605
|
+
version = "0.29.0"
|
|
629
606
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
630
|
-
checksum = "
|
|
607
|
+
checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362"
|
|
631
608
|
dependencies = [
|
|
632
609
|
"heck",
|
|
633
610
|
"proc-macro2",
|
|
634
|
-
"pyo3-build-config",
|
|
635
611
|
"quote",
|
|
636
612
|
"syn",
|
|
637
613
|
]
|
|
@@ -858,9 +834,9 @@ dependencies = [
|
|
|
858
834
|
|
|
859
835
|
[[package]]
|
|
860
836
|
name = "target-lexicon"
|
|
861
|
-
version = "0.
|
|
837
|
+
version = "0.13.5"
|
|
862
838
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
863
|
-
checksum = "
|
|
839
|
+
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
|
864
840
|
|
|
865
841
|
[[package]]
|
|
866
842
|
name = "tempfile"
|
|
@@ -923,12 +899,6 @@ version = "0.2.6"
|
|
|
923
899
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
924
900
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
|
925
901
|
|
|
926
|
-
[[package]]
|
|
927
|
-
name = "unindent"
|
|
928
|
-
version = "0.2.4"
|
|
929
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
930
|
-
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
|
931
|
-
|
|
932
902
|
[[package]]
|
|
933
903
|
name = "wait-timeout"
|
|
934
904
|
version = "0.2.1"
|
|
@@ -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.
|
|
6
|
+
version = "0.1.2"
|
|
7
7
|
edition = "2021"
|
|
8
8
|
authors = ["Ferrum Contributors"]
|
|
9
9
|
license = "Apache-2.0"
|
|
@@ -12,7 +12,7 @@ rust-version = "1.85"
|
|
|
12
12
|
|
|
13
13
|
[workspace.dependencies]
|
|
14
14
|
# PyO3 — only ferrum-pyo3 depends on this; declared here to pin the version
|
|
15
|
-
pyo3 = { version = "0.
|
|
15
|
+
pyo3 = { version = "0.29", features = ["abi3-py311"] }
|
|
16
16
|
|
|
17
17
|
# Serialization
|
|
18
18
|
serde = { version = "1", features = ["derive"] }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ferrum-orm
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Framework :: AsyncIO
|
|
6
6
|
Classifier: Intended Audience :: Developers
|
|
@@ -115,6 +115,10 @@ users = await (
|
|
|
115
115
|
.limit(10)
|
|
116
116
|
.all(conn)
|
|
117
117
|
)
|
|
118
|
+
|
|
119
|
+
async with conn.transaction() as tx:
|
|
120
|
+
user = await User.objects.create(tx, email="jane@example.com")
|
|
121
|
+
await AuditLog.objects.create(tx, user_id=user.id, action="signup")
|
|
118
122
|
```
|
|
119
123
|
|
|
120
124
|
---
|
|
@@ -207,7 +211,7 @@ This allows Ferrum to maintain a Pythonic API without sacrificing performance.
|
|
|
207
211
|
### v0.2
|
|
208
212
|
|
|
209
213
|
- [ ] Relationships
|
|
210
|
-
- [
|
|
214
|
+
- [x] Transactions
|
|
211
215
|
- [ ] Query optimization
|
|
212
216
|
- [ ] Bulk operations
|
|
213
217
|
|
|
@@ -56,6 +56,10 @@ users = await (
|
|
|
56
56
|
.limit(10)
|
|
57
57
|
.all(conn)
|
|
58
58
|
)
|
|
59
|
+
|
|
60
|
+
async with conn.transaction() as tx:
|
|
61
|
+
user = await User.objects.create(tx, email="jane@example.com")
|
|
62
|
+
await AuditLog.objects.create(tx, user_id=user.id, action="signup")
|
|
59
63
|
```
|
|
60
64
|
|
|
61
65
|
---
|
|
@@ -148,7 +152,7 @@ This allows Ferrum to maintain a Pythonic API without sacrificing performance.
|
|
|
148
152
|
### v0.2
|
|
149
153
|
|
|
150
154
|
- [ ] Relationships
|
|
151
|
-
- [
|
|
155
|
+
- [x] Transactions
|
|
152
156
|
- [ ] Query optimization
|
|
153
157
|
- [ ] Bulk operations
|
|
154
158
|
|
|
@@ -13,16 +13,9 @@
|
|
|
13
13
|
//! - Error payloads carry structured fields only — no trace blobs, no raw `PostgreSQL`
|
|
14
14
|
//! DETAIL/HINT, no memory addresses, no local paths.
|
|
15
15
|
|
|
16
|
-
// PyO3 0.22.x uses an internal `cfg(gil-refs)` feature gate that Rust's
|
|
17
|
-
// `unexpected_cfgs` lint flags. This is a known upstream issue resolved in
|
|
18
|
-
// PyO3 0.23+. Suppress here until the pyo3 dependency is upgraded.
|
|
19
|
-
#![allow(unexpected_cfgs)]
|
|
20
|
-
// PyO3 0.22's `#[pyfunction]` macro expands code that triggers `useless_conversion`
|
|
21
|
-
// (false positive: macro-generated `PyErr → PyErr` coercions). Remove on pyo3 >= 0.23.
|
|
22
|
-
#![allow(clippy::useless_conversion)]
|
|
23
|
-
|
|
24
16
|
use pyo3::exceptions::{PyNotImplementedError, PyRuntimeError};
|
|
25
17
|
use pyo3::prelude::*;
|
|
18
|
+
use pyo3::IntoPyObjectExt;
|
|
26
19
|
|
|
27
20
|
// With abi3, PyO3 does not support subclassing native types via `extends`.
|
|
28
21
|
// Use `create_exception!` to define custom exception classes that inherit from
|
|
@@ -100,8 +93,8 @@ fn compile_query(
|
|
|
100
93
|
|
|
101
94
|
match result {
|
|
102
95
|
Ok(Ok((compiled, operation_name))) => {
|
|
103
|
-
let dict = pyo3::types::PyDict::
|
|
104
|
-
dict.set_item("sql_text",
|
|
96
|
+
let dict = pyo3::types::PyDict::new(py);
|
|
97
|
+
dict.set_item("sql_text", compiled.sql_text.as_str())?;
|
|
105
98
|
// `bound_params` are JSON-encoded so Python can deserialize them into
|
|
106
99
|
// native types for the asyncpg driver. Never log these in Tier A hooks.
|
|
107
100
|
let params: Vec<String> = compiled
|
|
@@ -111,10 +104,10 @@ fn compile_query(
|
|
|
111
104
|
.collect();
|
|
112
105
|
dict.set_item("bound_params", params)?;
|
|
113
106
|
dict.set_item("param_type_summary", compiled.param_type_summary)?;
|
|
114
|
-
dict.set_item("fingerprint",
|
|
107
|
+
dict.set_item("fingerprint", compiled.fingerprint.as_str())?;
|
|
115
108
|
// Python routes on this: "select"/"insert"/"update"/"delete".
|
|
116
109
|
dict.set_item("operation", operation_name)?;
|
|
117
|
-
Ok(dict.
|
|
110
|
+
Ok(dict.into_any().unbind())
|
|
118
111
|
}
|
|
119
112
|
Ok(Err(compile_err)) => {
|
|
120
113
|
// Structured compile error → catchable Python exception.
|
|
@@ -134,20 +127,20 @@ fn compile_query(
|
|
|
134
127
|
///
|
|
135
128
|
/// Mapping: `null` → `None`, `bool` → `bool`, `number` → `int` or `float`,
|
|
136
129
|
/// `string` → `str`, `array` → `list`, `object` → `dict`.
|
|
137
|
-
fn json_value_to_pyobj(py: Python<'_>, val: &serde_json::Value) -> PyResult<
|
|
130
|
+
fn json_value_to_pyobj(py: Python<'_>, val: &serde_json::Value) -> PyResult<Py<PyAny>> {
|
|
138
131
|
match val {
|
|
139
132
|
serde_json::Value::Null => Ok(py.None()),
|
|
140
|
-
serde_json::Value::Bool(b) =>
|
|
133
|
+
serde_json::Value::Bool(b) => (*b).into_py_any(py),
|
|
141
134
|
serde_json::Value::Number(n) => {
|
|
142
135
|
if let Some(i) = n.as_i64() {
|
|
143
|
-
|
|
136
|
+
i.into_py_any(py)
|
|
144
137
|
} else if let Some(f) = n.as_f64() {
|
|
145
|
-
|
|
138
|
+
f.into_py_any(py)
|
|
146
139
|
} else {
|
|
147
140
|
// u64 values > i64::MAX are represented as u64 in serde_json.
|
|
148
141
|
// Try widening to u64 before giving up.
|
|
149
142
|
if let Some(u) = n.as_u64() {
|
|
150
|
-
|
|
143
|
+
u.into_py_any(py)
|
|
151
144
|
} else {
|
|
152
145
|
Err(FerrumHydrationError::new_err(
|
|
153
146
|
"numeric value is out of representable range",
|
|
@@ -155,20 +148,20 @@ fn json_value_to_pyobj(py: Python<'_>, val: &serde_json::Value) -> PyResult<PyOb
|
|
|
155
148
|
}
|
|
156
149
|
}
|
|
157
150
|
}
|
|
158
|
-
serde_json::Value::String(s) =>
|
|
151
|
+
serde_json::Value::String(s) => s.as_str().into_py_any(py),
|
|
159
152
|
serde_json::Value::Array(arr) => {
|
|
160
|
-
let list = pyo3::types::PyList::
|
|
153
|
+
let list = pyo3::types::PyList::empty(py);
|
|
161
154
|
for item in arr {
|
|
162
155
|
list.append(json_value_to_pyobj(py, item)?)?;
|
|
163
156
|
}
|
|
164
|
-
Ok(list.
|
|
157
|
+
Ok(list.into_any().unbind())
|
|
165
158
|
}
|
|
166
159
|
serde_json::Value::Object(obj) => {
|
|
167
|
-
let dict = pyo3::types::PyDict::
|
|
160
|
+
let dict = pyo3::types::PyDict::new(py);
|
|
168
161
|
for (k, v) in obj {
|
|
169
162
|
dict.set_item(k, json_value_to_pyobj(py, v)?)?;
|
|
170
163
|
}
|
|
171
|
-
Ok(dict.
|
|
164
|
+
Ok(dict.into_any().unbind())
|
|
172
165
|
}
|
|
173
166
|
}
|
|
174
167
|
}
|
|
@@ -184,7 +177,7 @@ fn json_value_to_pyobj(py: Python<'_>, val: &serde_json::Value) -> PyResult<PyOb
|
|
|
184
177
|
/// column is missing/null in a row.
|
|
185
178
|
/// - `FerrumInternalError` — Rust panic (should never occur in normal use; ERR-2).
|
|
186
179
|
#[pyfunction]
|
|
187
|
-
fn hydrate_rows(py: Python<'_>, metadata_json: &str, rows_json: &str) -> PyResult<
|
|
180
|
+
fn hydrate_rows(py: Python<'_>, metadata_json: &str, rows_json: &str) -> PyResult<Py<PyAny>> {
|
|
188
181
|
// `AssertUnwindSafe` is sound: only `&str` (Copy, RefUnwindSafe) crosses the
|
|
189
182
|
// panic boundary, called exactly once.
|
|
190
183
|
let unwind_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
@@ -198,15 +191,15 @@ fn hydrate_rows(py: Python<'_>, metadata_json: &str, rows_json: &str) -> PyResul
|
|
|
198
191
|
|
|
199
192
|
match unwind_result {
|
|
200
193
|
Ok(Ok(rows)) => {
|
|
201
|
-
let list = pyo3::types::PyList::
|
|
194
|
+
let list = pyo3::types::PyList::empty(py);
|
|
202
195
|
for row in &rows {
|
|
203
|
-
let dict = pyo3::types::PyDict::
|
|
196
|
+
let dict = pyo3::types::PyDict::new(py);
|
|
204
197
|
for (k, v) in row {
|
|
205
198
|
dict.set_item(k, json_value_to_pyobj(py, v)?)?;
|
|
206
199
|
}
|
|
207
200
|
list.append(dict)?;
|
|
208
201
|
}
|
|
209
|
-
Ok(list.
|
|
202
|
+
Ok(list.into_any().unbind())
|
|
210
203
|
}
|
|
211
204
|
Ok(Err(msg)) => Err(FerrumHydrationError::new_err(msg)),
|
|
212
205
|
Err(_panic_payload) => Err(FerrumInternalError::new_err(
|
|
@@ -236,17 +229,11 @@ fn _native(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
236
229
|
m.add_function(wrap_pyfunction!(compile_query, m)?)?;
|
|
237
230
|
m.add_function(wrap_pyfunction!(hydrate_rows, m)?)?;
|
|
238
231
|
m.add_function(wrap_pyfunction!(plan_migration, m)?)?;
|
|
239
|
-
m.add(
|
|
240
|
-
|
|
241
|
-
py.get_type_bound::<FerrumInternalError>(),
|
|
242
|
-
)?;
|
|
243
|
-
m.add(
|
|
244
|
-
"FerrumCompileError",
|
|
245
|
-
py.get_type_bound::<FerrumCompileError>(),
|
|
246
|
-
)?;
|
|
232
|
+
m.add("FerrumInternalError", py.get_type::<FerrumInternalError>())?;
|
|
233
|
+
m.add("FerrumCompileError", py.get_type::<FerrumCompileError>())?;
|
|
247
234
|
m.add(
|
|
248
235
|
"FerrumHydrationError",
|
|
249
|
-
py.
|
|
236
|
+
py.get_type::<FerrumHydrationError>(),
|
|
250
237
|
)?;
|
|
251
238
|
Ok(())
|
|
252
239
|
}
|
|
@@ -6,7 +6,7 @@ Import paths are stable API; internal module paths are not.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
__version__ = "0.1.
|
|
9
|
+
__version__ = "0.1.2"
|
|
10
10
|
__all__ = [
|
|
11
11
|
"FerrumCompileError",
|
|
12
12
|
"FerrumConfigError",
|
|
@@ -28,13 +28,16 @@ __all__ = [
|
|
|
28
28
|
"OneToOne",
|
|
29
29
|
"QuerySet",
|
|
30
30
|
"TSVector",
|
|
31
|
+
"Transaction",
|
|
31
32
|
"Vector",
|
|
32
33
|
"clear_hooks",
|
|
33
34
|
"connect",
|
|
35
|
+
"contrib",
|
|
34
36
|
"register_hook",
|
|
35
37
|
]
|
|
36
38
|
|
|
37
|
-
from ferrum
|
|
39
|
+
from ferrum import contrib
|
|
40
|
+
from ferrum.connection import Transaction, connect
|
|
38
41
|
from ferrum.errors import (
|
|
39
42
|
FerrumCompileError,
|
|
40
43
|
FerrumConfigError,
|
|
@@ -8,7 +8,8 @@ Security invariants:
|
|
|
8
8
|
- No credentials, bound values, or row data appear in emitted code or output.
|
|
9
9
|
- All queries use parameterised ``$1`` placeholders — schema name is the only
|
|
10
10
|
variable and is never interpolated into an identifier position.
|
|
11
|
-
- ``ferrum_migrations
|
|
11
|
+
- ``ferrum_migrations``, ``pg_*`` extension objects, and non-table relations
|
|
12
|
+
are excluded by the query (``BASE TABLE`` only).
|
|
12
13
|
"""
|
|
13
14
|
|
|
14
15
|
from __future__ import annotations
|
|
@@ -42,9 +43,35 @@ _HEADER = textwrap.dedent("""\
|
|
|
42
43
|
""")
|
|
43
44
|
|
|
44
45
|
|
|
46
|
+
# Tables never scaffolded as Ferrum models.
|
|
47
|
+
_EXCLUDED_TABLES: frozenset[str] = frozenset({"ferrum_migrations"})
|
|
48
|
+
|
|
49
|
+
# Extension / catalog objects that may appear in ``public`` as views.
|
|
50
|
+
_EXCLUDED_TABLE_PREFIXES: tuple[str, ...] = ("pg_",)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_scaffoldable_table(table_name: str) -> bool:
|
|
54
|
+
"""Return True when *table_name* should become a scaffolded model."""
|
|
55
|
+
if table_name in _EXCLUDED_TABLES:
|
|
56
|
+
return False
|
|
57
|
+
return not table_name.startswith(_EXCLUDED_TABLE_PREFIXES)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _singularize_table_name(table_name: str) -> str:
|
|
61
|
+
"""Best-effort singular form of a plural snake_case table name."""
|
|
62
|
+
if table_name.endswith("ies") and len(table_name) > 3:
|
|
63
|
+
return f"{table_name[:-3]}y"
|
|
64
|
+
if table_name.endswith(("ches", "shes", "xes", "ses", "zes")) and len(table_name) > 3:
|
|
65
|
+
return table_name[:-2]
|
|
66
|
+
if len(table_name) > 1 and table_name.endswith("s") and not table_name.endswith("ss"):
|
|
67
|
+
return table_name[:-1]
|
|
68
|
+
return table_name
|
|
69
|
+
|
|
70
|
+
|
|
45
71
|
def _to_class_name(table_name: str) -> str:
|
|
46
|
-
"""Convert
|
|
47
|
-
|
|
72
|
+
"""Convert a table name to a singular PascalCase model class name."""
|
|
73
|
+
singular = _singularize_table_name(table_name)
|
|
74
|
+
return "".join(part.capitalize() for part in singular.split("_"))
|
|
48
75
|
|
|
49
76
|
|
|
50
77
|
def _pg_type_to_ferrum(
|
|
@@ -129,12 +156,15 @@ def _pg_type_to_ferrum(
|
|
|
129
156
|
# ---------------------------------------------------------------------------
|
|
130
157
|
|
|
131
158
|
_COL_QUERY = """\
|
|
132
|
-
SELECT table_name, column_name, data_type, character_maximum_length,
|
|
133
|
-
numeric_precision, numeric_scale, is_nullable, column_default,
|
|
134
|
-
ordinal_position
|
|
135
|
-
FROM information_schema.columns
|
|
136
|
-
|
|
137
|
-
|
|
159
|
+
SELECT c.table_name, c.column_name, c.data_type, c.character_maximum_length,
|
|
160
|
+
c.numeric_precision, c.numeric_scale, c.is_nullable, c.column_default,
|
|
161
|
+
c.ordinal_position
|
|
162
|
+
FROM information_schema.columns c
|
|
163
|
+
JOIN information_schema.tables t
|
|
164
|
+
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
|
165
|
+
WHERE c.table_schema = $1
|
|
166
|
+
AND t.table_type = 'BASE TABLE'
|
|
167
|
+
ORDER BY c.table_name, c.ordinal_position
|
|
138
168
|
"""
|
|
139
169
|
|
|
140
170
|
_FK_QUERY = """\
|
|
@@ -148,7 +178,10 @@ JOIN information_schema.referential_constraints rc
|
|
|
148
178
|
ON kcu.constraint_name = rc.constraint_name
|
|
149
179
|
JOIN information_schema.constraint_column_usage ccu
|
|
150
180
|
ON rc.unique_constraint_name = ccu.constraint_name
|
|
181
|
+
JOIN information_schema.tables t
|
|
182
|
+
ON t.table_schema = kcu.table_schema AND t.table_name = kcu.table_name
|
|
151
183
|
WHERE kcu.table_schema = $1
|
|
184
|
+
AND t.table_type = 'BASE TABLE'
|
|
152
185
|
"""
|
|
153
186
|
|
|
154
187
|
_PK_QUERY = """\
|
|
@@ -156,7 +189,11 @@ SELECT kcu.table_name, kcu.column_name
|
|
|
156
189
|
FROM information_schema.key_column_usage kcu
|
|
157
190
|
JOIN information_schema.table_constraints tc
|
|
158
191
|
ON kcu.constraint_name = tc.constraint_name
|
|
159
|
-
|
|
192
|
+
JOIN information_schema.tables t
|
|
193
|
+
ON t.table_schema = kcu.table_schema AND t.table_name = kcu.table_name
|
|
194
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
195
|
+
AND kcu.table_schema = $1
|
|
196
|
+
AND t.table_type = 'BASE TABLE'
|
|
160
197
|
"""
|
|
161
198
|
|
|
162
199
|
|
|
@@ -181,7 +218,11 @@ def _render_class(
|
|
|
181
218
|
foreign_column_name, delete_rule).
|
|
182
219
|
"""
|
|
183
220
|
class_name = _to_class_name(table_name)
|
|
184
|
-
lines: list[str] = [
|
|
221
|
+
lines: list[str] = [
|
|
222
|
+
f"class {class_name}(Model):",
|
|
223
|
+
f" model_config = ferrum.ModelConfig(table={table_name!r})",
|
|
224
|
+
"",
|
|
225
|
+
]
|
|
185
226
|
|
|
186
227
|
for col in columns:
|
|
187
228
|
col_name: str = col["column_name"]
|
|
@@ -270,21 +311,27 @@ async def run_inspectdb(
|
|
|
270
311
|
# pk_cols_by_table: table_name → set of pk column names
|
|
271
312
|
pk_cols_by_table: dict[str, set[str]] = defaultdict(set)
|
|
272
313
|
for row in pk_rows:
|
|
273
|
-
|
|
314
|
+
table = row["table_name"]
|
|
315
|
+
if _is_scaffoldable_table(table):
|
|
316
|
+
pk_cols_by_table[table].add(row["column_name"])
|
|
274
317
|
|
|
275
318
|
# fk_by_table: table_name → {column_name: fk info dict}
|
|
276
319
|
fk_by_table: dict[str, dict[str, dict]] = defaultdict(dict)
|
|
277
320
|
for row in fk_rows:
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
321
|
+
table = row["table_name"]
|
|
322
|
+
if _is_scaffoldable_table(table):
|
|
323
|
+
fk_by_table[table][row["column_name"]] = {
|
|
324
|
+
"foreign_table_name": row["foreign_table_name"],
|
|
325
|
+
"foreign_column_name": row["foreign_column_name"],
|
|
326
|
+
"delete_rule": row["delete_rule"],
|
|
327
|
+
}
|
|
283
328
|
|
|
284
329
|
# cols_by_table: table_name → ordered list of column dicts
|
|
285
330
|
cols_by_table: dict[str, list[dict]] = defaultdict(list)
|
|
286
331
|
for row in col_rows:
|
|
287
|
-
|
|
332
|
+
table = row["table_name"]
|
|
333
|
+
if _is_scaffoldable_table(table):
|
|
334
|
+
cols_by_table[table].append(dict(row))
|
|
288
335
|
|
|
289
336
|
tables = list(cols_by_table.keys())
|
|
290
337
|
|
|
@@ -20,7 +20,7 @@ import typer
|
|
|
20
20
|
from rich import print as rprint
|
|
21
21
|
|
|
22
22
|
from ferrum.connection import connect
|
|
23
|
-
from ferrum.errors import FerrumConfigError, FerrumMigrationError
|
|
23
|
+
from ferrum.errors import FerrumConfigError, FerrumMigrationError, migration_op_failure
|
|
24
24
|
from ferrum.migrations import ledger as _ledger
|
|
25
25
|
from ferrum.migrations import loader as _loader
|
|
26
26
|
from ferrum.migrations.orchestrator import _op_to_sql
|
|
@@ -95,41 +95,56 @@ async def run_migrate(
|
|
|
95
95
|
rprint(f" [dim][dry-run][/dim] would apply {len(ops)} operations")
|
|
96
96
|
continue
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
98
|
+
driver = conn._require_driver()
|
|
99
|
+
dialect = conn.dialect
|
|
100
|
+
if dialect == "postgres":
|
|
101
|
+
pool = getattr(driver, "_pool", None)
|
|
102
|
+
if pool is None:
|
|
103
|
+
raise FerrumMigrationError("PostgreSQL pool is not open. [FERR-M001]")
|
|
104
|
+
async with pool.acquire() as db_conn, db_conn.transaction():
|
|
105
|
+
for op_index, op in enumerate(ops):
|
|
106
|
+
op_dict = op.to_op_dict()
|
|
107
|
+
sql = _op_to_sql(op_dict, dialect=dialect)
|
|
108
|
+
try:
|
|
108
109
|
await db_conn.execute(sql)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
110
|
+
except FerrumMigrationError:
|
|
111
|
+
raise
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
raise migration_op_failure(
|
|
114
|
+
action="apply",
|
|
115
|
+
migration_name=module.name,
|
|
116
|
+
op_index=op_index,
|
|
117
|
+
op=op_dict,
|
|
118
|
+
exc=exc,
|
|
119
|
+
) from None
|
|
119
120
|
await _ledger.record_applied(
|
|
120
121
|
conn,
|
|
121
122
|
digest,
|
|
122
123
|
environment=env,
|
|
123
124
|
description=module.name,
|
|
124
125
|
)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
126
|
+
else:
|
|
127
|
+
for op_index, op in enumerate(ops):
|
|
128
|
+
op_dict = op.to_op_dict()
|
|
129
|
+
sql = _op_to_sql(op_dict, dialect=dialect)
|
|
130
|
+
try:
|
|
131
|
+
await driver.execute(sql)
|
|
132
|
+
except FerrumMigrationError:
|
|
133
|
+
raise
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
raise migration_op_failure(
|
|
136
|
+
action="apply",
|
|
137
|
+
migration_name=module.name,
|
|
138
|
+
op_index=op_index,
|
|
139
|
+
op=op_dict,
|
|
140
|
+
exc=exc,
|
|
141
|
+
) from None
|
|
142
|
+
await _ledger.record_applied(
|
|
143
|
+
conn,
|
|
144
|
+
digest,
|
|
145
|
+
environment=env,
|
|
146
|
+
description=module.name,
|
|
147
|
+
)
|
|
133
148
|
rprint(" [green]OK[/green]")
|
|
134
149
|
|
|
135
150
|
return 0
|