ferrum-orm 0.1.7__tar.gz → 0.1.8__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 (80) hide show
  1. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/Cargo.lock +6 -6
  2. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/Cargo.toml +1 -1
  3. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/PKG-INFO +9 -1
  4. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/README.md +8 -0
  5. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/pyproject.toml +1 -1
  6. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/__init__.py +1 -1
  7. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/queryset.py +220 -11
  8. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/LICENSE +0 -0
  9. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-core/Cargo.toml +0 -0
  10. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-core/benches/hydrate.rs +0 -0
  11. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-core/src/compile/mod.rs +0 -0
  12. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-core/src/error.rs +0 -0
  13. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
  14. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-core/src/ir/metadata.rs +0 -0
  15. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-core/src/ir/mod.rs +0 -0
  16. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-core/src/lib.rs +0 -0
  17. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-core/src/migrate/mod.rs +0 -0
  18. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-pyo3/Cargo.toml +0 -0
  19. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-pyo3/src/lib.rs +0 -0
  20. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-sql/Cargo.toml +0 -0
  21. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-sql/benches/compile.rs +0 -0
  22. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-sql/src/dialect.rs +0 -0
  23. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-sql/src/emit.rs +0 -0
  24. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-sql/src/fts/mod.rs +0 -0
  25. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
  26. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
  27. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
  28. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
  29. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/crates/ferrum-sql/src/lib.rs +0 -0
  30. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/_native.pyi +0 -0
  31. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/cli/__init__.py +0 -0
  32. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/cli/app.py +0 -0
  33. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/cli/bootstrap.py +0 -0
  34. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/cli/init.py +0 -0
  35. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/cli/inspectdb_cmd.py +0 -0
  36. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/cli/makemigrations_cmd.py +0 -0
  37. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/cli/migrate_cmd.py +0 -0
  38. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/cli/migrations_cmd.py +0 -0
  39. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/cli/resetdb_cmd.py +0 -0
  40. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/cli/revert_cmd.py +0 -0
  41. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/cli/showmigrations_cmd.py +0 -0
  42. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
  43. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/config.py +0 -0
  44. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/connection.py +0 -0
  45. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/contrib/__init__.py +0 -0
  46. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/contrib/fastapi.py +0 -0
  47. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/drivers/__init__.py +0 -0
  48. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/drivers/mssql.py +0 -0
  49. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/drivers/mysql.py +0 -0
  50. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/drivers/postgres.py +0 -0
  51. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/drivers/protocol.py +0 -0
  52. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/drivers/sqlite.py +0 -0
  53. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/errors.py +0 -0
  54. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/expressions.py +0 -0
  55. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/ext/__init__.py +0 -0
  56. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/ext/fts.py +0 -0
  57. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/ext/pgvector.py +0 -0
  58. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/hooks.py +0 -0
  59. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/migrations/__init__.py +0 -0
  60. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/migrations/base.py +0 -0
  61. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/migrations/drift.py +0 -0
  62. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/migrations/fts/__init__.py +0 -0
  63. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/migrations/fts/mssql.py +0 -0
  64. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/migrations/fts/mysql.py +0 -0
  65. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/migrations/fts/postgres.py +0 -0
  66. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/migrations/fts/sqlite.py +0 -0
  67. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/migrations/gates.py +0 -0
  68. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/migrations/introspect.py +0 -0
  69. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/migrations/ledger.py +0 -0
  70. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/migrations/loader.py +0 -0
  71. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/migrations/operations.py +0 -0
  72. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/migrations/orchestrator.py +0 -0
  73. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/migrations/tokens.py +0 -0
  74. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/models.py +0 -0
  75. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/observability.py +0 -0
  76. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/py.typed +0 -0
  77. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/registry.py +0 -0
  78. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/relations.py +0 -0
  79. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/runtime.py +0 -0
  80. {ferrum_orm-0.1.7 → ferrum_orm-0.1.8}/python/ferrum/session.py +0 -0
@@ -174,9 +174,9 @@ dependencies = [
174
174
 
175
175
  [[package]]
176
176
  name = "crossbeam-epoch"
177
- version = "0.9.18"
177
+ version = "0.9.20"
178
178
  source = "registry+https://github.com/rust-lang/crates.io-index"
179
- checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
179
+ checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
180
180
  dependencies = [
181
181
  "crossbeam-utils",
182
182
  ]
@@ -223,7 +223,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
223
223
 
224
224
  [[package]]
225
225
  name = "ferrum-core"
226
- version = "0.1.6"
226
+ version = "0.1.8"
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.6"
237
+ version = "0.1.8"
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.6"
246
+ version = "0.1.8"
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.6"
258
+ version = "0.1.8"
259
259
  dependencies = [
260
260
  "criterion",
261
261
  "ferrum-core",
@@ -3,7 +3,7 @@ members = ["crates/ferrum-core", "crates/ferrum-sql", "crates/ferrum-pyo3"]
3
3
  resolver = "2"
4
4
 
5
5
  [workspace.package]
6
- version = "0.1.6"
6
+ version = "0.1.8"
7
7
  edition = "2021"
8
8
  authors = ["Ferrum Contributors"]
9
9
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferrum-orm
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Framework :: AsyncIO
6
6
  Classifier: Intended Audience :: Developers
@@ -121,6 +121,14 @@ user = await User.objects.create(
121
121
  email="john@example.com",
122
122
  )
123
123
 
124
+ # Insert from an instance (sentinel PK is dropped so the DB default runs)
125
+ draft = User(id=0, email="dana@example.com")
126
+ created = await User.objects.create(conn, draft)
127
+
128
+ # Persist one instance's changes by primary key
129
+ created.is_active = False
130
+ await User.objects.update_instance(conn, created, fields=["is_active"])
131
+
124
132
  users = await (
125
133
  User.objects
126
134
  .filter(is_active=True)
@@ -49,6 +49,14 @@ user = await User.objects.create(
49
49
  email="john@example.com",
50
50
  )
51
51
 
52
+ # Insert from an instance (sentinel PK is dropped so the DB default runs)
53
+ draft = User(id=0, email="dana@example.com")
54
+ created = await User.objects.create(conn, draft)
55
+
56
+ # Persist one instance's changes by primary key
57
+ created.is_active = False
58
+ await User.objects.update_instance(conn, created, fields=["is_active"])
59
+
52
60
  users = await (
53
61
  User.objects
54
62
  .filter(is_active=True)
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "ferrum-orm"
7
- version = "0.1.7"
7
+ version = "0.1.8"
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.7"
9
+ __version__ = "0.1.8"
10
10
  __all__ = [
11
11
  "CreateExtension",
12
12
  "CreateFunction",
@@ -1295,10 +1295,29 @@ class QuerySet(_QuerySetBase[_M], Generic[_M]):
1295
1295
  # Danger API guards (AGENTS.md §3 / ARCHITECTURE.md §3.9)
1296
1296
  # ------------------------------------------------------------------
1297
1297
 
1298
- async def create(self, conn: ConnectionLike, **values: Any) -> _M: # noqa: ANN401
1298
+ async def create(
1299
+ self,
1300
+ conn: ConnectionLike,
1301
+ _obj: _M | dict[str, Any] | None = None,
1302
+ **values: Any, # noqa: ANN401
1303
+ ) -> _M:
1299
1304
  """Insert a single row. Returns the hydrated model instance.
1300
1305
 
1301
- Builds an INSERT IR from ``values``, compiles it through the Rust
1306
+ Accepts either a model instance / dict positionally, or keyword field
1307
+ values — not both::
1308
+
1309
+ await User.objects.create(conn, user) # instance form
1310
+ await User.objects.create(conn, {"email": email}) # dict form
1311
+ await User.objects.create(conn, email=email) # kwargs form
1312
+
1313
+ The instance/dict form mirrors ``bulk_create()`` semantics: values come
1314
+ from ``model_dump()`` and an auto-generated primary key carrying a
1315
+ sentinel value (``0`` / ``None`` / ``""``) is omitted so the database
1316
+ default runs. The kwargs form inserts exactly the given values (no
1317
+ sentinel dropping). The passed instance is never mutated — the return
1318
+ value is a new instance hydrated from ``RETURNING *``.
1319
+
1320
+ Builds an INSERT IR from the values, compiles it through the Rust
1302
1321
  extension, executes ``INSERT … RETURNING *`` via asyncpg ``fetchrow``,
1303
1322
  and constructs the model instance via the ADR-003 trusted hydration path.
1304
1323
 
@@ -1307,15 +1326,31 @@ class QuerySet(_QuerySetBase[_M], Generic[_M]):
1307
1326
 
1308
1327
  Args:
1309
1328
  conn: An open ``Connection`` (obtained from ``ferrum.connect()``).
1329
+ _obj: A model instance or dict of field values. Named with a leading
1330
+ underscore so it can never collide with a model field passed via
1331
+ ``**values`` (Pydantic forbids underscore-prefixed field names).
1310
1332
  **values: Field names and their values to insert. Field names are
1311
1333
  validated against the model's allowlist before compilation.
1312
1334
 
1313
1335
  Raises:
1314
1336
  FerrumConfigError: if the native extension is not built.
1315
- FerrumCompileError: if a field name is not in the model's allowlist.
1337
+ FerrumCompileError: if a field name is not in the model's allowlist,
1338
+ if both an instance/dict and keyword values are given, or if the
1339
+ instance carries deferred fields.
1316
1340
  FerrumInternalError: if the INSERT returned no row (should not occur
1317
1341
  when the DB is healthy and the table exists).
1318
1342
  """
1343
+ if _obj is not None:
1344
+ if values:
1345
+ raise FerrumCompileError(
1346
+ "create() accepts either a model instance/dict or keyword values, not both.",
1347
+ model=self._model.__name__,
1348
+ )
1349
+ row = self._object_to_row_dict(_obj)
1350
+ row_metadata = self._get_metadata()
1351
+ if row_metadata is not None:
1352
+ row = self._drop_auto_pk_sentinel(row, row_metadata)
1353
+ values = row
1319
1354
  if _native_ext is None:
1320
1355
  raise FerrumConfigError(_EXT_NOT_BUILT_MSG)
1321
1356
  if conn is None:
@@ -1374,14 +1409,47 @@ class QuerySet(_QuerySetBase[_M], Generic[_M]):
1374
1409
  return pk_names if pk_names else [metadata.fields[0].name] if metadata.fields else ["id"]
1375
1410
 
1376
1411
  def _object_to_row_dict(self, obj: _M | dict[str, Any]) -> dict[str, Any]:
1377
- """Normalize a bulk-write input object to a mutable field-value dict."""
1412
+ """Normalize a write-input object to a mutable field-value dict.
1413
+
1414
+ Rejects instances carrying deferred fields: ``model_dump()`` bypasses
1415
+ the deferred-field access guard and would silently write class defaults
1416
+ for columns that were never loaded.
1417
+ """
1378
1418
  if isinstance(obj, dict):
1379
1419
  return dict(obj)
1380
1420
  if hasattr(obj, "model_dump"):
1421
+ deferred = getattr(obj, "__ferrum_deferred__", None)
1422
+ if deferred:
1423
+ raise FerrumCompileError(
1424
+ f"Cannot write an instance of model {type(obj).__name__!r} with "
1425
+ f"deferred fields {sorted(deferred)!r}; load the full row first.",
1426
+ model=type(obj).__name__,
1427
+ )
1381
1428
  return obj.model_dump()
1382
- msg = f"bulk_create() expected model instances or dicts, got {type(obj)!r}."
1429
+ msg = f"Expected a model instance or dict, got {type(obj)!r}."
1383
1430
  raise TypeError(msg)
1384
1431
 
1432
+ def _drop_auto_pk_sentinel(
1433
+ self, values: dict[str, Any], metadata: ModelMetadata
1434
+ ) -> dict[str, Any]:
1435
+ """Drop the auto-generated PK column when it carries a sentinel value.
1436
+
1437
+ A sentinel value (``0`` / ``None`` / ``""``) on the first PK field means
1438
+ "let the database default generate this". Raises a structured error when
1439
+ nothing is left to insert, so an empty INSERT never reaches SQL emission.
1440
+ """
1441
+ out = dict(values)
1442
+ pk_name = self._pk_field_name(metadata)
1443
+ if pk_name in out and out[pk_name] in (0, None, ""):
1444
+ out.pop(pk_name, None)
1445
+ if not out:
1446
+ raise FerrumCompileError(
1447
+ f"Insert on model {metadata.model_name!r} requires at least one field "
1448
+ "value after dropping auto-generated primary-key sentinel values.",
1449
+ model=metadata.model_name,
1450
+ )
1451
+ return out
1452
+
1385
1453
  def _build_bulk_insert_ir(
1386
1454
  self,
1387
1455
  rows: list[dict[str, Any]],
@@ -1407,14 +1475,10 @@ class QuerySet(_QuerySetBase[_M], Generic[_M]):
1407
1475
  model=metadata.model_name,
1408
1476
  )
1409
1477
  field_index: dict[str, int] = {f.name: i for i, f in enumerate(metadata.fields)}
1410
- pk_name = self._pk_field_name(metadata)
1411
1478
  ir_rows: list[list[Any]] = []
1412
1479
  column_order: list[str] | None = None
1413
1480
  for row in rows:
1414
- values = dict(row)
1415
- # Drop auto-generated PK columns with sentinel zero/null values.
1416
- if pk_name in values and values[pk_name] in (0, None, ""):
1417
- values.pop(pk_name, None)
1481
+ values = self._drop_auto_pk_sentinel(row, metadata)
1418
1482
  if column_order is None:
1419
1483
  column_order = sorted(values.keys())
1420
1484
  elif sorted(values.keys()) != column_order:
@@ -1715,7 +1779,7 @@ class QuerySet(_QuerySetBase[_M], Generic[_M]):
1715
1779
  field_list = list(fields)
1716
1780
  rows: list[tuple[Any, dict[str, Any]]] = []
1717
1781
  for obj in objects:
1718
- data = obj.model_dump()
1782
+ data = self._object_to_row_dict(obj)
1719
1783
  for pk_name in pk_names:
1720
1784
  if pk_name not in data:
1721
1785
  raise FerrumCompileError(
@@ -2146,6 +2210,32 @@ class QuerySet(_QuerySetBase[_M], Generic[_M]):
2146
2210
 
2147
2211
  return upserted if returning else total
2148
2212
 
2213
+ def _check_write_scope(self, api: str) -> None:
2214
+ """Reject queryset state that UPDATE/DELETE cannot honor.
2215
+
2216
+ ``limit``/``offset`` and join/ranking state are silently absent from the
2217
+ write IR; letting them through would change which rows are affected
2218
+ (e.g. ``filter(...)[:10].delete()`` deleting every matching row). Fail
2219
+ loudly before compilation instead.
2220
+ """
2221
+ if self._limit is not None or self._offset is not None:
2222
+ raise FerrumCompileError(
2223
+ f"{api} cannot be used on a sliced QuerySet; "
2224
+ "LIMIT/OFFSET do not apply to UPDATE/DELETE.",
2225
+ model=self._model.__name__,
2226
+ )
2227
+ if self._select_related:
2228
+ raise FerrumCompileError(
2229
+ f"{api} cannot be used with select_related(); joins do not apply to UPDATE/DELETE.",
2230
+ model=self._model.__name__,
2231
+ )
2232
+ if self._vector_order_by is not None or self._text_rank_by is not None:
2233
+ raise FerrumCompileError(
2234
+ f"{api} cannot be used with nearest_to()/rank_by()/search(); "
2235
+ "ranking does not apply to UPDATE/DELETE.",
2236
+ model=self._model.__name__,
2237
+ )
2238
+
2149
2239
  async def delete(self, conn: ConnectionLike | None = None) -> int:
2150
2240
  """Delete filtered rows. Returns the row count.
2151
2241
 
@@ -2171,6 +2261,7 @@ class QuerySet(_QuerySetBase[_M], Generic[_M]):
2171
2261
  "Refusing unscoped delete(). Use QuerySet.danger_delete_all() "
2172
2262
  "to explicitly delete all rows in the table."
2173
2263
  )
2264
+ self._check_write_scope("delete()")
2174
2265
  if _native_ext is None:
2175
2266
  raise FerrumConfigError(_EXT_NOT_BUILT_MSG)
2176
2267
  if conn is None:
@@ -2301,6 +2392,7 @@ class QuerySet(_QuerySetBase[_M], Generic[_M]):
2301
2392
  "Refusing unscoped update(). Use QuerySet.danger_update_all() "
2302
2393
  "to explicitly update all rows in the table."
2303
2394
  )
2395
+ self._check_write_scope("update()")
2304
2396
  if _native_ext is None:
2305
2397
  raise FerrumConfigError(_EXT_NOT_BUILT_MSG)
2306
2398
  if conn is None:
@@ -2348,6 +2440,123 @@ class QuerySet(_QuerySetBase[_M], Generic[_M]):
2348
2440
  )
2349
2441
  return row_count
2350
2442
 
2443
+ async def update_instance(
2444
+ self,
2445
+ conn: ConnectionLike,
2446
+ obj: _M,
2447
+ *,
2448
+ fields: Sequence[str] | None = None,
2449
+ ) -> int:
2450
+ """Persist one instance's field values to its row, targeted by primary key.
2451
+
2452
+ The singular counterpart of ``bulk_update()``::
2453
+
2454
+ user = await User.objects.filter(email=email).first(conn)
2455
+ user.active = False
2456
+ count = await User.objects.update_instance(conn, user, fields=["active"])
2457
+ if count == 0:
2458
+ ... # row was deleted concurrently (stale instance)
2459
+
2460
+ Values come from ``model_dump()`` (same as the bulk paths), the WHERE
2461
+ clause is the instance's primary key (composite PKs supported), and the
2462
+ call delegates to ``filter(pk=...).update(conn, ...)`` — inheriting the
2463
+ allowlist validation, bound parameters, Tier A hooks, and error mapping.
2464
+
2465
+ Prefer an explicit ``fields=[...]`` subset: ``fields=None`` writes every
2466
+ non-PK column and is last-writer-wins against concurrent updates.
2467
+
2468
+ Args:
2469
+ conn: An open ``Connection`` (obtained from ``ferrum.connect()``).
2470
+ obj: The model instance to persist. Never mutated.
2471
+ fields: Field names to update. ``None`` updates all non-PK fields.
2472
+
2473
+ Returns:
2474
+ The number of rows updated: ``1``, or ``0`` when no row matches the
2475
+ primary key (missing or concurrently deleted — caller decides).
2476
+
2477
+ Raises:
2478
+ FerrumCompileError: if called on a filtered QuerySet, if a primary
2479
+ key value is missing or a sentinel (``0``/``None``/``""``), if
2480
+ ``fields`` is empty, unknown, contains a PK field, or overlaps
2481
+ the instance's deferred fields.
2482
+ """
2483
+ model_name = self._model.__name__
2484
+ if self._is_filtered:
2485
+ raise FerrumCompileError(
2486
+ "update_instance() must be called on an unfiltered QuerySet — it "
2487
+ "targets the row by primary key. Use filter(...).update(conn, ...) "
2488
+ "for filtered updates.",
2489
+ model=model_name,
2490
+ )
2491
+ metadata = self._get_metadata()
2492
+ if metadata is None:
2493
+ raise FerrumCompileError(
2494
+ f"Model {model_name!r} has no metadata.",
2495
+ model=model_name,
2496
+ )
2497
+ field_index = {f.name: i for i, f in enumerate(metadata.fields)}
2498
+ pk_names = self._pk_field_names(metadata)
2499
+ deferred = frozenset(getattr(obj, "__ferrum_deferred__", None) or ())
2500
+ deferred_pks = sorted(set(pk_names) & deferred)
2501
+ if deferred_pks:
2502
+ raise FerrumCompileError(
2503
+ f"update_instance() requires loaded primary-key fields; "
2504
+ f"{deferred_pks!r} are deferred on model {metadata.model_name!r}.",
2505
+ model=metadata.model_name,
2506
+ )
2507
+ if fields is None:
2508
+ if deferred:
2509
+ raise FerrumCompileError(
2510
+ "update_instance() without fields=[...] cannot be used on an "
2511
+ f"instance with deferred fields {sorted(deferred)!r}; pass an "
2512
+ "explicit fields subset.",
2513
+ model=metadata.model_name,
2514
+ )
2515
+ field_list = [f.name for f in metadata.fields if f.name not in pk_names]
2516
+ else:
2517
+ field_list = list(dict.fromkeys(fields))
2518
+ if not field_list:
2519
+ raise FerrumCompileError(
2520
+ "update_instance() requires at least one field.",
2521
+ model=metadata.model_name,
2522
+ )
2523
+ for name in field_list:
2524
+ if name not in field_index:
2525
+ raise FerrumCompileError(
2526
+ f"Unknown field {name!r} on model {metadata.model_name!r}.",
2527
+ model=metadata.model_name,
2528
+ field=name,
2529
+ )
2530
+ if name in pk_names:
2531
+ raise FerrumCompileError(
2532
+ f"update_instance() cannot assign primary-key field {name!r}; "
2533
+ "primary keys identify the target row.",
2534
+ model=metadata.model_name,
2535
+ field=name,
2536
+ )
2537
+ if name in deferred:
2538
+ raise FerrumCompileError(
2539
+ f"update_instance() cannot write deferred field {name!r}; "
2540
+ "it was never loaded on this instance.",
2541
+ model=metadata.model_name,
2542
+ field=name,
2543
+ )
2544
+ row = obj.model_dump(include=set(field_list) | set(pk_names))
2545
+ pk_map: dict[str, Any] = {}
2546
+ for pk_name in pk_names:
2547
+ pk_value = row.get(pk_name)
2548
+ if pk_value in (0, None, ""):
2549
+ raise FerrumCompileError(
2550
+ f"update_instance() requires a primary-key value for field "
2551
+ f"{pk_name!r} on model {metadata.model_name!r}.",
2552
+ model=metadata.model_name,
2553
+ field=pk_name,
2554
+ )
2555
+ pk_map[pk_name] = pk_value
2556
+ assignments = {name: row[name] for name in field_list}
2557
+ target: QuerySet[_M] = QuerySet(self._model).filter(**pk_map)
2558
+ return await target.update(conn, **assignments)
2559
+
2351
2560
  async def danger_update_all(self, conn: ConnectionLike, **assignments: Any) -> int: # noqa: ANN401
2352
2561
  """Update ALL rows in the table without a filter.
2353
2562
 
File without changes