Updates to patching logic.

This commit is contained in:
Yaro Kasear 2025-10-01 11:41:38 -05:00
parent c040ff74c9
commit 244f0945bb
3 changed files with 63 additions and 44 deletions

View file

@ -10,7 +10,7 @@ from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.sql import operators from sqlalchemy.sql import operators
from sqlalchemy.sql.elements import UnaryExpression, ColumnElement from sqlalchemy.sql.elements import UnaryExpression, ColumnElement
from crudkit.core import deep_diff, diff_to_patch from crudkit.core import deep_diff, diff_to_patch, filter_to_columns, normalize_payload
from crudkit.core.base import Version from crudkit.core.base import Version
from crudkit.core.spec import CRUDSpec from crudkit.core.spec import CRUDSpec
from crudkit.core.types import OrderSpec, SeekWindow from crudkit.core.types import OrderSpec, SeekWindow
@ -661,41 +661,58 @@ class CRUDService(Generic[T]):
session = self.session session = self.session
obj = session.get(self.model, id) obj = session.get(self.model, id)
if not obj: if not obj:
raise ValueError("f{self.model.__name__} id ID {id} not found.") raise ValueError(f"{self.model.__name__} with ID {id} not found.")
# Only touch real columns
valid = {c.name for c in self.model.__table__.columns}
unknown = set(data) - valid
if unknown:
raise ValueError(f"Unknown fields: {', '.join(sorted(unknown))}")
# BEFORE snapshot (non-lazy, columns-only is fine)
before = obj.as_dict() before = obj.as_dict()
# Apply patch # Normalize and restrict payload to real columns
for k, v in data.items(): norm = normalize_payload(data, self.model)
setattr(obj, k, v) incoming = filter_to_columns(norm, self.model)
# If nothing changed at ORM level, bail early # Build a synthetic "desired" state for top-level columns
if not session.is_modified(obj, include_collections=False): desired = {**before, **incoming}
# Compute intended change set (before vs intended)
proposed = deep_diff(
before, desired,
ignore_keys={"id", "created_at", "updated_at"},
list_mode="index",
)
patch = diff_to_patch(proposed)
# Nothing to do
if not patch:
return obj return obj
# Apply only what actually changes
for k, v in patch.items():
setattr(obj, k, v)
# Optional: skip commit if ORM says no real change (paranoid check)
# Note: is_modified can lie if attrs are expired; use history for certainty.
dirty = any(inspect(obj).attrs[k].history.has_changes() for k in patch.keys())
if not dirty:
return obj
# Commit atomically
session.commit() session.commit()
# AFTER snapshot for audit
after = obj.as_dict() after = obj.as_dict()
diff = deep_diff( # Actual diff (captures triggers/defaults, still ignoring noisy keys)
before, actual = deep_diff(
after, before, after,
ignore_keys={"updated_at", "created_at", "id"}, ignore_keys={"id", "created_at", "updated_at"},
list_mode="index", list_mode="index",
) )
# If somehow no diff, do not spam versions # If truly nothing changed post-commit (rare), skip version spam
if not(diff["added"] or diff["removed"] or diff["changed"]): if not (actual["added"] or actual["removed"] or actual["changed"]):
return obj return obj
self._log_version("update", obj, actor, metadata={"diff": diff}) # Log both what we *intended* and what *actually* happened
self._log_version("update", obj, actor, metadata={"diff": actual, "patch": patch})
return obj return obj
def delete(self, id: int, hard: bool = False, actor = None): def delete(self, id: int, hard: bool = False, actor = None):

View file

@ -197,7 +197,7 @@ def init_entry_routes(app):
raise TypeError("Invalid model.") raise TypeError("Invalid model.")
service = crudkit.crud.get_service(cls) service = crudkit.crud.get_service(cls)
item = service.get(id, params) service.update(id, data=payload, actor="update_entry")
return {"status": "success", "payload": payload} return {"status": "success", "payload": payload}
except Exception as e: except Exception as e:

View file

@ -31,6 +31,8 @@ def init_index_routes(app):
"limit": 0 "limit": 0
}) })
rows = [item.as_dict() for item in inventory_report_rows] rows = [item.as_dict() for item in inventory_report_rows]
chart_data = {}
if rows:
df = pd.DataFrame(rows) df = pd.DataFrame(rows)
xtab = pd.crosstab(df["condition"], df["device_type.description"]).astype(int) xtab = pd.crosstab(df["condition"], df["device_type.description"]).astype(int)