sqlmirror 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlmirror/__init__.py +1 -0
- sqlmirror/apply.py +136 -0
- sqlmirror/backfill.py +65 -0
- sqlmirror/cli.py +335 -0
- sqlmirror/cron.py +103 -0
- sqlmirror/db.py +469 -0
- sqlmirror/db_mssql.py +530 -0
- sqlmirror/dev/__init__.py +1 -0
- sqlmirror/dev/cli.py +166 -0
- sqlmirror/dev/compare.py +87 -0
- sqlmirror/dev/e2e.py +261 -0
- sqlmirror/dev/golden.py +182 -0
- sqlmirror/dev/mock_jira.py +347 -0
- sqlmirror/dev/site.py +383 -0
- sqlmirror/dev/synth.py +769 -0
- sqlmirror/golden/01_issues_by_status.sql +8 -0
- sqlmirror/golden/02_issue_keys.sql +7 -0
- sqlmirror/golden/03_created_vs_resolved.sql +8 -0
- sqlmirror/golden/04_open_by_priority.sql +10 -0
- sqlmirror/golden/05_time_in_status.sql +16 -0
- sqlmirror/golden/06_cycle_time.sql +16 -0
- sqlmirror/golden/07_worklog_by_user.sql +11 -0
- sqlmirror/golden/08_fix_versions.sql +11 -0
- sqlmirror/golden/09_bugs_by_affected_version.sql +11 -0
- sqlmirror/golden/10_components.sql +10 -0
- sqlmirror/golden/11_top_labels.sql +9 -0
- sqlmirror/golden/12_select_field_distribution.sql +11 -0
- sqlmirror/golden/13_story_points_by_sprint.sql +11 -0
- sqlmirror/golden/14_sprint_completion.sql +14 -0
- sqlmirror/golden/15_epics.sql +12 -0
- sqlmirror/golden/16_subtasks.sql +10 -0
- sqlmirror/golden/17_blocked_issues.sql +11 -0
- sqlmirror/golden/18_most_discussed.sql +10 -0
- sqlmirror/golden/19_assignee_workload.sql +11 -0
- sqlmirror/golden/20_resolutions.sql +7 -0
- sqlmirror/golden/21_issue_history.sql +10 -0
- sqlmirror/golden/22_reopened.sql +11 -0
- sqlmirror/golden/23_overdue.sql +8 -0
- sqlmirror/golden/24_cascading_select.sql +13 -0
- sqlmirror/golden/25_user_picker.sql +12 -0
- sqlmirror/golden/26_attachments.sql +9 -0
- sqlmirror/golden/27_project_categories.sql +8 -0
- sqlmirror/golden/28_issue_types.sql +7 -0
- sqlmirror/golden/29_estimate_accuracy.sql +11 -0
- sqlmirror/golden/30_project_leads.sql +7 -0
- sqlmirror/golden/tsql/01_issues_by_status.sql +8 -0
- sqlmirror/golden/tsql/02_issue_keys.sql +6 -0
- sqlmirror/golden/tsql/03_created_vs_resolved.sql +8 -0
- sqlmirror/golden/tsql/04_open_by_priority.sql +10 -0
- sqlmirror/golden/tsql/05_time_in_status.sql +16 -0
- sqlmirror/golden/tsql/06_cycle_time.sql +16 -0
- sqlmirror/golden/tsql/07_worklog_by_user.sql +11 -0
- sqlmirror/golden/tsql/08_fix_versions.sql +11 -0
- sqlmirror/golden/tsql/09_bugs_by_affected_version.sql +11 -0
- sqlmirror/golden/tsql/10_components.sql +10 -0
- sqlmirror/golden/tsql/11_top_labels.sql +8 -0
- sqlmirror/golden/tsql/12_select_field_distribution.sql +11 -0
- sqlmirror/golden/tsql/13_story_points_by_sprint.sql +11 -0
- sqlmirror/golden/tsql/14_sprint_completion.sql +14 -0
- sqlmirror/golden/tsql/15_epics.sql +11 -0
- sqlmirror/golden/tsql/16_subtasks.sql +9 -0
- sqlmirror/golden/tsql/17_blocked_issues.sql +11 -0
- sqlmirror/golden/tsql/18_most_discussed.sql +9 -0
- sqlmirror/golden/tsql/19_assignee_workload.sql +11 -0
- sqlmirror/golden/tsql/20_resolutions.sql +7 -0
- sqlmirror/golden/tsql/21_issue_history.sql +10 -0
- sqlmirror/golden/tsql/22_reopened.sql +11 -0
- sqlmirror/golden/tsql/23_overdue.sql +8 -0
- sqlmirror/golden/tsql/24_cascading_select.sql +13 -0
- sqlmirror/golden/tsql/25_user_picker.sql +12 -0
- sqlmirror/golden/tsql/26_attachments.sql +9 -0
- sqlmirror/golden/tsql/27_project_categories.sql +8 -0
- sqlmirror/golden/tsql/28_issue_types.sql +7 -0
- sqlmirror/golden/tsql/29_estimate_accuracy.sql +11 -0
- sqlmirror/golden/tsql/30_project_leads.sql +7 -0
- sqlmirror/idmap.py +161 -0
- sqlmirror/jira.py +223 -0
- sqlmirror/mapper.py +774 -0
- sqlmirror/reconcile.py +73 -0
- sqlmirror/samples/dc_export_example.csv +19 -0
- sqlmirror/samples/dc_reports/mysql_sprint_report.sql +10 -0
- sqlmirror/samples/dc_reports/postgres_assigned_to_user.sql +6 -0
- sqlmirror/samples/dc_reports/postgres_customer_impact.sql +7 -0
- sqlmirror/samples/dc_reports/postgres_time_logged.sql +7 -0
- sqlmirror/samples/dc_reports/postgres_workflow_steps.sql +4 -0
- sqlmirror/samples/dc_reports/sqlserver_open_by_status.sql +9 -0
- sqlmirror/schema.py +518 -0
- sqlmirror/source.py +117 -0
- sqlmirror/sqlcheck.py +647 -0
- sqlmirror/util.py +80 -0
- sqlmirror-0.2.0.dist-info/METADATA +19 -0
- sqlmirror-0.2.0.dist-info/RECORD +95 -0
- sqlmirror-0.2.0.dist-info/WHEEL +5 -0
- sqlmirror-0.2.0.dist-info/entry_points.txt +3 -0
- sqlmirror-0.2.0.dist-info/top_level.txt +1 -0
sqlmirror/sqlcheck.py
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
"""Check and rewrite Data Center report SQL so it runs against the mirror.
|
|
2
|
+
|
|
3
|
+
For every statement it:
|
|
4
|
+
1. parses it in the source dialect (Postgres, SQL Server, MySQL, Oracle);
|
|
5
|
+
2. flags tables the mirror does not provide, and columns that are always NULL;
|
|
6
|
+
3. finds hard-coded Data Center ids (statuses, custom fields, projects...) and
|
|
7
|
+
usernames, which all change in a Cloud migration, and rewrites them when an
|
|
8
|
+
id map is supplied;
|
|
9
|
+
4. fixes identifier case (Active Objects tables are quoted upper-case,
|
|
10
|
+
everything else lower-case) and boolean comparisons on sprint flags;
|
|
11
|
+
5. transpiles to the mirror's database: Postgres, or SQL Server (T-SQL),
|
|
12
|
+
fixing what a plain translation gets wrong (|| with numbers, GROUP BY 1,
|
|
13
|
+
LATERAL, date_trunc, extract(epoch ...), ::numeric);
|
|
14
|
+
6. optionally compiles the result against a mirror to prove it binds
|
|
15
|
+
(EXPLAIN on Postgres, sp_describe_first_result_set on SQL Server).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import sqlglot
|
|
26
|
+
from sqlglot import exp
|
|
27
|
+
from sqlglot.errors import ErrorLevel, ParseError
|
|
28
|
+
|
|
29
|
+
from .idmap import IdMap
|
|
30
|
+
from .schema import ALWAYS_NULL_COLUMNS, TABLE_BY_NAME, UNSUPPORTED_TABLES
|
|
31
|
+
|
|
32
|
+
TARGETS = {"postgres": "postgres", "postgresql": "postgres", "sqlserver": "tsql", "mssql": "tsql", "tsql": "tsql"}
|
|
33
|
+
|
|
34
|
+
DIALECTS = {"postgres": "postgres", "postgresql": "postgres", "sqlserver": "tsql", "mssql": "tsql", "tsql": "tsql",
|
|
35
|
+
"mysql": "mysql", "mariadb": "mysql", "oracle": "oracle"}
|
|
36
|
+
|
|
37
|
+
MIRROR_TABLES = {name.lower(): name for name in TABLE_BY_NAME if not name.startswith("sqlmirror_")}
|
|
38
|
+
AO_TABLES = {name.lower(): name for name in TABLE_BY_NAME if name.startswith("AO_")}
|
|
39
|
+
AO_COLUMNS = {name.lower(): {c.lower(): c for c in TABLE_BY_NAME[name].column_names} for name in AO_TABLES.values()}
|
|
40
|
+
AO_BOOLEAN_COLUMNS = {"started", "closed"}
|
|
41
|
+
|
|
42
|
+
# (table, column) -> entity whose ids differ between Data Center and Cloud.
|
|
43
|
+
ID_COLUMNS: dict[tuple[str, str], str] = {
|
|
44
|
+
("jiraissue", "issuestatus"): "status",
|
|
45
|
+
("jiraissue", "priority"): "priority",
|
|
46
|
+
("jiraissue", "resolution"): "resolution",
|
|
47
|
+
("jiraissue", "issuetype"): "issuetype",
|
|
48
|
+
("jiraissue", "project"): "project",
|
|
49
|
+
("jiraissue", "id"): "issue",
|
|
50
|
+
("jiraissue", "security"): "securitylevel",
|
|
51
|
+
("projectversion", "project"): "project",
|
|
52
|
+
("component", "project"): "project",
|
|
53
|
+
("customfieldvalue", "customfield"): "customfield",
|
|
54
|
+
("customfieldvalue", "issue"): "issue",
|
|
55
|
+
("customfieldoption", "customfield"): "customfield",
|
|
56
|
+
("label", "fieldid"): "customfield",
|
|
57
|
+
("label", "issue"): "issue",
|
|
58
|
+
("issuelink", "linktype"): "issuelinktype",
|
|
59
|
+
("issuelink", "source"): "issue",
|
|
60
|
+
("issuelink", "destination"): "issue",
|
|
61
|
+
("changegroup", "issueid"): "issue",
|
|
62
|
+
("worklog", "issueid"): "issue",
|
|
63
|
+
("jiraaction", "issueid"): "issue",
|
|
64
|
+
("issuestatus", "id"): "status",
|
|
65
|
+
("priority", "id"): "priority",
|
|
66
|
+
("resolution", "id"): "resolution",
|
|
67
|
+
("issuetype", "id"): "issuetype",
|
|
68
|
+
("project", "id"): "project",
|
|
69
|
+
("customfield", "id"): "customfield",
|
|
70
|
+
("issuelinktype", "id"): "issuelinktype",
|
|
71
|
+
("projectversion", "id"): "version",
|
|
72
|
+
("component", "id"): "component",
|
|
73
|
+
("customfieldoption", "id"): "option",
|
|
74
|
+
("ao_60db71_sprint", "id"): "sprint",
|
|
75
|
+
}
|
|
76
|
+
# Column names distinctive enough to map without knowing the table.
|
|
77
|
+
ID_COLUMN_NAMES = {"issuestatus": "status", "issuetype": "issuetype", "customfield": "customfield",
|
|
78
|
+
"linktype": "issuelinktype", "fieldid": "customfield"}
|
|
79
|
+
USER_COLUMNS = {"assignee", "reporter", "creator", "author", "updateauthor", "lead", "user_key", "lower_user_name",
|
|
80
|
+
"user_name", "owner_user_name"}
|
|
81
|
+
AMBIGUOUS_ID_COLUMNS = {("customfieldvalue", "stringvalue"): "option or sprint ids stored as text",
|
|
82
|
+
("customfieldvalue", "numbervalue"): "version, project or issue ids stored as numbers",
|
|
83
|
+
("nodeassociation", "sink_node_id"): "version or component ids",
|
|
84
|
+
("changeitem", "oldvalue"): "status, user or other ids in change history",
|
|
85
|
+
("changeitem", "newvalue"): "status, user or other ids in change history"}
|
|
86
|
+
|
|
87
|
+
ENTITY_HELP = {
|
|
88
|
+
"status": "compare issuestatus.pname instead, or map ids with --id-map",
|
|
89
|
+
"priority": "compare priority.pname instead, or map ids with --id-map",
|
|
90
|
+
"resolution": "compare resolution.pname instead, or map ids with --id-map",
|
|
91
|
+
"issuetype": "compare issuetype.pname instead, or map ids with --id-map",
|
|
92
|
+
"project": "compare project.pkey instead, or map ids with --id-map",
|
|
93
|
+
"customfield": "compare customfield.cfname instead, or map ids with --id-map",
|
|
94
|
+
"issuelinktype": "compare issuelinktype.linkname instead, or map ids with --id-map",
|
|
95
|
+
"version": "compare projectversion.vname instead, or map ids with --id-map",
|
|
96
|
+
"component": "compare component.cname instead, or map ids with --id-map",
|
|
97
|
+
"option": "compare customfieldoption.customvalue instead, or map ids with --id-map",
|
|
98
|
+
"sprint": "compare \"AO_60DB71_SPRINT\".\"NAME\" instead, or map ids with --id-map",
|
|
99
|
+
"issue": "issue ids change in migration; use project key + issue number instead",
|
|
100
|
+
"user": "Cloud identifies users by account id; join cwd_user on display_name or email, or map with --id-map",
|
|
101
|
+
"securitylevel": "security level ids change in migration",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_TARGET_NAMES = {"postgres": "Postgres", "tsql": "SQL Server"}
|
|
106
|
+
_TEXT_TYPES = (exp.DataType.Type.VARCHAR, exp.DataType.Type.NVARCHAR, exp.DataType.Type.CHAR,
|
|
107
|
+
exp.DataType.Type.NCHAR, exp.DataType.Type.TEXT)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class Finding:
|
|
112
|
+
severity: str # "error" | "warning" | "fix" | "info"
|
|
113
|
+
message: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class StatementResult:
|
|
118
|
+
index: int
|
|
119
|
+
original: str
|
|
120
|
+
rewritten: str | None
|
|
121
|
+
findings: list[Finding] = field(default_factory=list)
|
|
122
|
+
explain: str | None = None # None = not run, "ok" or the error text
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def verdict(self) -> str:
|
|
126
|
+
sev = {f.severity for f in self.findings}
|
|
127
|
+
if "error" in sev or (self.explain not in (None, "ok")):
|
|
128
|
+
return "needs work"
|
|
129
|
+
if "warning" in sev:
|
|
130
|
+
return "check"
|
|
131
|
+
if "fix" in sev:
|
|
132
|
+
return "rewritten"
|
|
133
|
+
return "works as-is"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _Capture(logging.Handler):
|
|
137
|
+
def __init__(self) -> None:
|
|
138
|
+
super().__init__()
|
|
139
|
+
self.messages: list[str] = []
|
|
140
|
+
|
|
141
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
142
|
+
self.messages.append(record.getMessage())
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _literal_value(node: exp.Expression) -> str | None:
|
|
146
|
+
if isinstance(node, exp.Literal):
|
|
147
|
+
return node.this
|
|
148
|
+
if isinstance(node, exp.Neg) and isinstance(node.this, exp.Literal):
|
|
149
|
+
return "-" + node.this.this
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _set_literal(node: exp.Expression, value: str) -> None:
|
|
154
|
+
if isinstance(node, exp.Literal):
|
|
155
|
+
if node.is_string:
|
|
156
|
+
node.replace(exp.Literal.string(value))
|
|
157
|
+
elif re.fullmatch(r"-?\d+(\.\d+)?", value):
|
|
158
|
+
node.replace(exp.Literal.number(value))
|
|
159
|
+
else:
|
|
160
|
+
node.replace(exp.Literal.string(value))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Checker:
|
|
164
|
+
"""``mirror`` (a PgMirror / MssqlMirror) or ``conn`` (a Postgres connection) enables the compile check."""
|
|
165
|
+
|
|
166
|
+
def __init__(self, read_dialect: str = "postgres", idmap: IdMap | None = None, conn: Any = None,
|
|
167
|
+
*, mirror: Any = None, target: str | None = None):
|
|
168
|
+
if read_dialect.lower() not in DIALECTS:
|
|
169
|
+
raise ValueError(f"unknown dialect {read_dialect}; use one of {', '.join(sorted(DIALECTS))}")
|
|
170
|
+
self.read = DIALECTS[read_dialect.lower()]
|
|
171
|
+
self.idmap = idmap
|
|
172
|
+
self.mirror = mirror
|
|
173
|
+
self.conn = conn if conn is not None else getattr(mirror, "conn", None)
|
|
174
|
+
if target is None:
|
|
175
|
+
target = getattr(mirror, "dialect", None) or "postgres"
|
|
176
|
+
if target.lower() not in TARGETS:
|
|
177
|
+
raise ValueError(f"unknown target {target}; use postgres or sqlserver")
|
|
178
|
+
self.target = TARGETS[target.lower()]
|
|
179
|
+
if mirror is not None and getattr(mirror, "dialect", self.target) != self.target:
|
|
180
|
+
raise ValueError(f"the mirror is {mirror.dialect}, but --target is {self.target}")
|
|
181
|
+
|
|
182
|
+
# -------------------------------------------------------------- public
|
|
183
|
+
def check_text(self, text: str) -> list[StatementResult]:
|
|
184
|
+
try:
|
|
185
|
+
statements = sqlglot.parse(text, read=self.read)
|
|
186
|
+
except ParseError as exc:
|
|
187
|
+
return [StatementResult(1, text, None, [Finding("error", f"Could not parse: {str(exc).splitlines()[0]}")])]
|
|
188
|
+
results = []
|
|
189
|
+
for i, stmt in enumerate(s for s in statements if s is not None):
|
|
190
|
+
results.append(self.check_statement(i + 1, stmt))
|
|
191
|
+
return results
|
|
192
|
+
|
|
193
|
+
def check_statement(self, index: int, stmt: exp.Expression) -> StatementResult:
|
|
194
|
+
original = stmt.sql(dialect=self.read, pretty=True)
|
|
195
|
+
res = StatementResult(index, original, None)
|
|
196
|
+
if isinstance(stmt, (exp.Insert, exp.Update, exp.Delete, exp.Merge, exp.Create, exp.Drop, exp.Alter)):
|
|
197
|
+
res.findings.append(Finding("error", f"{stmt.key.upper()} statements are not supported: the mirror is "
|
|
198
|
+
"read-only and rewritten by every sync."))
|
|
199
|
+
return res
|
|
200
|
+
if not isinstance(stmt, (exp.Select, exp.Union, exp.Intersect, exp.Except, exp.Subquery)):
|
|
201
|
+
res.findings.append(Finding("error", f"{stmt.key.upper()} statements are not report queries; skipped."))
|
|
202
|
+
return res
|
|
203
|
+
|
|
204
|
+
stmt = stmt.copy()
|
|
205
|
+
aliases = self._aliases(stmt)
|
|
206
|
+
self._check_tables(stmt, res)
|
|
207
|
+
self._fix_dialect_quirks(stmt, res)
|
|
208
|
+
self._fix_identifiers(stmt, aliases, res)
|
|
209
|
+
self._check_columns(stmt, aliases, res)
|
|
210
|
+
self._check_literals(stmt, aliases, res)
|
|
211
|
+
if self.target == "tsql" and self.read != "tsql":
|
|
212
|
+
stmt = self._to_tsql(stmt, aliases, res)
|
|
213
|
+
|
|
214
|
+
capture = _Capture()
|
|
215
|
+
logger = logging.getLogger("sqlglot")
|
|
216
|
+
logger.addHandler(capture)
|
|
217
|
+
try:
|
|
218
|
+
res.rewritten = stmt.sql(dialect=self.target, pretty=True, unsupported_level=ErrorLevel.WARN)
|
|
219
|
+
finally:
|
|
220
|
+
logger.removeHandler(capture)
|
|
221
|
+
for msg in capture.messages:
|
|
222
|
+
res.findings.append(Finding("warning", f"Dialect translation: {msg}"))
|
|
223
|
+
if self.read != self.target:
|
|
224
|
+
res.findings.append(Finding("info", f"Translated from {self.read} to {_TARGET_NAMES[self.target]}."))
|
|
225
|
+
if res.rewritten and (self.mirror is not None or self.conn is not None):
|
|
226
|
+
res.explain = self.mirror.explain(res.rewritten) if self.mirror is not None else self._explain(res.rewritten)
|
|
227
|
+
return res
|
|
228
|
+
|
|
229
|
+
# ------------------------------------------------------------ analysis
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _aliases(stmt: exp.Expression) -> dict[str, str]:
|
|
232
|
+
"""alias or table name (lower) -> real table name (lower). CTE names map to themselves."""
|
|
233
|
+
out: dict[str, str] = {}
|
|
234
|
+
for t in stmt.find_all(exp.Table):
|
|
235
|
+
name = t.name.lower()
|
|
236
|
+
out[t.alias_or_name.lower()] = name
|
|
237
|
+
out.setdefault(name, name)
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _cte_names(stmt: exp.Expression) -> set[str]:
|
|
242
|
+
return {c.alias_or_name.lower() for c in stmt.find_all(exp.CTE)}
|
|
243
|
+
|
|
244
|
+
def _check_tables(self, stmt: exp.Expression, res: StatementResult) -> None:
|
|
245
|
+
ctes = self._cte_names(stmt)
|
|
246
|
+
seen = set()
|
|
247
|
+
for t in stmt.find_all(exp.Table):
|
|
248
|
+
name = t.name.lower()
|
|
249
|
+
if name in ctes or name in seen or not name:
|
|
250
|
+
continue
|
|
251
|
+
seen.add(name)
|
|
252
|
+
if name in MIRROR_TABLES:
|
|
253
|
+
continue
|
|
254
|
+
if name in UNSUPPORTED_TABLES:
|
|
255
|
+
res.findings.append(Finding("error", f"Table {name} is not in the mirror: {UNSUPPORTED_TABLES[name]}."))
|
|
256
|
+
elif name.startswith("ao_"):
|
|
257
|
+
res.findings.append(Finding("error", f"Table {t.name} belongs to a Data Center add-on and is not mirrored."))
|
|
258
|
+
else:
|
|
259
|
+
res.findings.append(Finding("warning", f"Table {t.name} is not a Jira table the mirror provides."))
|
|
260
|
+
|
|
261
|
+
def _fix_dialect_quirks(self, stmt: exp.Expression, res: StatementResult) -> None:
|
|
262
|
+
"""Rewrites sqlglot can't infer without column types."""
|
|
263
|
+
changed = []
|
|
264
|
+
if self.read == "tsql" and self.target == "postgres":
|
|
265
|
+
# SQL Server concatenates strings with "+"; Postgres needs "||".
|
|
266
|
+
def stringy(node: exp.Expression) -> bool:
|
|
267
|
+
if isinstance(node, exp.Literal) and node.is_string:
|
|
268
|
+
return True
|
|
269
|
+
if isinstance(node, exp.Cast) and node.to.this in (exp.DataType.Type.VARCHAR, exp.DataType.Type.NVARCHAR,
|
|
270
|
+
exp.DataType.Type.CHAR, exp.DataType.Type.NCHAR,
|
|
271
|
+
exp.DataType.Type.TEXT):
|
|
272
|
+
return True
|
|
273
|
+
if isinstance(node, (exp.Add, exp.DPipe)):
|
|
274
|
+
return stringy(node.this) or stringy(node.expression)
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
for add in list(stmt.find_all(exp.Add)):
|
|
278
|
+
if add.parent is not None and stringy(add):
|
|
279
|
+
add.replace(exp.DPipe(this=add.this, expression=add.expression))
|
|
280
|
+
changed.append("string concatenation + -> ||")
|
|
281
|
+
if self.read == "mysql":
|
|
282
|
+
# MySQL CAST(x AS CHAR) means "as text"; in Postgres CHAR is one character.
|
|
283
|
+
for cast in list(stmt.find_all(exp.Cast)):
|
|
284
|
+
if cast.to.this == exp.DataType.Type.CHAR and not cast.to.expressions:
|
|
285
|
+
cast.set("to", exp.DataType.build("VARCHAR"))
|
|
286
|
+
changed.append("CAST(... AS CHAR) -> VARCHAR")
|
|
287
|
+
for msg in sorted(set(changed)):
|
|
288
|
+
res.findings.append(Finding("fix", f"Dialect fix: {msg}."))
|
|
289
|
+
|
|
290
|
+
def _fix_identifiers(self, stmt: exp.Expression, aliases: dict[str, str], res: StatementResult) -> None:
|
|
291
|
+
fixed = False
|
|
292
|
+
for t in stmt.find_all(exp.Table):
|
|
293
|
+
name = t.name.lower()
|
|
294
|
+
ident = t.this
|
|
295
|
+
if not isinstance(ident, exp.Identifier):
|
|
296
|
+
continue
|
|
297
|
+
if name in AO_TABLES:
|
|
298
|
+
want = AO_TABLES[name]
|
|
299
|
+
if ident.this != want or not ident.quoted:
|
|
300
|
+
t.set("this", exp.Identifier(this=want, quoted=True))
|
|
301
|
+
fixed = True
|
|
302
|
+
elif name in MIRROR_TABLES and (ident.quoted or ident.this != name):
|
|
303
|
+
t.set("this", exp.Identifier(this=name, quoted=False))
|
|
304
|
+
fixed = True
|
|
305
|
+
# Drop schema / database qualifiers (jiradb.dbo.jiraissue -> jiraissue).
|
|
306
|
+
if name in MIRROR_TABLES and (t.args.get("db") or t.args.get("catalog")):
|
|
307
|
+
t.set("db", None)
|
|
308
|
+
t.set("catalog", None)
|
|
309
|
+
fixed = True
|
|
310
|
+
|
|
311
|
+
single_table = None
|
|
312
|
+
real = {v for k, v in aliases.items()} - self._cte_names(stmt)
|
|
313
|
+
if len(real) == 1:
|
|
314
|
+
single_table = next(iter(real))
|
|
315
|
+
for col in stmt.find_all(exp.Column):
|
|
316
|
+
table = aliases.get(col.table.lower()) if col.table else single_table
|
|
317
|
+
ident = col.this
|
|
318
|
+
if not isinstance(ident, exp.Identifier) or not table:
|
|
319
|
+
continue
|
|
320
|
+
if table in AO_TABLES:
|
|
321
|
+
want = AO_COLUMNS[table].get(ident.this.lower())
|
|
322
|
+
if want and (ident.this != want or not ident.quoted):
|
|
323
|
+
col.set("this", exp.Identifier(this=want, quoted=True))
|
|
324
|
+
fixed = True
|
|
325
|
+
elif table in MIRROR_TABLES and (ident.quoted or ident.this != ident.this.lower()):
|
|
326
|
+
col.set("this", exp.Identifier(this=ident.this.lower(), quoted=False))
|
|
327
|
+
fixed = True
|
|
328
|
+
|
|
329
|
+
# Sprint flags are booleans on Postgres; MySQL/SQL Server reports compare them with 0/1.
|
|
330
|
+
# (On a SQL Server mirror they are bit columns, so 0/1 is already right.)
|
|
331
|
+
for cmp in list(stmt.find_all(exp.EQ, exp.NEQ)) if self.target == "postgres" else []:
|
|
332
|
+
col, lit = (cmp.this, cmp.expression)
|
|
333
|
+
if not isinstance(col, exp.Column):
|
|
334
|
+
col, lit = lit, col
|
|
335
|
+
if not isinstance(col, exp.Column) or not isinstance(lit, exp.Literal) or lit.is_string:
|
|
336
|
+
continue
|
|
337
|
+
table = aliases.get(col.table.lower()) if col.table else single_table
|
|
338
|
+
if table in AO_TABLES and col.name.lower() in AO_BOOLEAN_COLUMNS and lit.this in ("0", "1"):
|
|
339
|
+
lit.replace(exp.Boolean(this=lit.this == "1"))
|
|
340
|
+
fixed = True
|
|
341
|
+
if fixed:
|
|
342
|
+
res.findings.append(Finding("fix", f"Adjusted identifier case / types for {_TARGET_NAMES[self.target]}."))
|
|
343
|
+
|
|
344
|
+
def _check_columns(self, stmt: exp.Expression, aliases: dict[str, str], res: StatementResult) -> None:
|
|
345
|
+
reported = set()
|
|
346
|
+
for col in stmt.find_all(exp.Column):
|
|
347
|
+
table = aliases.get(col.table.lower()) if col.table else None
|
|
348
|
+
if not table:
|
|
349
|
+
continue
|
|
350
|
+
real = TABLE_BY_NAME.get(AO_TABLES.get(table, table))
|
|
351
|
+
key = (real.name if real else table, col.name if (real and real.quoted) else col.name.lower())
|
|
352
|
+
if key in ALWAYS_NULL_COLUMNS and key not in reported:
|
|
353
|
+
reported.add(key)
|
|
354
|
+
res.findings.append(Finding("warning", f"{key[0]}.{key[1]} is always NULL in the mirror: {ALWAYS_NULL_COLUMNS[key]}."))
|
|
355
|
+
if real and real.mode != "state":
|
|
356
|
+
names = {c.lower() for c in real.column_names}
|
|
357
|
+
if col.name.lower() not in names and (table, col.name.lower()) not in reported:
|
|
358
|
+
reported.add((table, col.name.lower()))
|
|
359
|
+
res.findings.append(Finding("error", f"Column {table}.{col.name} does not exist in the mirror."))
|
|
360
|
+
|
|
361
|
+
def _entity_for(self, col: exp.Column, aliases: dict[str, str], single_table: str | None) -> tuple[str | None, str | None]:
|
|
362
|
+
name = col.name.lower()
|
|
363
|
+
table = aliases.get(col.table.lower()) if col.table else single_table
|
|
364
|
+
if table and (table, name) in ID_COLUMNS:
|
|
365
|
+
return ID_COLUMNS[(table, name)], table
|
|
366
|
+
if table and (table, name) in AMBIGUOUS_ID_COLUMNS:
|
|
367
|
+
return "ambiguous", table
|
|
368
|
+
if name in ID_COLUMN_NAMES and (not table or table == "jiraissue" or table in MIRROR_TABLES):
|
|
369
|
+
return ID_COLUMN_NAMES[name], table
|
|
370
|
+
if name in USER_COLUMNS:
|
|
371
|
+
return "user", table
|
|
372
|
+
return None, table
|
|
373
|
+
|
|
374
|
+
def _check_literals(self, stmt: exp.Expression, aliases: dict[str, str], res: StatementResult) -> None:
|
|
375
|
+
real = {v for v in aliases.values()} - self._cte_names(stmt)
|
|
376
|
+
single_table = next(iter(real)) if len(real) == 1 else None
|
|
377
|
+
comparisons = list(stmt.find_all(exp.EQ, exp.NEQ, exp.GT, exp.GTE, exp.LT, exp.LTE, exp.In))
|
|
378
|
+
for cmp in comparisons:
|
|
379
|
+
if isinstance(cmp, exp.In):
|
|
380
|
+
col, lits = cmp.this, list(cmp.expressions)
|
|
381
|
+
else:
|
|
382
|
+
col, other = cmp.this, cmp.expression
|
|
383
|
+
if not isinstance(col, exp.Column):
|
|
384
|
+
col, other = other, col
|
|
385
|
+
lits = [other]
|
|
386
|
+
if not isinstance(col, exp.Column):
|
|
387
|
+
continue
|
|
388
|
+
lits = [lit for lit in lits if _literal_value(lit) is not None]
|
|
389
|
+
if not lits:
|
|
390
|
+
continue
|
|
391
|
+
entity, table = self._entity_for(col, aliases, single_table)
|
|
392
|
+
if entity is None:
|
|
393
|
+
continue
|
|
394
|
+
where = f"{col.sql()} {'IN' if isinstance(cmp, exp.In) else cmp.key.upper()}"
|
|
395
|
+
if entity == "ambiguous" and self.idmap and (table, col.name.lower()) == ("customfieldvalue", "stringvalue"):
|
|
396
|
+
# Option and sprint ids are stored as text; rewrite when exactly one mapping matches.
|
|
397
|
+
remaining = []
|
|
398
|
+
for lit in lits:
|
|
399
|
+
value = _literal_value(lit)
|
|
400
|
+
hits = {e: self.idmap.lookup(e, value) for e in ("option", "sprint")}
|
|
401
|
+
hits = {e: v for e, v in hits.items() if v is not None}
|
|
402
|
+
if len(hits) == 1:
|
|
403
|
+
ent, mapped = next(iter(hits.items()))
|
|
404
|
+
_set_literal(lit, mapped)
|
|
405
|
+
res.findings.append(Finding("fix", f"{where}: Data Center {ent} {value} -> Cloud {mapped}."))
|
|
406
|
+
else:
|
|
407
|
+
remaining.append(lit)
|
|
408
|
+
lits = remaining
|
|
409
|
+
if not lits:
|
|
410
|
+
continue
|
|
411
|
+
if entity == "ambiguous":
|
|
412
|
+
vals = ", ".join(_literal_value(lit) for lit in lits)
|
|
413
|
+
res.findings.append(Finding("warning", f"{where} {vals}: hard-coded value in {table}.{col.name} "
|
|
414
|
+
f"({AMBIGUOUS_ID_COLUMNS[(table, col.name.lower())]}); "
|
|
415
|
+
"check it still means the same thing in Cloud."))
|
|
416
|
+
continue
|
|
417
|
+
for lit in lits:
|
|
418
|
+
value = _literal_value(lit)
|
|
419
|
+
if entity == "user" and not (isinstance(lit, exp.Literal) and lit.is_string):
|
|
420
|
+
continue
|
|
421
|
+
if entity != "user" and not re.fullmatch(r"-?\d+", value or ""):
|
|
422
|
+
continue
|
|
423
|
+
mapped = self.idmap.lookup(entity, value) if self.idmap else None
|
|
424
|
+
if mapped is not None:
|
|
425
|
+
_set_literal(lit, mapped)
|
|
426
|
+
res.findings.append(Finding("fix", f"{where}: Data Center {entity} {value} -> Cloud {mapped}."))
|
|
427
|
+
else:
|
|
428
|
+
res.findings.append(Finding("warning", f"{where} {value}: hard-coded Data Center {entity} "
|
|
429
|
+
f"{'id' if entity != 'user' else 'name'}. {ENTITY_HELP.get(entity, '')}"))
|
|
430
|
+
|
|
431
|
+
# ------------------------------------------------------- to SQL Server
|
|
432
|
+
def _to_tsql(self, stmt: exp.Expression, aliases: dict[str, str], res: StatementResult) -> exp.Expression:
|
|
433
|
+
"""Fix what a plain Postgres/MySQL/Oracle -> T-SQL translation gets wrong."""
|
|
434
|
+
fixes: list[str] = []
|
|
435
|
+
real = {v for v in aliases.values()} - self._cte_names(stmt)
|
|
436
|
+
single_table = next(iter(real)) if len(real) == 1 else None
|
|
437
|
+
|
|
438
|
+
def is_text(node: exp.Expression) -> bool:
|
|
439
|
+
if isinstance(node, exp.Literal):
|
|
440
|
+
return node.is_string
|
|
441
|
+
if isinstance(node, exp.Cast):
|
|
442
|
+
return node.to.this in _TEXT_TYPES
|
|
443
|
+
if isinstance(node, exp.Column):
|
|
444
|
+
table = aliases.get(node.table.lower()) if node.table else single_table
|
|
445
|
+
t = TABLE_BY_NAME.get(AO_TABLES.get(table, table)) if table else None
|
|
446
|
+
if t is not None:
|
|
447
|
+
typ = dict((c.lower(), ty) for c, ty in t.columns).get(node.name.lower())
|
|
448
|
+
return typ in ("str", "text")
|
|
449
|
+
if isinstance(node, (exp.Upper, exp.Lower, exp.Trim, exp.Substring, exp.Concat)):
|
|
450
|
+
return True
|
|
451
|
+
if isinstance(node, exp.Coalesce):
|
|
452
|
+
return is_text(node.this)
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
# 1. a || b: T-SQL "+" adds numbers, so cast every non-text operand to text.
|
|
456
|
+
for dp in list(stmt.find_all(exp.DPipe)):
|
|
457
|
+
for side in ("this", "expression"):
|
|
458
|
+
part = dp.args.get(side)
|
|
459
|
+
if part is None or isinstance(part, exp.DPipe) or is_text(part):
|
|
460
|
+
continue
|
|
461
|
+
part.replace(exp.Cast(this=part.copy(), to=exp.DataType.build("NVARCHAR(MAX)", dialect="tsql")))
|
|
462
|
+
fixes.append("|| with a non-text value -> + with CAST(... AS NVARCHAR(MAX))")
|
|
463
|
+
|
|
464
|
+
# 2. JOIN LATERAL (...) ON true -> CROSS APPLY / OUTER APPLY.
|
|
465
|
+
for join in list(stmt.find_all(exp.Join)):
|
|
466
|
+
lat = join.this
|
|
467
|
+
if not isinstance(lat, exp.Lateral):
|
|
468
|
+
continue
|
|
469
|
+
on = join.args.get("on")
|
|
470
|
+
if on is not None and not (isinstance(on, exp.Boolean) and on.this):
|
|
471
|
+
res.findings.append(Finding("warning", "LATERAL join with a join condition: move the condition into "
|
|
472
|
+
"the subquery's WHERE and use CROSS APPLY."))
|
|
473
|
+
continue
|
|
474
|
+
outer = (join.side or "").upper() == "LEFT"
|
|
475
|
+
join.replace(exp.Join(this=exp.Lateral(this=lat.this.copy(), alias=lat.args.get("alias"),
|
|
476
|
+
cross_apply=not outer)))
|
|
477
|
+
fixes.append("JOIN LATERAL -> " + ("OUTER APPLY" if outer else "CROSS APPLY"))
|
|
478
|
+
|
|
479
|
+
# 3. GROUP BY 1, 2 -> the expressions (T-SQL only allows ordinals in ORDER BY).
|
|
480
|
+
for select in list(stmt.find_all(exp.Select)):
|
|
481
|
+
group = select.args.get("group")
|
|
482
|
+
items = select.expressions
|
|
483
|
+
for g in list(group.expressions) if group else []:
|
|
484
|
+
if isinstance(g, exp.Literal) and not g.is_string and g.this.isdigit() and 0 < int(g.this) <= len(items):
|
|
485
|
+
target = items[int(g.this) - 1]
|
|
486
|
+
g.replace((target.this if isinstance(target, exp.Alias) else target).copy())
|
|
487
|
+
fixes.append("GROUP BY position -> GROUP BY expression")
|
|
488
|
+
|
|
489
|
+
# ORDER BY 1 too, so NULLs sort as on Postgres (T-SQL can't do that for a position).
|
|
490
|
+
order = select.args.get("order")
|
|
491
|
+
if order and select.args.get("distinct"):
|
|
492
|
+
# Postgres sorts NULLs last; emulating that adds a CASE to ORDER BY, which SQL Server
|
|
493
|
+
# rejects with DISTINCT (Msg 145). Keep SQL Server's order and say so.
|
|
494
|
+
for o in order.expressions:
|
|
495
|
+
if isinstance(o, exp.Ordered):
|
|
496
|
+
o.set("nulls_first", not o.args.get("desc"))
|
|
497
|
+
res.findings.append(Finding("warning", "SELECT DISTINCT ... ORDER BY: SQL Server sorts NULLs first "
|
|
498
|
+
"(Postgres sorts them last)."))
|
|
499
|
+
if order and not select.args.get("distinct") and not isinstance(select.parent, exp.SetOperation):
|
|
500
|
+
for o in list(order.expressions):
|
|
501
|
+
pos = o.this
|
|
502
|
+
if isinstance(pos, exp.Literal) and not pos.is_string and pos.this.isdigit() \
|
|
503
|
+
and 0 < int(pos.this) <= len(items):
|
|
504
|
+
target = items[int(pos.this) - 1]
|
|
505
|
+
pos.replace((target.this if isinstance(target, exp.Alias) else target).copy())
|
|
506
|
+
|
|
507
|
+
# 4. date_trunc -> DATEADD(unit, DATEDIFF(unit, 0, x), 0), which works on every SQL Server version.
|
|
508
|
+
for node in list(stmt.find_all(exp.TimestampTrunc, exp.DateTrunc)):
|
|
509
|
+
unit = (node.args.get("unit").name if node.args.get("unit") is not None else "").upper()
|
|
510
|
+
if unit in ("YEAR", "QUARTER", "MONTH", "DAY", "HOUR", "MINUTE"):
|
|
511
|
+
tmpl = sqlglot.parse_one(f"DATEADD({unit}, DATEDIFF({unit}, 0, __sqlm_x), 0)", read="tsql")
|
|
512
|
+
tmpl.find(exp.Column).replace(node.this.copy())
|
|
513
|
+
node.replace(tmpl)
|
|
514
|
+
fixes.append(f"date_trunc('{unit.lower()}') -> DATEADD/DATEDIFF")
|
|
515
|
+
else:
|
|
516
|
+
res.findings.append(Finding("warning", f"date_trunc('{unit.lower()}') becomes DATETRUNC, which needs "
|
|
517
|
+
"SQL Server 2022 or Azure SQL."))
|
|
518
|
+
|
|
519
|
+
# 5. extract(epoch FROM a - b) -> DATEDIFF_BIG(MILLISECOND, b, a) / 1000.0
|
|
520
|
+
for node in list(stmt.find_all(exp.Extract)):
|
|
521
|
+
if node.name.upper() != "EPOCH":
|
|
522
|
+
continue
|
|
523
|
+
arg = node.expression
|
|
524
|
+
while isinstance(arg, exp.Paren):
|
|
525
|
+
arg = arg.this
|
|
526
|
+
if isinstance(arg, exp.Sub):
|
|
527
|
+
start, end = arg.expression, arg.this
|
|
528
|
+
else:
|
|
529
|
+
start, end = sqlglot.parse_one("CAST('1970-01-01' AS DATETIME)", read="tsql"), arg
|
|
530
|
+
tmpl = sqlglot.parse_one("DATEDIFF_BIG(MILLISECOND, __sqlm_a, __sqlm_b) / 1000.0", read="tsql")
|
|
531
|
+
for col in list(tmpl.find_all(exp.Column)):
|
|
532
|
+
col.replace((start if col.name == "__sqlm_a" else end).copy())
|
|
533
|
+
node.replace(exp.Paren(this=tmpl))
|
|
534
|
+
fixes.append("extract(epoch ...) -> DATEDIFF_BIG(MILLISECOND, ...) / 1000.0")
|
|
535
|
+
|
|
536
|
+
# 6. ::numeric without precision is numeric(18,0) on SQL Server; keep the decimals.
|
|
537
|
+
for cast in list(stmt.find_all(exp.Cast)):
|
|
538
|
+
if cast.to.this == exp.DataType.Type.DECIMAL and not cast.to.expressions:
|
|
539
|
+
cast.set("to", exp.DataType.build("NUMERIC(38, 10)", dialect="tsql"))
|
|
540
|
+
fixes.append("CAST(... AS numeric) -> NUMERIC(38, 10)")
|
|
541
|
+
|
|
542
|
+
# 7. now(): the mirror stores UTC.
|
|
543
|
+
if getattr(self.mirror, "tz_name", "UTC") == "UTC":
|
|
544
|
+
for node in list(stmt.find_all(exp.CurrentTimestamp)):
|
|
545
|
+
node.replace(exp.Anonymous(this="GETUTCDATE", expressions=[]))
|
|
546
|
+
fixes.append("now() -> GETUTCDATE() (the mirror stores UTC)")
|
|
547
|
+
elif any(True for _ in stmt.find_all(exp.CurrentTimestamp)):
|
|
548
|
+
res.findings.append(Finding("warning", f"now() becomes GETDATE(), the SQL Server machine's local time; "
|
|
549
|
+
f"the mirror stores {self.mirror.tz_name} time."))
|
|
550
|
+
|
|
551
|
+
# 8. ::text / ::varchar -> NVARCHAR(MAX) (VARCHAR would lose characters outside the code page).
|
|
552
|
+
for cast in list(stmt.find_all(exp.Cast)):
|
|
553
|
+
to = cast.to
|
|
554
|
+
if to.this == exp.DataType.Type.TEXT or (to.this == exp.DataType.Type.VARCHAR and not to.expressions):
|
|
555
|
+
cast.set("to", exp.DataType.build("NVARCHAR(MAX)", dialect="tsql"))
|
|
556
|
+
|
|
557
|
+
# 9. ILIKE -> LIKE: SQL Server's default collation is already case-insensitive.
|
|
558
|
+
for node in list(stmt.find_all(exp.ILike)):
|
|
559
|
+
node.replace(exp.Like(this=node.this.copy(), expression=node.expression.copy()))
|
|
560
|
+
fixes.append("ILIKE -> LIKE (case-insensitive under SQL Server's default collation)")
|
|
561
|
+
|
|
562
|
+
# 10. x +/- interval 'n unit' -> DATEADD(unit, +/-n, x)
|
|
563
|
+
for node in list(stmt.find_all(exp.Add, exp.Sub)):
|
|
564
|
+
iv = node.expression
|
|
565
|
+
if not isinstance(iv, exp.Interval):
|
|
566
|
+
continue
|
|
567
|
+
amount = iv.this.this if isinstance(iv.this, exp.Literal) else None
|
|
568
|
+
unit = iv.args.get("unit")
|
|
569
|
+
unit_name = (unit.name if unit is not None else "").upper().rstrip("S")
|
|
570
|
+
parts = (amount or "").split()
|
|
571
|
+
if len(parts) == 2 and not unit_name: # interval '7 days'
|
|
572
|
+
amount, unit_name = parts[0], parts[1].upper().rstrip("S")
|
|
573
|
+
if unit_name not in ("YEAR", "QUARTER", "MONTH", "WEEK", "DAY", "HOUR", "MINUTE", "SECOND") \
|
|
574
|
+
or not re.fullmatch(r"-?\d+", amount or ""):
|
|
575
|
+
res.findings.append(Finding("warning", f"Interval arithmetic {node.sql()} has no direct T-SQL form; "
|
|
576
|
+
"use DATEADD."))
|
|
577
|
+
continue
|
|
578
|
+
n = int(amount) * (-1 if isinstance(node, exp.Sub) else 1)
|
|
579
|
+
tmpl = sqlglot.parse_one(f"DATEADD({unit_name}, {n}, __sqlm_x)", read="tsql")
|
|
580
|
+
tmpl.find(exp.Column).replace(node.this.copy())
|
|
581
|
+
node.replace(tmpl)
|
|
582
|
+
fixes.append("date +/- interval -> DATEADD")
|
|
583
|
+
|
|
584
|
+
# 11. Regular expressions: not in T-SQL (before SQL Server 2025).
|
|
585
|
+
if any(True for _ in stmt.find_all(exp.RegexpLike)):
|
|
586
|
+
res.findings.append(Finding("warning", "Regular expression match (~): SQL Server has no regex before "
|
|
587
|
+
"SQL Server 2025; rewrite with LIKE."))
|
|
588
|
+
|
|
589
|
+
for msg in sorted(set(fixes)):
|
|
590
|
+
res.findings.append(Finding("fix", f"SQL Server: {msg}."))
|
|
591
|
+
return stmt
|
|
592
|
+
|
|
593
|
+
def _explain(self, sql_text: str) -> str:
|
|
594
|
+
try:
|
|
595
|
+
with self.conn.cursor() as cur:
|
|
596
|
+
cur.execute("EXPLAIN " + sql_text)
|
|
597
|
+
self.conn.rollback()
|
|
598
|
+
return "ok"
|
|
599
|
+
except Exception as exc: # psycopg.Error, kept generic so the checker works without a DB driver
|
|
600
|
+
self.conn.rollback()
|
|
601
|
+
return str(exc).strip().splitlines()[0]
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# ---------------------------------------------------------------- reporting
|
|
605
|
+
def check_paths(paths: list[Path], checker: Checker) -> dict[Path, list[StatementResult]]:
|
|
606
|
+
files: list[Path] = []
|
|
607
|
+
for p in paths:
|
|
608
|
+
if p.is_dir():
|
|
609
|
+
files.extend(sorted(p.rglob("*.sql")))
|
|
610
|
+
else:
|
|
611
|
+
files.append(p)
|
|
612
|
+
return {f: checker.check_text(f.read_text(encoding="utf-8", errors="replace")) for f in files}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def write_outputs(results: dict[Path, list[StatementResult]], out_dir: Path) -> None:
|
|
616
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
617
|
+
for path, stmts in results.items():
|
|
618
|
+
body = ";\n\n".join(s.rewritten for s in stmts if s.rewritten)
|
|
619
|
+
if body:
|
|
620
|
+
(out_dir / path.name).write_text(body + ";\n", encoding="utf-8")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def markdown_report(results: dict[Path, list[StatementResult]], *, dialect: str, explained: bool,
|
|
624
|
+
target: str = "postgres") -> str:
|
|
625
|
+
counts: dict[str, int] = {}
|
|
626
|
+
for stmts in results.values():
|
|
627
|
+
for s in stmts:
|
|
628
|
+
counts[s.verdict] = counts.get(s.verdict, 0) + 1
|
|
629
|
+
total = sum(counts.values())
|
|
630
|
+
lines = ["# SQL Mirror: Data Center SQL check", "",
|
|
631
|
+
f"Checked {total} statements in {len(results)} files (source dialect: {dialect}, "
|
|
632
|
+
f"mirror: {_TARGET_NAMES.get(TARGETS.get(target, target), target)})."
|
|
633
|
+
+ (" Each rewritten statement was compiled against the mirror." if explained else ""), ""]
|
|
634
|
+
for verdict in ("works as-is", "rewritten", "check", "needs work"):
|
|
635
|
+
if counts.get(verdict):
|
|
636
|
+
lines.append(f"- **{verdict}**: {counts[verdict]}")
|
|
637
|
+
lines.append("")
|
|
638
|
+
for path, stmts in results.items():
|
|
639
|
+
lines.append(f"## {path.name}")
|
|
640
|
+
for s in stmts:
|
|
641
|
+
label = f"Statement {s.index}" if len(stmts) > 1 else "Result"
|
|
642
|
+
lines.append(f"**{label}: {s.verdict}**" + (f" (EXPLAIN: {s.explain})" if s.explain else ""))
|
|
643
|
+
for f in s.findings:
|
|
644
|
+
if f.severity != "info":
|
|
645
|
+
lines.append(f"- {f.severity}: {f.message}")
|
|
646
|
+
lines.append("")
|
|
647
|
+
return "\n".join(lines)
|