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.
Files changed (95) hide show
  1. sqlmirror/__init__.py +1 -0
  2. sqlmirror/apply.py +136 -0
  3. sqlmirror/backfill.py +65 -0
  4. sqlmirror/cli.py +335 -0
  5. sqlmirror/cron.py +103 -0
  6. sqlmirror/db.py +469 -0
  7. sqlmirror/db_mssql.py +530 -0
  8. sqlmirror/dev/__init__.py +1 -0
  9. sqlmirror/dev/cli.py +166 -0
  10. sqlmirror/dev/compare.py +87 -0
  11. sqlmirror/dev/e2e.py +261 -0
  12. sqlmirror/dev/golden.py +182 -0
  13. sqlmirror/dev/mock_jira.py +347 -0
  14. sqlmirror/dev/site.py +383 -0
  15. sqlmirror/dev/synth.py +769 -0
  16. sqlmirror/golden/01_issues_by_status.sql +8 -0
  17. sqlmirror/golden/02_issue_keys.sql +7 -0
  18. sqlmirror/golden/03_created_vs_resolved.sql +8 -0
  19. sqlmirror/golden/04_open_by_priority.sql +10 -0
  20. sqlmirror/golden/05_time_in_status.sql +16 -0
  21. sqlmirror/golden/06_cycle_time.sql +16 -0
  22. sqlmirror/golden/07_worklog_by_user.sql +11 -0
  23. sqlmirror/golden/08_fix_versions.sql +11 -0
  24. sqlmirror/golden/09_bugs_by_affected_version.sql +11 -0
  25. sqlmirror/golden/10_components.sql +10 -0
  26. sqlmirror/golden/11_top_labels.sql +9 -0
  27. sqlmirror/golden/12_select_field_distribution.sql +11 -0
  28. sqlmirror/golden/13_story_points_by_sprint.sql +11 -0
  29. sqlmirror/golden/14_sprint_completion.sql +14 -0
  30. sqlmirror/golden/15_epics.sql +12 -0
  31. sqlmirror/golden/16_subtasks.sql +10 -0
  32. sqlmirror/golden/17_blocked_issues.sql +11 -0
  33. sqlmirror/golden/18_most_discussed.sql +10 -0
  34. sqlmirror/golden/19_assignee_workload.sql +11 -0
  35. sqlmirror/golden/20_resolutions.sql +7 -0
  36. sqlmirror/golden/21_issue_history.sql +10 -0
  37. sqlmirror/golden/22_reopened.sql +11 -0
  38. sqlmirror/golden/23_overdue.sql +8 -0
  39. sqlmirror/golden/24_cascading_select.sql +13 -0
  40. sqlmirror/golden/25_user_picker.sql +12 -0
  41. sqlmirror/golden/26_attachments.sql +9 -0
  42. sqlmirror/golden/27_project_categories.sql +8 -0
  43. sqlmirror/golden/28_issue_types.sql +7 -0
  44. sqlmirror/golden/29_estimate_accuracy.sql +11 -0
  45. sqlmirror/golden/30_project_leads.sql +7 -0
  46. sqlmirror/golden/tsql/01_issues_by_status.sql +8 -0
  47. sqlmirror/golden/tsql/02_issue_keys.sql +6 -0
  48. sqlmirror/golden/tsql/03_created_vs_resolved.sql +8 -0
  49. sqlmirror/golden/tsql/04_open_by_priority.sql +10 -0
  50. sqlmirror/golden/tsql/05_time_in_status.sql +16 -0
  51. sqlmirror/golden/tsql/06_cycle_time.sql +16 -0
  52. sqlmirror/golden/tsql/07_worklog_by_user.sql +11 -0
  53. sqlmirror/golden/tsql/08_fix_versions.sql +11 -0
  54. sqlmirror/golden/tsql/09_bugs_by_affected_version.sql +11 -0
  55. sqlmirror/golden/tsql/10_components.sql +10 -0
  56. sqlmirror/golden/tsql/11_top_labels.sql +8 -0
  57. sqlmirror/golden/tsql/12_select_field_distribution.sql +11 -0
  58. sqlmirror/golden/tsql/13_story_points_by_sprint.sql +11 -0
  59. sqlmirror/golden/tsql/14_sprint_completion.sql +14 -0
  60. sqlmirror/golden/tsql/15_epics.sql +11 -0
  61. sqlmirror/golden/tsql/16_subtasks.sql +9 -0
  62. sqlmirror/golden/tsql/17_blocked_issues.sql +11 -0
  63. sqlmirror/golden/tsql/18_most_discussed.sql +9 -0
  64. sqlmirror/golden/tsql/19_assignee_workload.sql +11 -0
  65. sqlmirror/golden/tsql/20_resolutions.sql +7 -0
  66. sqlmirror/golden/tsql/21_issue_history.sql +10 -0
  67. sqlmirror/golden/tsql/22_reopened.sql +11 -0
  68. sqlmirror/golden/tsql/23_overdue.sql +8 -0
  69. sqlmirror/golden/tsql/24_cascading_select.sql +13 -0
  70. sqlmirror/golden/tsql/25_user_picker.sql +12 -0
  71. sqlmirror/golden/tsql/26_attachments.sql +9 -0
  72. sqlmirror/golden/tsql/27_project_categories.sql +8 -0
  73. sqlmirror/golden/tsql/28_issue_types.sql +7 -0
  74. sqlmirror/golden/tsql/29_estimate_accuracy.sql +11 -0
  75. sqlmirror/golden/tsql/30_project_leads.sql +7 -0
  76. sqlmirror/idmap.py +161 -0
  77. sqlmirror/jira.py +223 -0
  78. sqlmirror/mapper.py +774 -0
  79. sqlmirror/reconcile.py +73 -0
  80. sqlmirror/samples/dc_export_example.csv +19 -0
  81. sqlmirror/samples/dc_reports/mysql_sprint_report.sql +10 -0
  82. sqlmirror/samples/dc_reports/postgres_assigned_to_user.sql +6 -0
  83. sqlmirror/samples/dc_reports/postgres_customer_impact.sql +7 -0
  84. sqlmirror/samples/dc_reports/postgres_time_logged.sql +7 -0
  85. sqlmirror/samples/dc_reports/postgres_workflow_steps.sql +4 -0
  86. sqlmirror/samples/dc_reports/sqlserver_open_by_status.sql +9 -0
  87. sqlmirror/schema.py +518 -0
  88. sqlmirror/source.py +117 -0
  89. sqlmirror/sqlcheck.py +647 -0
  90. sqlmirror/util.py +80 -0
  91. sqlmirror-0.2.0.dist-info/METADATA +19 -0
  92. sqlmirror-0.2.0.dist-info/RECORD +95 -0
  93. sqlmirror-0.2.0.dist-info/WHEEL +5 -0
  94. sqlmirror-0.2.0.dist-info/entry_points.txt +3 -0
  95. 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)