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/db_mssql.py ADDED
@@ -0,0 +1,530 @@
1
+ """SQL Server writer for the mirror (SQL Server 2016+, Azure SQL Database, Azure SQL Managed Instance).
2
+
3
+ Connection string:
4
+
5
+ sqlserver://user:password@host:1433/database local or on-premises
6
+ sqlserver://user:password@host:1433/database?encrypt=true Azure SQL (TLS, verified with the system CAs)
7
+
8
+ Other options (query string): instance=NAME (named instance through SQL Browser,
9
+ no port), cafile=/path/ca.pem, trustservercertificate=true (skip the host-name
10
+ check), schema=NAME (default dbo), login_timeout=SECONDS, timezone=IANA name
11
+ (see below).
12
+
13
+ The driver is python-tds (pure Python, no ODBC driver needed):
14
+ pip install "sqlmirror[sqlserver]"
15
+
16
+ Differences from the Postgres writer, all invisible to report SQL:
17
+ - Upserts are MERGE statements; bulk loads are multi-row INSERTs.
18
+ - Every value is CAST to its column type inside the statement, so a batch
19
+ mixing NULLs, numbers and text never trips SQL Server's row-type rules.
20
+ - Data Center tables use datetime, as Data Center does on SQL Server. Times
21
+ are stored in UTC, or with ?timezone=America/New_York in that zone's local
22
+ time: Data Center stored the Jira server's local time, so reports that use
23
+ GETDATE() or group by day match the old numbers only with the old server's
24
+ zone. Dates before 1753, which datetime can't hold (typos such as year
25
+ 0202), are stored as NULL.
26
+ - Mirror bookkeeping times use datetime2(6), so "newest snapshot wins" is exact.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import math
32
+ from datetime import date, datetime, timedelta, timezone, tzinfo
33
+ from decimal import Decimal
34
+ from typing import Any, Callable, Iterable, Iterator
35
+ from urllib.parse import parse_qs, unquote, urlsplit
36
+
37
+ from .db import ApplyStats, Mirror
38
+ from .mapper import SYNTHETIC_LINKTYPE_IDS, Mapper
39
+ from .schema import SCHEMA_VERSION, TABLE_BY_NAME, TABLES, Table, column_type, ddl_statements, tsql_ident
40
+ from .util import parse_ts, utcnow
41
+
42
+ MAX_PARAMS = 2000 # SQL Server allows 2100 parameters per statement
43
+ MAX_ROWS = 1000 # and 1000 rows per VALUES list
44
+ ID_CHUNK = 1000 # ids inlined per IN (...) list
45
+ DATETIME_MIN = datetime(1753, 1, 1)
46
+
47
+ _TRUE = ("1", "true", "yes", "on", "strict", "mandatory")
48
+
49
+
50
+ def parse_dsn(dsn: str) -> dict[str, Any]:
51
+ """sqlserver://user:pass@host:port/db?options -> keyword arguments for pytds.connect, plus 'schema'."""
52
+ u = urlsplit(dsn.strip())
53
+ q = {k.lower(): v[-1] for k, v in parse_qs(u.query, keep_blank_values=True).items()}
54
+ host = u.hostname or "localhost"
55
+ instance = q.get("instance") or q.get("instancename")
56
+ port = None if instance else (u.port or int(q.get("port") or 1433))
57
+ database = unquote(u.path.lstrip("/")) or q.get("database") or "master"
58
+ kwargs: dict[str, Any] = {
59
+ "server": f"{host}\\{instance}" if instance else host,
60
+ "port": port,
61
+ "database": database,
62
+ "user": unquote(u.username) if u.username else None,
63
+ "password": unquote(u.password) if u.password else None,
64
+ "login_timeout": float(q.get("login_timeout") or q.get("connect_timeout") or 15),
65
+ "appname": "sqlmirror",
66
+ "autocommit": False,
67
+ }
68
+ cafile = q.get("cafile")
69
+ if not cafile and q.get("encrypt", "").lower() in _TRUE:
70
+ import certifi
71
+
72
+ cafile = certifi.where()
73
+ if cafile:
74
+ kwargs["cafile"] = cafile
75
+ kwargs["validate_host"] = q.get("trustservercertificate", "").lower() not in _TRUE
76
+ kwargs["schema"] = q.get("schema") or None
77
+ kwargs["timezone"] = q.get("timezone") or q.get("tz") or None
78
+ return kwargs
79
+
80
+
81
+ def _utc(v: datetime | None) -> datetime | None:
82
+ """A value read back from SQL Server (naive, UTC) as an aware datetime."""
83
+ if isinstance(v, datetime) and v.tzinfo is None:
84
+ return v.replace(tzinfo=timezone.utc)
85
+ return v
86
+
87
+
88
+ def _to_naive_utc(v: Any, tz: tzinfo = timezone.utc) -> datetime | None:
89
+ """Aware or ISO value -> naive datetime in ``tz`` (UTC unless the mirror is set to a local zone)."""
90
+ if v is None:
91
+ return None
92
+ if not isinstance(v, datetime):
93
+ v = parse_ts(v) if not isinstance(v, date) else datetime(v.year, v.month, v.day, tzinfo=timezone.utc)
94
+ if v is None:
95
+ return None
96
+ if v.tzinfo is not None:
97
+ v = v.astimezone(tz).replace(tzinfo=None)
98
+ return v
99
+
100
+
101
+ # Date-only Jira fields: stored as that calendar day's midnight, never shifted between zones.
102
+ DATE_ONLY_COLUMNS = {("jiraissue", "duedate"), ("projectversion", "startdate"), ("projectversion", "releasedate")}
103
+
104
+
105
+ def _utf16_len(v: str) -> int:
106
+ """Length as SQL Server counts nvarchar: UTF-16 code units (an emoji counts 2)."""
107
+ return len(v) + sum(1 for ch in v if ord(ch) > 0xFFFF)
108
+
109
+
110
+ def _converter(sql_type: str, tz: tzinfo = timezone.utc, *, where: str = "", date_only: bool = False
111
+ ) -> Callable[[Any], Any]:
112
+ base = sql_type.split("(")[0].lower()
113
+ if base == "datetime":
114
+ def conv_dt(v: Any) -> Any:
115
+ if date_only or (isinstance(v, datetime) and v.tzinfo is not None and _is_utc_midnight(v)
116
+ and tz is not timezone.utc and where.endswith(".datevalue")):
117
+ v = _to_naive_utc(v) # a calendar date: keep the day as Jira shows it
118
+ else:
119
+ v = _to_naive_utc(v, tz)
120
+ return None if v is None or v < DATETIME_MIN else v
121
+ return conv_dt
122
+ if base == "datetime2":
123
+ return _to_naive_utc
124
+ if base == "bit":
125
+ return lambda v: None if v is None else bool(v)
126
+ if base == "float":
127
+ def conv_float(v: Any) -> Any:
128
+ if v is None or v == "":
129
+ return None
130
+ f = float(v)
131
+ return None if math.isnan(f) or math.isinf(f) else f # SQL Server has no NaN / infinity
132
+ return conv_float
133
+ if base in ("numeric", "bigint", "int"):
134
+ def conv_num(v: Any) -> Any:
135
+ if v is None or v == "":
136
+ return None
137
+ if isinstance(v, bool):
138
+ return int(v)
139
+ if isinstance(v, float):
140
+ return None if math.isnan(v) or math.isinf(v) else int(v)
141
+ return v
142
+ return conv_num
143
+ # nvarchar(n): CAST would silently cut a longer value; refuse it, as Postgres does.
144
+ limit = sql_type[sql_type.find("(") + 1:-1] if "(" in sql_type else "max"
145
+ if limit.lower() == "max":
146
+ return lambda v: v
147
+ n = int(limit)
148
+
149
+ def conv_str(v: Any) -> Any:
150
+ if isinstance(v, str) and len(v) > n // 2 and _utf16_len(v) > n:
151
+ raise ValueError(f"Value too long for {where} ({sql_type}): {_utf16_len(v)} characters, "
152
+ f"starting {v[:40]!r}")
153
+ return v
154
+ return conv_str
155
+
156
+
157
+ def _is_utc_midnight(v: datetime) -> bool:
158
+ u = v.astimezone(timezone.utc)
159
+ return (u.hour, u.minute, u.second, u.microsecond) == (0, 0, 0, 0)
160
+
161
+
162
+ class MssqlMirror(Mirror):
163
+ dialect = "tsql"
164
+ # datetime keeps 1/300 s: allow for rounding when comparing with Jira's milliseconds.
165
+ timestamp_resolution = timedelta(milliseconds=4)
166
+
167
+ def __init__(self, dsn: str, schema: str | None = None, *, connect: Callable[..., Any] | None = None):
168
+ kwargs = parse_dsn(dsn)
169
+ self.dsn = dsn
170
+ self.schema = schema or kwargs.pop("schema") or "dbo"
171
+ kwargs.pop("schema", None)
172
+ self.tz_name = kwargs.pop("timezone") or "UTC"
173
+ if self.tz_name.upper() == "UTC":
174
+ self.tz_name, self.tz = "UTC", timezone.utc
175
+ else:
176
+ from zoneinfo import ZoneInfo
177
+
178
+ self.tz = ZoneInfo(self.tz_name)
179
+ if connect is None:
180
+ try:
181
+ import pytds
182
+ except ImportError: # pragma: no cover - depends on the install
183
+ raise SystemExit('SQL Server support needs the python-tds driver: pip install "sqlmirror[sqlserver]"')
184
+ connect = pytds.connect
185
+ self.conn = connect(**kwargs)
186
+ # Column types and value converters per table, from the shared table definitions.
187
+ self._cols: dict[str, list[tuple[str, str, Callable[[Any], Any]]]] = {
188
+ t.name: [(c, column_type(t, c, typ, "tsql"),
189
+ _converter(column_type(t, c, typ, "tsql"), self.tz, where=f"{t.name}.{c}",
190
+ date_only=(t.name, c) in DATE_ONLY_COLUMNS))
191
+ for c, typ in t.columns]
192
+ for t in TABLES
193
+ }
194
+
195
+ # ----------------------------------------------------------- naming
196
+ def t(self, name: str) -> str:
197
+ return f"{tsql_ident(self.schema)}.{tsql_ident(name)}"
198
+
199
+ def sql_name(self, name: str) -> str:
200
+ return tsql_ident(name)
201
+
202
+ def rollback(self) -> None:
203
+ try:
204
+ self.conn.rollback()
205
+ except Exception: # noqa: BLE001 - the server may already have rolled back
206
+ pass
207
+
208
+ # ---------------------------------------------------------------- schema
209
+ def init_schema(self) -> None:
210
+ with self.conn.cursor() as cur:
211
+ for stmt in ddl_statements("tsql", self.schema):
212
+ cur.execute(stmt)
213
+ self.conn.commit()
214
+ self.set_meta("schema_version", SCHEMA_VERSION)
215
+ self.conn.commit()
216
+
217
+ def get_meta(self, key: str) -> str | None:
218
+ with self.conn.cursor() as cur:
219
+ cur.execute(f"SELECT [value] FROM {self.t('sqlmirror_meta')} WHERE [key] = %s", (key,))
220
+ row = cur.fetchone()
221
+ return row[0] if row else None
222
+
223
+ def set_meta(self, key: str, value: Any) -> None:
224
+ if not isinstance(value, str):
225
+ value = json.dumps(value, default=str)
226
+ with self.conn.cursor() as cur:
227
+ self._upsert(cur, TABLE_BY_NAME["sqlmirror_meta"], [{"key": key, "value": value}], overwrite=True)
228
+
229
+ def load_registry(self, mapper: Mapper) -> None:
230
+ with self.conn.cursor() as cur:
231
+ cur.execute(f"SELECT [id], [customfieldtypekey], [cfname] FROM {self.t('customfield')}")
232
+ for num, typekey, name in cur.fetchall():
233
+ mapper.register_custom_field(int(num), typekey, name)
234
+
235
+ # ------------------------------------------------------------ bookkeeping
236
+ def applied_files(self, keys: Iterable[str]) -> set[str]:
237
+ keys = list(keys)
238
+ out: set[str] = set()
239
+ with self.conn.cursor() as cur:
240
+ for i in range(0, len(keys), MAX_PARAMS):
241
+ chunk = keys[i:i + MAX_PARAMS]
242
+ cur.execute(f"SELECT [key] FROM {self.t('sqlmirror_applied_file')} WHERE [key] IN "
243
+ f"({', '.join(['%s'] * len(chunk))})", tuple(chunk))
244
+ out.update(r[0] for r in cur.fetchall())
245
+ # The key column is case-insensitive on most servers; only exact matches count.
246
+ return out & set(keys)
247
+
248
+ def last_applied_file(self, prefix: str = "") -> str | None:
249
+ with self.conn.cursor() as cur:
250
+ if prefix:
251
+ cur.execute(f"SELECT MAX([key]) FROM {self.t('sqlmirror_applied_file')} WHERE LEFT([key], %s) = %s",
252
+ (len(prefix), prefix))
253
+ else:
254
+ cur.execute(f"SELECT MAX([key]) FROM {self.t('sqlmirror_applied_file')}")
255
+ row = cur.fetchone()
256
+ return row[0] if row else None
257
+
258
+ def mark_file_applied(self, key: str, records: int) -> None:
259
+ with self.conn.cursor() as cur:
260
+ cur.execute(
261
+ f"INSERT INTO {self.t('sqlmirror_applied_file')} ([key], [records], [applied_at]) "
262
+ f"SELECT %s, %s, %s WHERE NOT EXISTS (SELECT 1 FROM {self.t('sqlmirror_applied_file')} "
263
+ f"WHERE [key] = %s)",
264
+ (key, records, _to_naive_utc(utcnow()), key),
265
+ )
266
+
267
+ def issue_states(self, ids: list[int]) -> dict[int, tuple[datetime, bool]]:
268
+ out: dict[int, tuple[datetime, bool]] = {}
269
+ with self.conn.cursor() as cur:
270
+ for chunk in _id_chunks(ids):
271
+ cur.execute(f"SELECT [issue_id], [fetched_at], [deleted] FROM {self.t('sqlmirror_issue_state')} "
272
+ f"WHERE [issue_id] IN ({chunk})")
273
+ for r in cur.fetchall():
274
+ out[int(r[0])] = (_utc(r[1]), bool(r[2]) if r[2] is not None else None)
275
+ return out
276
+
277
+ def project_issue_ids(self, project_id: int | None) -> list[int]:
278
+ if project_id is None:
279
+ return []
280
+ with self.conn.cursor() as cur:
281
+ cur.execute(f"SELECT [id] FROM {self.t('jiraissue')} WHERE [project] = {int(project_id)}")
282
+ return [int(r[0]) for r in cur.fetchall()]
283
+
284
+ def _save_issue_states(self, cur: Any, rows: list[tuple]) -> None:
285
+ cols = ("issue_id", "fetched_at", "deleted", "source", "applied_at")
286
+ self._upsert(cur, TABLE_BY_NAME["sqlmirror_issue_state"], [dict(zip(cols, r)) for r in rows], overwrite=True)
287
+
288
+ # --------------------------------------------------------------- deletes
289
+ def _delete_issue_rows(self, cur: Any, ids: list[int], deleted_ids: list[int]) -> None:
290
+ synthetic = ", ".join(str(int(x)) for x in sorted(SYNTHETIC_LINKTYPE_IDS))
291
+ T = self.t
292
+ for chunk in _id_chunks(ids):
293
+ cur.execute(f"DELETE FROM {T('changeitem')} WHERE [groupid] IN "
294
+ f"(SELECT [id] FROM {T('changegroup')} WHERE [issueid] IN ({chunk}))")
295
+ for table, col in (("changegroup", "issueid"), ("jiraaction", "issueid"), ("worklog", "issueid"),
296
+ ("fileattachment", "issueid"), ("customfieldvalue", "issue"), ("label", "issue")):
297
+ cur.execute(f"DELETE FROM {T(table)} WHERE {tsql_ident(col)} IN ({chunk})")
298
+ cur.execute(f"DELETE FROM {T('nodeassociation')} WHERE [source_node_entity] = N'Issue' "
299
+ f"AND [source_node_id] IN ({chunk})")
300
+ # Ordinary links belong to both issues; parent/child links belong to the child.
301
+ cur.execute(f"DELETE FROM {T('issuelink')} WHERE ([source] IN ({chunk}) OR [destination] IN ({chunk})) "
302
+ f"AND [linktype] NOT IN ({synthetic})")
303
+ cur.execute(f"DELETE FROM {T('issuelink')} WHERE [destination] IN ({chunk}) AND [linktype] IN ({synthetic})")
304
+ for chunk in _id_chunks(deleted_ids):
305
+ cur.execute(f"DELETE FROM {T('issuelink')} WHERE [source] IN ({chunk})")
306
+ for chunk in _id_chunks(ids):
307
+ cur.execute(f"DELETE FROM {T('jiraissue')} WHERE [id] IN ({chunk})")
308
+
309
+ def _project_categories(self, cur: Any, assoc: list[dict[str, Any]], all_projects: list[int]) -> None:
310
+ projects = sorted({a["project"] for a in assoc} | set(all_projects))
311
+ if not projects:
312
+ return
313
+ na = self.t("nodeassociation")
314
+ for chunk in _id_chunks(projects):
315
+ cur.execute(f"DELETE FROM {na} WHERE [source_node_entity] = N'Project' "
316
+ f"AND [association_type] = N'ProjectCategory' AND [source_node_id] IN ({chunk})")
317
+ seen = set()
318
+ for a in assoc:
319
+ key = (int(a["project"]), int(a["category"]))
320
+ if key in seen:
321
+ continue
322
+ seen.add(key)
323
+ cur.execute(
324
+ f"INSERT INTO {na} ([source_node_id], [source_node_entity], [sink_node_id], [sink_node_entity], "
325
+ f"[association_type], [sequence]) SELECT {key[0]}, N'Project', {key[1]}, N'ProjectCategory', "
326
+ f"N'ProjectCategory', NULL WHERE NOT EXISTS (SELECT 1 FROM {na} WHERE [source_node_id] = {key[0]} "
327
+ f"AND [source_node_entity] = N'Project' AND [sink_node_id] = {key[1]} "
328
+ f"AND [sink_node_entity] = N'ProjectCategory' AND [association_type] = N'ProjectCategory')"
329
+ )
330
+
331
+ # ---------------------------------------------------------- low level
332
+ def _values(self, t: Table, rows: list[dict[str, Any]]) -> Iterator[tuple[list[str], list[Any]]]:
333
+ """Batches of "(CAST(.. AS type), ...)" row literals and their parameters.
334
+
335
+ Integers are inlined (they are ints, so nothing to escape); NULLs are
336
+ inlined; everything else is a parameter. Batches stay under SQL
337
+ Server's parameter and row limits.
338
+ """
339
+ cols = self._cols[t.name]
340
+ batch: list[str] = []
341
+ params: list[Any] = []
342
+ for r in rows:
343
+ parts: list[str] = []
344
+ row_params: list[Any] = []
345
+ for c, typ, conv in cols:
346
+ v = conv(r.get(c))
347
+ if v is None:
348
+ parts.append(f"CAST(NULL AS {typ})")
349
+ elif isinstance(v, bool):
350
+ parts.append(f"CAST({1 if v else 0} AS {typ})")
351
+ elif isinstance(v, int):
352
+ parts.append(f"CAST({int(v)} AS {typ})")
353
+ else:
354
+ parts.append(f"CAST(%s AS {typ})")
355
+ row_params.append(v)
356
+ if batch and (len(batch) >= MAX_ROWS or len(params) + len(row_params) > MAX_PARAMS):
357
+ yield batch, params
358
+ batch, params = [], []
359
+ batch.append("(" + ", ".join(parts) + ")")
360
+ params.extend(row_params)
361
+ if batch:
362
+ yield batch, params
363
+
364
+ def _upsert(self, cur: Any, t: Table, rows: list[dict[str, Any]], overwrite: bool = False) -> int:
365
+ rows = self._dedupe(t, rows)
366
+ q = tsql_ident
367
+ cols = t.column_names
368
+ non_pk = [c for c in cols if c not in t.pk]
369
+ on = " AND ".join(f"tgt.{q(c)} = src.{q(c)}" for c in t.pk)
370
+ if overwrite:
371
+ sets = ", ".join(f"tgt.{q(c)} = src.{q(c)}" for c in non_pk)
372
+ else:
373
+ sets = ", ".join(f"tgt.{q(c)} = COALESCE(src.{q(c)}, tgt.{q(c)})" for c in non_pk)
374
+ col_list = ", ".join(q(c) for c in cols)
375
+ src_list = ", ".join(f"src.{q(c)}" for c in cols)
376
+ for values, params in self._values(t, rows):
377
+ cur.execute(
378
+ f"MERGE {self.t(t.name)} WITH (HOLDLOCK) AS tgt "
379
+ f"USING (VALUES {', '.join(values)}) AS src ({col_list}) ON {on} "
380
+ + (f"WHEN MATCHED THEN UPDATE SET {sets} " if non_pk else "")
381
+ + f"WHEN NOT MATCHED BY TARGET THEN INSERT ({col_list}) VALUES ({src_list});",
382
+ tuple(params),
383
+ )
384
+ return len(rows)
385
+
386
+ def _insert_rows(self, cur: Any, t: Table, rows: list[dict[str, Any]]) -> int:
387
+ rows = self._dedupe(t, rows)
388
+ col_list = ", ".join(tsql_ident(c) for c in t.column_names)
389
+ for values, params in self._values(t, rows):
390
+ cur.execute(f"INSERT INTO {self.t(t.name)} ({col_list}) VALUES {', '.join(values)};", tuple(params))
391
+ return len(rows)
392
+
393
+ # ------------------------------------------------------------- sync log
394
+ def log_run(self, kind: str, source: str, started: datetime, *, files: int = 0, stats: "ApplyStats | None" = None,
395
+ error: str | None = None) -> None:
396
+ self.rollback() # in case the run failed mid-transaction
397
+ with self.conn.cursor() as cur:
398
+ self._insert_rows(cur, TABLE_BY_NAME["sqlmirror_sync_log"], [{
399
+ "id": int(started.timestamp() * 1_000_000), "started_at": started, "finished_at": utcnow(),
400
+ "kind": kind, "source": source, "files": files,
401
+ "issues": stats.snapshots if stats else 0, "tombstones": stats.tombstones if stats else 0,
402
+ "skipped": stats.skipped_stale if stats else 0, "error": error,
403
+ }])
404
+ self.conn.commit()
405
+
406
+ def recent_log(self, limit: int = 20) -> list[tuple]:
407
+ with self.conn.cursor() as cur:
408
+ cur.execute(f"SELECT TOP ({int(limit)}) [started_at], [finished_at], [kind], [source], [files], [issues], "
409
+ f"[tombstones], [skipped], [error] FROM {self.t('sqlmirror_sync_log')} ORDER BY [started_at] DESC")
410
+ return [(_utc(r[0]), _utc(r[1]), *r[2:]) for r in cur.fetchall()]
411
+
412
+ # -------------------------------------------------------------- queries
413
+ def project_counts(self) -> dict[str, int]:
414
+ with self.conn.cursor() as cur:
415
+ cur.execute(f"SELECT p.[pkey], COUNT(i.[id]) FROM {self.t('project')} p "
416
+ f"LEFT JOIN {self.t('jiraissue')} i ON i.[project] = p.[id] GROUP BY p.[pkey] ORDER BY p.[pkey]")
417
+ return {r[0]: int(r[1]) for r in cur.fetchall()}
418
+
419
+ def issue_updated(self, project_key: str) -> dict[int, datetime]:
420
+ with self.conn.cursor() as cur:
421
+ cur.execute(f"SELECT i.[id], i.[updated] FROM {self.t('jiraissue')} i "
422
+ f"JOIN {self.t('project')} p ON p.[id] = i.[project] WHERE p.[pkey] = %s", (project_key,))
423
+ return {int(r[0]): self.local_to_aware(r[1]) for r in cur.fetchall()}
424
+
425
+ def local_to_aware(self, v: Any) -> Any:
426
+ """A datetime read from a Data Center table (naive, in the mirror's zone) as an aware datetime."""
427
+ if isinstance(v, datetime) and v.tzinfo is None:
428
+ if self.tz is timezone.utc:
429
+ return v.replace(tzinfo=timezone.utc)
430
+ # In the autumn hour that happens twice, take the later reading, so reconcile
431
+ # never sees the mirror as older than Jira and re-fetches forever.
432
+ # Returned in UTC: Python never treats an ambiguous local time as equal to another zone's.
433
+ return max(v.replace(tzinfo=self.tz, fold=0).astimezone(timezone.utc),
434
+ v.replace(tzinfo=self.tz, fold=1).astimezone(timezone.utc))
435
+ return v
436
+
437
+ def table_counts(self) -> dict[str, int]:
438
+ out = {}
439
+ with self.conn.cursor() as cur:
440
+ for name in TABLE_BY_NAME:
441
+ cur.execute(f"SELECT COUNT_BIG(*) FROM {self.t(name)}")
442
+ out[name] = int(cur.fetchone()[0])
443
+ return out
444
+
445
+ def freshness(self) -> tuple[datetime | None, datetime | None]:
446
+ with self.conn.cursor() as cur:
447
+ cur.execute(f"SELECT MAX([applied_at]), MAX([fetched_at]) FROM {self.t('sqlmirror_issue_state')}")
448
+ row = cur.fetchone()
449
+ return _utc(row[0]), _utc(row[1])
450
+
451
+ def read_table(self, t: Table) -> list[tuple]:
452
+ with self.conn.cursor() as cur:
453
+ cur.execute(f"SELECT {', '.join(tsql_ident(c) for c in t.column_names)} FROM {self.t(t.name)}")
454
+ return cur.fetchall()
455
+
456
+ def explain(self, sql_text: str) -> str:
457
+ """"ok" if SQL Server can compile the statement against the mirror, else the error."""
458
+ try:
459
+ with self.conn.cursor() as cur:
460
+ cur.execute("EXEC sp_describe_first_result_set @tsql = %s", (sql_text,))
461
+ cur.fetchall()
462
+ return "ok"
463
+ except Exception as exc: # noqa: BLE001 - pytds.Error and friends
464
+ return str(exc).strip().splitlines()[0]
465
+ finally:
466
+ self.rollback()
467
+
468
+ def query(self, sql_text: str, params: tuple = (), timeout_s: int = 60) -> list[tuple]:
469
+ try:
470
+ with self.conn.cursor() as cur:
471
+ if params:
472
+ cur.execute(sql_text, params)
473
+ else:
474
+ cur.execute(sql_text)
475
+ return cur.fetchall() if cur.description else []
476
+ finally:
477
+ self.rollback()
478
+
479
+
480
+ def _id_chunks(ids: Iterable[int]) -> Iterator[str]:
481
+ """Comma-separated id lists for IN (...). Ids are ints, so inlining them is safe."""
482
+ ids = [int(i) for i in ids]
483
+ for i in range(0, len(ids), ID_CHUNK):
484
+ yield ", ".join(str(x) for x in ids[i:i + ID_CHUNK])
485
+
486
+
487
+ # ------------------------------------------------------------ server admin
488
+ def server_connect(dsn: str, *, autocommit: bool = True) -> Any:
489
+ """A connection to the server itself (database from the DSN, usually master), for test setup."""
490
+ import pytds
491
+
492
+ kwargs = parse_dsn(dsn)
493
+ kwargs.pop("schema", None)
494
+ kwargs.pop("timezone", None)
495
+ kwargs["autocommit"] = autocommit
496
+ return pytds.connect(**kwargs)
497
+
498
+
499
+ def dsn_with_database(dsn: str, database: str) -> str:
500
+ u = urlsplit(dsn)
501
+ return u._replace(path="/" + database).geturl()
502
+
503
+
504
+ def recreate_database(server_dsn: str, name: str) -> str:
505
+ q = tsql_ident(name)
506
+ lit = name.replace("'", "''")
507
+ conn = server_connect(server_dsn)
508
+ try:
509
+ with conn.cursor() as cur:
510
+ cur.execute(f"IF DB_ID(N'{lit}') IS NOT NULL BEGIN ALTER DATABASE {q} SET SINGLE_USER WITH ROLLBACK IMMEDIATE; "
511
+ f"DROP DATABASE {q}; END")
512
+ cur.execute(f"CREATE DATABASE {q}")
513
+ finally:
514
+ conn.close()
515
+ return dsn_with_database(server_dsn, name)
516
+
517
+
518
+ def drop_database(server_dsn: str, name: str) -> None:
519
+ q = tsql_ident(name)
520
+ lit = name.replace("'", "''")
521
+ conn = server_connect(server_dsn)
522
+ try:
523
+ with conn.cursor() as cur:
524
+ cur.execute(f"IF DB_ID(N'{lit}') IS NOT NULL BEGIN ALTER DATABASE {q} SET SINGLE_USER WITH ROLLBACK IMMEDIATE; "
525
+ f"DROP DATABASE {q}; END")
526
+ finally:
527
+ conn.close()
528
+
529
+
530
+ __all__ = ["MssqlMirror", "parse_dsn", "recreate_database", "drop_database", "dsn_with_database", "Decimal"]
@@ -0,0 +1 @@
1
+ """Development tools: synthetic tenants, a mock Jira server, site loaders, golden queries."""