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.
Files changed (61) hide show
  1. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/Cargo.lock +16 -46
  2. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/Cargo.toml +2 -2
  3. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/PKG-INFO +6 -2
  4. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/README.md +5 -1
  5. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/Cargo.toml +1 -0
  6. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-pyo3/Cargo.toml +1 -0
  7. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-pyo3/src/lib.rs +22 -35
  8. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-sql/Cargo.toml +1 -0
  9. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/pyproject.toml +1 -1
  10. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/__init__.py +5 -2
  11. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/inspectdb_cmd.py +65 -18
  12. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/migrate_cmd.py +44 -29
  13. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/revert_cmd.py +40 -22
  14. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/connection.py +126 -3
  15. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/drivers/postgres.py +75 -0
  16. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/drivers/protocol.py +10 -0
  17. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/errors.py +71 -0
  18. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/orchestrator.py +33 -3
  19. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/models.py +36 -4
  20. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/queryset.py +14 -10
  21. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/LICENSE +0 -0
  22. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/benches/hydrate.rs +0 -0
  23. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/src/compile/mod.rs +0 -0
  24. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/src/error.rs +0 -0
  25. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
  26. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/src/ir/metadata.rs +0 -0
  27. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/src/ir/mod.rs +0 -0
  28. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/src/lib.rs +0 -0
  29. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-core/src/migrate/mod.rs +0 -0
  30. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-sql/benches/compile.rs +0 -0
  31. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-sql/src/dialect.rs +0 -0
  32. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-sql/src/emit.rs +0 -0
  33. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/crates/ferrum-sql/src/lib.rs +0 -0
  34. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/_native.pyi +0 -0
  35. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/__init__.py +0 -0
  36. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/app.py +0 -0
  37. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/bootstrap.py +0 -0
  38. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/init.py +0 -0
  39. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/makemigrations_cmd.py +0 -0
  40. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/migrations_cmd.py +0 -0
  41. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/resetdb_cmd.py +0 -0
  42. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/cli/showmigrations_cmd.py +0 -0
  43. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/config.py +0 -0
  44. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/contrib/__init__.py +0 -0
  45. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/contrib/fastapi.py +0 -0
  46. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/drivers/__init__.py +0 -0
  47. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/drivers/mysql.py +0 -0
  48. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/drivers/sqlite.py +0 -0
  49. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/ext/__init__.py +0 -0
  50. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/ext/pgvector.py +0 -0
  51. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/hooks.py +0 -0
  52. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/__init__.py +0 -0
  53. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/base.py +0 -0
  54. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/drift.py +0 -0
  55. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/gates.py +0 -0
  56. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/introspect.py +0 -0
  57. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/ledger.py +0 -0
  58. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/loader.py +0 -0
  59. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/operations.py +0 -0
  60. {ferrum_orm-0.1.1 → ferrum_orm-0.1.2}/python/ferrum/migrations/tokens.py +0 -0
  61. {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.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.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.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.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.22.6"
560
+ version = "0.29.0"
579
561
  source = "registry+https://github.com/rust-lang/crates.io-index"
580
- checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884"
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.22.6"
574
+ version = "0.29.0"
597
575
  source = "registry+https://github.com/rust-lang/crates.io-index"
598
- checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38"
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.22.6"
583
+ version = "0.29.0"
607
584
  source = "registry+https://github.com/rust-lang/crates.io-index"
608
- checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636"
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.22.6"
593
+ version = "0.29.0"
617
594
  source = "registry+https://github.com/rust-lang/crates.io-index"
618
- checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453"
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.22.6"
605
+ version = "0.29.0"
629
606
  source = "registry+https://github.com/rust-lang/crates.io-index"
630
- checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe"
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.12.16"
837
+ version = "0.13.5"
862
838
  source = "registry+https://github.com/rust-lang/crates.io-index"
863
- checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
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.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.22", features = ["abi3-py311"] }
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.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
- - [ ] Transactions
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
- - [ ] Transactions
155
+ - [x] Transactions
152
156
  - [ ] Query optimization
153
157
  - [ ] Bulk operations
154
158
 
@@ -1,5 +1,6 @@
1
1
  [package]
2
2
  name = "ferrum-core"
3
+ publish = false
3
4
  version.workspace = true
4
5
  edition.workspace = true
5
6
  authors.workspace = true
@@ -1,5 +1,6 @@
1
1
  [package]
2
2
  name = "ferrum-pyo3"
3
+ publish = false
3
4
  version.workspace = true
4
5
  edition.workspace = true
5
6
  authors.workspace = true
@@ -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::new_bound(py);
104
- dict.set_item("sql_text", &compiled.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", &compiled.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.into())
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<PyObject> {
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) => Ok(b.into_py(py)),
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
- Ok(i.into_py(py))
136
+ i.into_py_any(py)
144
137
  } else if let Some(f) = n.as_f64() {
145
- Ok(f.into_py(py))
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
- Ok(u.into_py(py))
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) => Ok(s.as_str().into_py(py)),
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::empty_bound(py);
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.into())
157
+ Ok(list.into_any().unbind())
165
158
  }
166
159
  serde_json::Value::Object(obj) => {
167
- let dict = pyo3::types::PyDict::new_bound(py);
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.into())
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<PyObject> {
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::empty_bound(py);
194
+ let list = pyo3::types::PyList::empty(py);
202
195
  for row in &rows {
203
- let dict = pyo3::types::PyDict::new_bound(py);
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.into())
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
- "FerrumInternalError",
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.get_type_bound::<FerrumHydrationError>(),
236
+ py.get_type::<FerrumHydrationError>(),
250
237
  )?;
251
238
  Ok(())
252
239
  }
@@ -1,5 +1,6 @@
1
1
  [package]
2
2
  name = "ferrum-sql"
3
+ publish = false
3
4
  version.workspace = true
4
5
  edition.workspace = true
5
6
  authors.workspace = true
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "ferrum-orm"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Next-generation async ORM for Python with a Rust-powered core"
9
9
  license = { text = "Apache-2.0" }
10
10
  readme = "README.md"
@@ -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.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.connection import connect
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`` and system tables (``pg_*``) are excluded by the query.
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 snake_case table name to PascalCase class name."""
47
- return "".join(part.capitalize() for part in table_name.split("_"))
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
- WHERE table_schema = $1 AND table_name != 'ferrum_migrations'
137
- ORDER BY table_name, ordinal_position
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
- WHERE tc.constraint_type = 'PRIMARY KEY' AND kcu.table_schema = $1
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] = [f"class {class_name}(Model):"]
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
- pk_cols_by_table[row["table_name"]].add(row["column_name"])
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
- fk_by_table[row["table_name"]][row["column_name"]] = {
279
- "foreign_table_name": row["foreign_table_name"],
280
- "foreign_column_name": row["foreign_column_name"],
281
- "delete_rule": row["delete_rule"],
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
- cols_by_table[row["table_name"]].append(dict(row))
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
- try:
99
- driver = conn._require_driver()
100
- dialect = conn.dialect
101
- if dialect == "postgres":
102
- pool = getattr(driver, "_pool", None)
103
- if pool is None:
104
- raise FerrumMigrationError("PostgreSQL pool is not open. [FERR-M001]")
105
- async with pool.acquire() as db_conn, db_conn.transaction():
106
- for op in ops:
107
- sql = _op_to_sql(op.to_op_dict(), dialect=dialect)
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
- await _ledger.record_applied(
110
- conn,
111
- digest,
112
- environment=env,
113
- description=module.name,
114
- )
115
- else:
116
- for op in ops:
117
- sql = _op_to_sql(op.to_op_dict(), dialect=dialect)
118
- await driver.execute(sql)
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
- except FerrumMigrationError:
126
- raise
127
- except Exception as exc:
128
- raise FerrumMigrationError(
129
- f"Failed to apply migration {module.name!r}: "
130
- f"{type(exc).__name__} [FERR-M001]"
131
- ) from None
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