k0880k commited on
Commit
4e99f09
·
1 Parent(s): 874e249

fix(migration): encode proven Joomla table semantics

Browse files
Files changed (1) hide show
  1. core/component_data_semantics.py +175 -0
core/component_data_semantics.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conservative Joomla table semantics discovered from the target runtime.
2
+
3
+ This layer exists for tables that were being blocked only because the generic
4
+ component-data classifier did not yet know what they mean. It deliberately
5
+ handles only cases we can prove without inventing a merge key:
6
+
7
+ * Smart Search link/term joins and scheduler execution logs are derived state;
8
+ * user profiles are client data keyed by the migrated user + profile key;
9
+ * Guided Tours and Scheduler Tasks may be reused only when every source
10
+ semantic identity already exists on the target (no unsafe asset-id insert);
11
+ * Joomla's stock one-stage/seven-transition workflow is target factory state and
12
+ article workflow associations are regenerated against the target default.
13
+
14
+ Anything outside those proofs remains quarantined. In particular this module
15
+ does not guess identities for third-party Regular Labs tables or scheduler_jobs.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from . import component_data_guard as component
20
+ from . import dbmesh as d
21
+ from . import dbmesh_policy_patch as policy
22
+
23
+ _INSTALLED = False
24
+ _PRIOR_CLASSIFY = component.classify_table
25
+ _PRIOR_PHASE_TABLES = component.phase_tables
26
+
27
+ DERIVED = {
28
+ "finder_links_terms",
29
+ "scheduler_logs",
30
+ }
31
+
32
+ REUSE_ONLY_KEYS = {
33
+ # uid is indexed by Joomla and names the tour independently of install-local id.
34
+ "guidedtours": ("uid",),
35
+ # A task type may be instantiated more than once, so title is part of identity.
36
+ "scheduler_tasks": ("type", "title"),
37
+ }
38
+
39
+ BASIC_STAGE_TITLES = {"COM_WORKFLOW_BASIC_STAGE"}
40
+ BASIC_TRANSITION_TITLES = {
41
+ "UNPUBLISH", "PUBLISH", "TRASH", "ARCHIVE",
42
+ "FEATURE", "UNFEATURE", "PUBLISH_AND_FEATURE",
43
+ }
44
+
45
+
46
+ def _rows(conn, table: str, cols: tuple[str, ...]) -> list[dict]:
47
+ available = d.columns(conn, table)
48
+ if any(col not in available for col in cols):
49
+ return []
50
+ with conn.cursor() as cur:
51
+ cur.execute(
52
+ "SELECT " + ", ".join(f"`{c}`" for c in cols) + f" FROM `{table}`"
53
+ )
54
+ return list(cur.fetchall())
55
+
56
+
57
+ def _keys(conn, table: str, cols: tuple[str, ...]) -> set[tuple[str, ...]]:
58
+ return {
59
+ tuple("" if row.get(c) is None else str(row.get(c)) for c in cols)
60
+ for row in _rows(conn, table, cols)
61
+ }
62
+
63
+
64
+ def _source_keys_are_target_owned(sconn, dconn, sp: str, dp: str,
65
+ table: str, cols: tuple[str, ...]) -> tuple[bool, list[tuple[str, ...]]]:
66
+ stabs, dtabs = d.tables(sconn, sp), d.tables(dconn, dp)
67
+ if table not in stabs or table not in dtabs:
68
+ return False, []
69
+ source = _keys(sconn, sp + table, cols)
70
+ target = _keys(dconn, dp + table, cols)
71
+ if not source:
72
+ return False, []
73
+ missing = sorted(source - target)
74
+ return not missing, missing[:20]
75
+
76
+
77
+ def _basic_workflow_is_target_factory(sconn, dconn, sp: str, dp: str) -> bool:
78
+ needed = {"workflow_stages", "workflow_transitions"}
79
+ if not needed <= d.tables(sconn, sp) or not needed <= d.tables(dconn, dp):
80
+ return False
81
+ src_stages = {k[0] for k in _keys(sconn, sp + "workflow_stages", ("title",))}
82
+ src_transitions = {k[0] for k in _keys(sconn, sp + "workflow_transitions", ("title",))}
83
+ dst_stages = {k[0] for k in _keys(dconn, dp + "workflow_stages", ("title",))}
84
+ dst_transitions = {k[0] for k in _keys(dconn, dp + "workflow_transitions", ("title",))}
85
+ return (
86
+ src_stages == BASIC_STAGE_TITLES
87
+ and src_transitions == BASIC_TRANSITION_TITLES
88
+ and src_stages <= dst_stages
89
+ and src_transitions <= dst_transitions
90
+ )
91
+
92
+
93
+ def classify_table(sconn, dconn, sp: str, dp: str, table: str, *, phase: str = "extensions") -> dict:
94
+ """Add only evidence-backed semantics to the component classifier."""
95
+ result = dict(_PRIOR_CLASSIFY(sconn, dconn, sp, dp, table, phase=phase))
96
+
97
+ if table in REUSE_ONLY_KEYS and phase == "extensions":
98
+ cols = REUSE_ONLY_KEYS[table]
99
+ safe, missing = _source_keys_are_target_owned(sconn, dconn, sp, dp, table, cols)
100
+ if safe:
101
+ result.update({
102
+ "class": "semantic_merge",
103
+ "identity": list(cols),
104
+ "reason": "every source semantic identity already exists on the target; reuse target-owned rows",
105
+ })
106
+ elif result.get("source_rows") and result.get("target_rows"):
107
+ result.update({
108
+ "class": "quarantine",
109
+ "reason": (
110
+ "source contains semantic identities absent from the target; "
111
+ "inserting this table safely needs its asset/relationship policy"
112
+ ),
113
+ "source_only_identities": [list(x) for x in missing],
114
+ })
115
+ return result
116
+
117
+ if table in {"workflow_stages", "workflow_transitions", "workflow_associations"} \
118
+ and phase == "extensions" and _basic_workflow_is_target_factory(sconn, dconn, sp, dp):
119
+ if table == "workflow_associations":
120
+ result.update({
121
+ "class": "regenerate",
122
+ "reason": "stock Joomla workflow associations are rebound to the target default stage after article migration",
123
+ })
124
+ else:
125
+ result.update({
126
+ "class": "target_factory",
127
+ "reason": "source contains Joomla's stock basic workflow definition already owned by the target runtime",
128
+ })
129
+ return result
130
+
131
+ return result
132
+
133
+
134
+ def phase_tables(sconn, dconn, sp, dp, phase, exclude=()):
135
+ """Only classes that represent client rows may reach copy_table."""
136
+ rows = _PRIOR_PHASE_TABLES(sconn, dconn, sp, dp, phase, exclude)
137
+ if phase != "extensions":
138
+ return rows
139
+ out = []
140
+ for table in rows:
141
+ decision = classify_table(sconn, dconn, sp, dp, table, phase="extensions")
142
+ if decision.get("class") in {"client_entity", "semantic_merge"}:
143
+ out.append(table)
144
+ return out
145
+
146
+
147
+ def install() -> None:
148
+ global _INSTALLED
149
+ if _INSTALLED:
150
+ return
151
+
152
+ # Derived tables are never copied. The target rebuilds them from canonical data.
153
+ policy.REGENERATE.update(DERIVED)
154
+ d.NEVER_COPY.update(DERIVED)
155
+
156
+ # Client profile values follow the migrated user id. No install-local asset id
157
+ # is involved, so this is a normal semantic merge rather than reuse-only.
158
+ d.NATURAL_KEY["user_profiles"] = ("user_id", "profile_key")
159
+ d.FKS.setdefault("user_profiles", {})["user_id"] = "users"
160
+
161
+ # These keys are used only after classify_table proves source ⊆ target. That
162
+ # guarantees copy_table can reuse them and cannot fall into an unsafe insert.
163
+ for table, cols in REUSE_ONLY_KEYS.items():
164
+ d.NATURAL_KEY[table] = cols
165
+
166
+ # Every existing planning/migration surface resolves these module globals at
167
+ # runtime, so one install covers Studio, MPort, plan and live execution.
168
+ component.classify_table = classify_table
169
+ component.phase_tables = phase_tables
170
+ policy.classify_table = classify_table
171
+ policy.phase_tables = phase_tables
172
+ d.classify_table = classify_table
173
+ d.phase_tables = phase_tables
174
+
175
+ _INSTALLED = True