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