Spaces:
Running
Running
File size: 7,638 Bytes
aafea53 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | """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
|