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/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."""
|