SignalMesh / core /component_data_guard.py
k0880k
make Joomla migration route self-contained and component-centric
aafea53 unverified
Raw History Blame Contribute Delete
7.64 kB
"""Restrict Joomla extension-data migration to component-owned domains.
``dbmesh_policy_patch`` intentionally made the old catch-all extension phase
conservative, but an empty target table could still be treated as client data
simply because it was not otherwise classified. That is still too broad.
The migration rule is now:
component -> owns client data tables
plugin/module/template/package -> runtime/config/dependency branch only
A physical table that cannot be assigned to an installed source component is
quarantined. Component tables that do not yet exist on the target stay behind
until the target component is installed. The underlying row copier, factory
maps, id remaps and semantic merge rules remain unchanged.
"""
from __future__ import annotations
from . import component_data_domains as domains
from . import dbmesh as d
from . import dbmesh_policy_patch as policy
_INSTALLED = False
_PRIOR_CLASSIFY = policy.classify_table
_PRIOR_PHASE_TABLES = policy.phase_tables
_PRIOR_PLAN = d.plan
_PRIOR_MIGRATE = d.migrate
CORE_CLAIMED = {
"usergroups", "viewlevels", "users", "user_usergroup_map",
"template_styles", "categories", "content", "content_frontpage",
"menu_types", "menu", "modules", "modules_menu", "fields_groups",
"fields", "fields_values", "tags", "contentitem_tag_map",
}
def _owner_shape(owner: dict | None) -> dict:
if not owner:
return {}
return {
"component": owner.get("element") or "",
"component_name": owner.get("name") or owner.get("element") or "",
"component_token": owner.get("token") or "",
"component_version": owner.get("version") or "",
"component_core": bool(owner.get("core")),
}
def classify_table(sconn, dconn, sp: str, dp: str, table: str, *, phase: str = "extensions") -> dict:
"""Policy classification with component ownership as the extension gate."""
if phase != "extensions" or table in CORE_CLAIMED:
return _PRIOR_CLASSIFY(sconn, dconn, sp, dp, table, phase=phase)
stabs, dtabs = d.tables(sconn, sp), d.tables(dconn, dp)
src_rows = d.row_count(sconn, sp + table) if table in stabs else 0
dst_rows = d.row_count(dconn, dp + table) if table in dtabs else 0
# Keep the semantic distinction visible even though the lower safety patch
# also places regenerate tables in NEVER_COPY to stop old callers copying
# them. They are rebuildable state, not target factory catalogues.
if table in policy.REGENERATE:
return {"class": "regenerate", "source_rows": src_rows, "target_rows": dst_rows}
if table in policy.FACTORY or table in d.NEVER_COPY:
return _PRIOR_CLASSIFY(sconn, dconn, sp, dp, table, phase=phase)
if table not in stabs:
return _PRIOR_CLASSIFY(sconn, dconn, sp, dp, table, phase=phase)
component = domains.owner(sconn, sp, table)
if component is None:
return {
"class": "quarantine",
"source_rows": src_rows,
"target_rows": dst_rows,
"reason": "table is not owned by an installed Joomla component; plugins/modules/packages are not data owners",
"component": "",
}
result = dict(_PRIOR_CLASSIFY(sconn, dconn, sp, dp, table, phase=phase))
result.update(_owner_shape(component))
if result.get("class") == "missing_target":
result["reason"] = (
f"{component.get('element')} owns this data, but the component table does not exist on the target; "
"install/bridge the target component before migrating its data"
)
elif result.get("class") == "quarantine":
result["reason"] = result.get("reason") or (
f"{component.get('element')} owns this table, but the target already contains rows and no safe semantic merge key is known"
)
return result
def phase_tables(sconn, dconn, sp, dp, phase, exclude=()):
"""Reuse the policy phase selector with the stricter classifier installed."""
return _PRIOR_PHASE_TABLES(sconn, dconn, sp, dp, phase, exclude)
def _component_map(sconn, dconn, sp: str, dp: str) -> dict:
stabs, dtabs = d.tables(sconn, sp), d.tables(dconn, dp)
rows = domains.domains(
sconn, dconn, sp, dp,
source_tables=stabs - CORE_CLAIMED,
target_tables=dtabs - CORE_CLAIMED,
)
out = []
for row in rows:
tables = []
for table in row.get("source_tables") or []:
decision = classify_table(sconn, dconn, sp, dp, table, phase="extensions")
tables.append({"table": table, **decision})
item = {**row, "tables": tables}
item["rows"] = sum(t.get("source_rows", 0) for t in tables
if t.get("class") not in {"target_factory", "regenerate"})
item["migratable_tables"] = [t["table"] for t in tables
if t.get("class") in {"client_entity", "semantic_merge"}]
item["blocked_tables"] = [t["table"] for t in tables
if t.get("class") in {"missing_target", "quarantine"}]
out.append(item)
return {
"rule": "components own data; plugin/module/template/package members are runtime dependencies only",
"domains": out,
"components": len(out),
"tables": sum(len(x.get("tables") or []) for x in out),
"rows": sum(x.get("rows", 0) for x in out),
}
def plan(src, dst, resolve, exclude=()):
"""Classified plan plus explicit component data domains."""
result = _PRIOR_PLAN(src, dst, resolve, exclude)
sconn, sp, _ = resolve(src)
dconn, dp, _ = resolve(dst)
try:
result["component_data"] = _component_map(sconn, dconn, sp, dp)
# The prior planner already sees our patched classifier through the
# policy module, so its quarantined bucket includes unowned tables.
result["unowned_tables"] = [
x for x in result.get("quarantined", []) if not x.get("component")
]
finally:
sconn.close(); dconn.close()
return result
def migrate(src, dst, phases=d.PHASES, *, live=False, urls=True, resolve,
url_override=None, on_progress=None, on_conflict="reassign", exclude=()):
"""Run the existing migrator while exposing component-data semantics."""
sconn, sp, _ = resolve(src)
dconn, dp, _ = resolve(dst)
try:
component_map = _component_map(sconn, dconn, sp, dp)
finally:
sconn.close(); dconn.close()
def progress(event):
ev = dict(event)
if ev.get("phase") == "extensions":
ev["phase"] = "component data"
if on_progress:
on_progress(ev)
report = _PRIOR_MIGRATE(
src, dst, phases,
live=live, urls=urls, resolve=resolve, url_override=url_override,
on_progress=progress, on_conflict=on_conflict, exclude=exclude,
)
report["component_data"] = component_map
# Keep the internal phase key for backward compatibility while naming the
# user-facing meaning explicitly.
report["component_data_phase"] = report.get("phases", {}).get("extensions", {})
return report
def install() -> None:
global _INSTALLED
if _INSTALLED:
return
# dbmesh_policy_patch's plan/migrate resolve these globals at runtime, so
# replacing them here narrows every existing MPort/Studio entry point too.
policy.classify_table = classify_table
policy.phase_tables = phase_tables
d.classify_table = classify_table
d.phase_tables = phase_tables
d.plan = plan
d.migrate = migrate
d.COMPONENT_DATA_DOMAINS = domains
_INSTALLED = True