"""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