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.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""Database writers for the mirror (Postgres here, SQL Server in db_mssql.py).
|
|
2
|
+
|
|
3
|
+
Every write goes through ``apply_records`` (issue snapshots and tombstones) or
|
|
4
|
+
``apply_metadata``. Both are idempotent: applying the same data twice leaves
|
|
5
|
+
the database unchanged, and an older snapshot never overwrites a newer one.
|
|
6
|
+
That logic lives in ``Mirror``; each database class supplies the SQL.
|
|
7
|
+
|
|
8
|
+
``open_mirror(dsn)`` picks the class from the connection string:
|
|
9
|
+
postgresql://user:pass@host:5432/db -> PgMirror
|
|
10
|
+
sqlserver://user:pass@host:1433/db (mssql://) -> MssqlMirror
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime, timedelta
|
|
18
|
+
from typing import Any, Iterable
|
|
19
|
+
|
|
20
|
+
import psycopg
|
|
21
|
+
from psycopg import sql
|
|
22
|
+
|
|
23
|
+
from .mapper import SYNTHETIC_LINKTYPE_IDS, Mapper
|
|
24
|
+
from .schema import ISSUE_TABLES, SCHEMA_VERSION, TABLE_BY_NAME, UPSERT_TABLES, Table, ddl
|
|
25
|
+
from .util import parse_ts, to_int, utcnow
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ApplyStats:
|
|
30
|
+
snapshots: int = 0
|
|
31
|
+
tombstones: int = 0
|
|
32
|
+
skipped_stale: int = 0
|
|
33
|
+
rows: dict[str, int] = field(default_factory=lambda: defaultdict(int))
|
|
34
|
+
|
|
35
|
+
def add(self, other: "ApplyStats") -> None:
|
|
36
|
+
self.snapshots += other.snapshots
|
|
37
|
+
self.tombstones += other.tombstones
|
|
38
|
+
self.skipped_stale += other.skipped_stale
|
|
39
|
+
for k, v in other.rows.items():
|
|
40
|
+
self.rows[k] += v
|
|
41
|
+
|
|
42
|
+
def summary(self) -> str:
|
|
43
|
+
return (f"{self.snapshots} issues written, {self.tombstones} deleted, "
|
|
44
|
+
f"{self.skipped_stale} skipped (older than mirror)")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def record_time(rec: dict[str, Any]) -> datetime | None:
|
|
48
|
+
return parse_ts(rec.get("fetchedAt") or rec.get("deletedAt"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_sqlserver_dsn(dsn: str) -> bool:
|
|
52
|
+
return dsn.strip().lower().split("://", 1)[0] in ("sqlserver", "mssql", "mssql+pytds", "mssql+pyodbc", "mssql+pymssql")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def open_mirror(dsn: str, schema: str | None = None) -> "Mirror":
|
|
56
|
+
"""Connect to the mirror database named by the connection string."""
|
|
57
|
+
if is_sqlserver_dsn(dsn):
|
|
58
|
+
from .db_mssql import MssqlMirror
|
|
59
|
+
|
|
60
|
+
return MssqlMirror(dsn, schema=schema)
|
|
61
|
+
return PgMirror(dsn, schema=schema)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Mirror:
|
|
65
|
+
"""Shared apply logic. Subclasses provide the SQL for one database."""
|
|
66
|
+
|
|
67
|
+
dialect = "" # "postgres" or "tsql"
|
|
68
|
+
# Smallest difference between two stored timestamps the database can show
|
|
69
|
+
# (SQL Server's datetime keeps 1/300 s), so reconcile doesn't chase rounding.
|
|
70
|
+
timestamp_resolution = timedelta(0)
|
|
71
|
+
conn: Any
|
|
72
|
+
dsn: str
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
self.conn.close()
|
|
76
|
+
|
|
77
|
+
def commit(self) -> None:
|
|
78
|
+
self.conn.commit()
|
|
79
|
+
|
|
80
|
+
def rollback(self) -> None:
|
|
81
|
+
self.conn.rollback()
|
|
82
|
+
|
|
83
|
+
# -------------------------------------------------------------- metadata
|
|
84
|
+
def apply_metadata(self, mapper: Mapper, meta: dict[str, Any]) -> dict[str, int]:
|
|
85
|
+
rows = mapper.map_metadata(meta)
|
|
86
|
+
counts: dict[str, int] = {}
|
|
87
|
+
with self.conn.cursor() as cur:
|
|
88
|
+
for t in UPSERT_TABLES:
|
|
89
|
+
if rows.get(t.name):
|
|
90
|
+
counts[t.name] = self._upsert(cur, t, rows[t.name])
|
|
91
|
+
self._project_categories(cur, rows.get("project_category_assoc", []),
|
|
92
|
+
[r["id"] for r in rows.get("project", [])])
|
|
93
|
+
self.set_meta("metadata_fetched_at", meta.get("fetchedAt") or "")
|
|
94
|
+
self.conn.commit()
|
|
95
|
+
return counts
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------- issues
|
|
98
|
+
def apply_records(self, mapper: Mapper, records: list[dict[str, Any]], source: str) -> ApplyStats:
|
|
99
|
+
"""Apply issue snapshots and tombstones in one transaction."""
|
|
100
|
+
stats = ApplyStats()
|
|
101
|
+
# A deleted project becomes a tombstone for every issue the mirror has in it.
|
|
102
|
+
expanded: list[dict[str, Any]] = []
|
|
103
|
+
for rec in records:
|
|
104
|
+
if rec.get("type") == "project_deleted":
|
|
105
|
+
ids = self.project_issue_ids(to_int(rec.get("projectId")))
|
|
106
|
+
expanded.extend({"v": 1, "type": "deleted", "id": str(i), "deletedAt": rec.get("deletedAt"),
|
|
107
|
+
"reason": "project_deleted"} for i in ids)
|
|
108
|
+
else:
|
|
109
|
+
expanded.append(rec)
|
|
110
|
+
records = expanded
|
|
111
|
+
latest: dict[int, dict[str, Any]] = {}
|
|
112
|
+
for rec in records:
|
|
113
|
+
iid = to_int(rec.get("id") or (rec.get("issue") or {}).get("id"))
|
|
114
|
+
t = record_time(rec)
|
|
115
|
+
if iid is None or t is None:
|
|
116
|
+
continue
|
|
117
|
+
cur_best = latest.get(iid)
|
|
118
|
+
if cur_best is None or record_time(cur_best) < t:
|
|
119
|
+
latest[iid] = rec
|
|
120
|
+
|
|
121
|
+
states = self.issue_states(list(latest))
|
|
122
|
+
keep: dict[int, dict[str, Any]] = {}
|
|
123
|
+
for iid, rec in latest.items():
|
|
124
|
+
prev = states.get(iid)
|
|
125
|
+
if prev is not None and prev[0] is not None and prev[0] >= record_time(rec):
|
|
126
|
+
stats.skipped_stale += 1
|
|
127
|
+
continue
|
|
128
|
+
keep[iid] = rec
|
|
129
|
+
if not keep:
|
|
130
|
+
return stats
|
|
131
|
+
|
|
132
|
+
snapshots = {i: r for i, r in keep.items() if r.get("type") == "issue"}
|
|
133
|
+
tombstones = {i: r for i, r in keep.items() if r.get("type") == "deleted"}
|
|
134
|
+
|
|
135
|
+
merged: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
136
|
+
for rec in snapshots.values():
|
|
137
|
+
for table, rows in mapper.map_issue(rec).items():
|
|
138
|
+
merged[table].extend(rows)
|
|
139
|
+
|
|
140
|
+
ids = list(keep)
|
|
141
|
+
with self.conn.cursor() as cur:
|
|
142
|
+
self._delete_issue_rows(cur, ids, list(tombstones))
|
|
143
|
+
for t in UPSERT_TABLES:
|
|
144
|
+
if merged.get(t.name):
|
|
145
|
+
stats.rows[t.name] += self._upsert(cur, t, merged[t.name])
|
|
146
|
+
self._project_categories(cur, merged.get("project_category_assoc", []), [])
|
|
147
|
+
for t in ISSUE_TABLES:
|
|
148
|
+
rows = merged.get(t.name)
|
|
149
|
+
if not rows:
|
|
150
|
+
continue
|
|
151
|
+
if t.name == "issuelink":
|
|
152
|
+
stats.rows[t.name] += self._upsert(cur, t, rows, overwrite=True)
|
|
153
|
+
else:
|
|
154
|
+
stats.rows[t.name] += self._insert_rows(cur, t, rows)
|
|
155
|
+
now = utcnow()
|
|
156
|
+
self._save_issue_states(
|
|
157
|
+
cur, [(iid, record_time(rec), rec.get("type") == "deleted", source, now) for iid, rec in keep.items()])
|
|
158
|
+
self.conn.commit()
|
|
159
|
+
stats.snapshots = len(snapshots)
|
|
160
|
+
stats.tombstones = len(tombstones)
|
|
161
|
+
return stats
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _dedupe(t: Table, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
165
|
+
"""Merge rows that share a primary key: later non-null values win."""
|
|
166
|
+
out: dict[tuple, dict[str, Any]] = {}
|
|
167
|
+
for r in rows:
|
|
168
|
+
key = tuple(r.get(c) for c in t.pk)
|
|
169
|
+
if key in out:
|
|
170
|
+
for k, v in r.items():
|
|
171
|
+
if v is not None:
|
|
172
|
+
out[key][k] = v
|
|
173
|
+
else:
|
|
174
|
+
out[key] = dict(r)
|
|
175
|
+
return list(out.values())
|
|
176
|
+
|
|
177
|
+
# ------------------------------------------------- per-database SQL hooks
|
|
178
|
+
def init_schema(self) -> None: raise NotImplementedError
|
|
179
|
+
def get_meta(self, key: str) -> str | None: raise NotImplementedError
|
|
180
|
+
def set_meta(self, key: str, value: Any) -> None: raise NotImplementedError
|
|
181
|
+
def load_registry(self, mapper: Mapper) -> None: raise NotImplementedError
|
|
182
|
+
def applied_files(self, keys: Iterable[str]) -> set[str]: raise NotImplementedError
|
|
183
|
+
def last_applied_file(self, prefix: str = "") -> str | None: raise NotImplementedError
|
|
184
|
+
def mark_file_applied(self, key: str, records: int) -> None: raise NotImplementedError
|
|
185
|
+
def issue_states(self, ids: list[int]) -> dict[int, tuple[datetime, bool]]: raise NotImplementedError
|
|
186
|
+
def project_issue_ids(self, project_id: int | None) -> list[int]: raise NotImplementedError
|
|
187
|
+
def _delete_issue_rows(self, cur: Any, ids: list[int], deleted_ids: list[int]) -> None: raise NotImplementedError
|
|
188
|
+
def _project_categories(self, cur: Any, assoc: list[dict[str, Any]], all_projects: list[int]) -> None:
|
|
189
|
+
raise NotImplementedError
|
|
190
|
+
def _upsert(self, cur: Any, t: Table, rows: list[dict[str, Any]], overwrite: bool = False) -> int:
|
|
191
|
+
raise NotImplementedError
|
|
192
|
+
def _insert_rows(self, cur: Any, t: Table, rows: list[dict[str, Any]]) -> int: raise NotImplementedError
|
|
193
|
+
def _save_issue_states(self, cur: Any, rows: list[tuple]) -> None: raise NotImplementedError
|
|
194
|
+
def log_run(self, kind: str, source: str, started: datetime, *, files: int = 0, stats: "ApplyStats | None" = None,
|
|
195
|
+
error: str | None = None) -> None: raise NotImplementedError
|
|
196
|
+
def recent_log(self, limit: int = 20) -> list[tuple]: raise NotImplementedError
|
|
197
|
+
def project_counts(self) -> dict[str, int]: raise NotImplementedError
|
|
198
|
+
def issue_updated(self, project_key: str) -> dict[int, datetime]: raise NotImplementedError
|
|
199
|
+
def table_counts(self) -> dict[str, int]: raise NotImplementedError
|
|
200
|
+
def freshness(self) -> tuple[datetime | None, datetime | None]: raise NotImplementedError
|
|
201
|
+
def read_table(self, t: Table) -> list[tuple]: raise NotImplementedError
|
|
202
|
+
def explain(self, sql_text: str) -> str: raise NotImplementedError
|
|
203
|
+
def query(self, sql_text: str, params: tuple = (), timeout_s: int = 60) -> list[tuple]: raise NotImplementedError
|
|
204
|
+
def sql_name(self, name: str) -> str:
|
|
205
|
+
"""A table or column name quoted for this database when it needs quoting (Active Objects tables)."""
|
|
206
|
+
raise NotImplementedError
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class PgMirror(Mirror):
|
|
210
|
+
dialect = "postgres"
|
|
211
|
+
|
|
212
|
+
def __init__(self, dsn: str, schema: str | None = None):
|
|
213
|
+
self.dsn = dsn
|
|
214
|
+
self.schema = schema
|
|
215
|
+
self.conn = psycopg.connect(dsn, autocommit=False)
|
|
216
|
+
if schema:
|
|
217
|
+
with self.conn.cursor() as cur:
|
|
218
|
+
cur.execute(sql.SQL("CREATE SCHEMA IF NOT EXISTS {}").format(sql.Identifier(schema)))
|
|
219
|
+
cur.execute(sql.SQL("SET search_path TO {}").format(sql.Identifier(schema)))
|
|
220
|
+
self.conn.commit()
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------- schema
|
|
223
|
+
def init_schema(self) -> None:
|
|
224
|
+
with self.conn.cursor() as cur:
|
|
225
|
+
cur.execute(ddl("postgres"))
|
|
226
|
+
self.conn.commit()
|
|
227
|
+
self.set_meta("schema_version", SCHEMA_VERSION)
|
|
228
|
+
self.conn.commit()
|
|
229
|
+
|
|
230
|
+
def get_meta(self, key: str) -> str | None:
|
|
231
|
+
with self.conn.cursor() as cur:
|
|
232
|
+
cur.execute("SELECT value FROM sqlmirror_meta WHERE key = %s", (key,))
|
|
233
|
+
row = cur.fetchone()
|
|
234
|
+
return row[0] if row else None
|
|
235
|
+
|
|
236
|
+
def set_meta(self, key: str, value: Any) -> None:
|
|
237
|
+
if not isinstance(value, str):
|
|
238
|
+
value = json.dumps(value, default=str)
|
|
239
|
+
with self.conn.cursor() as cur:
|
|
240
|
+
cur.execute(
|
|
241
|
+
"INSERT INTO sqlmirror_meta (key, value) VALUES (%s, %s) "
|
|
242
|
+
"ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
|
|
243
|
+
(key, value),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def load_registry(self, mapper: Mapper) -> None:
|
|
247
|
+
"""Teach the mapper about custom fields already stored in the mirror."""
|
|
248
|
+
with self.conn.cursor() as cur:
|
|
249
|
+
cur.execute("SELECT id, customfieldtypekey, cfname FROM customfield")
|
|
250
|
+
for num, typekey, name in cur.fetchall():
|
|
251
|
+
mapper.register_custom_field(int(num), typekey, name)
|
|
252
|
+
|
|
253
|
+
# ------------------------------------------------------------ bookkeeping
|
|
254
|
+
def applied_files(self, keys: Iterable[str]) -> set[str]:
|
|
255
|
+
keys = list(keys)
|
|
256
|
+
if not keys:
|
|
257
|
+
return set()
|
|
258
|
+
with self.conn.cursor() as cur:
|
|
259
|
+
cur.execute("SELECT key FROM sqlmirror_applied_file WHERE key = ANY(%s)", (keys,))
|
|
260
|
+
return {r[0] for r in cur.fetchall()}
|
|
261
|
+
|
|
262
|
+
def last_applied_file(self, prefix: str = "") -> str | None:
|
|
263
|
+
with self.conn.cursor() as cur:
|
|
264
|
+
if prefix:
|
|
265
|
+
cur.execute("SELECT max(key) FROM sqlmirror_applied_file WHERE key LIKE %s", (prefix + "%",))
|
|
266
|
+
else:
|
|
267
|
+
cur.execute("SELECT max(key) FROM sqlmirror_applied_file")
|
|
268
|
+
row = cur.fetchone()
|
|
269
|
+
return row[0] if row else None
|
|
270
|
+
|
|
271
|
+
def mark_file_applied(self, key: str, records: int) -> None:
|
|
272
|
+
with self.conn.cursor() as cur:
|
|
273
|
+
cur.execute(
|
|
274
|
+
"INSERT INTO sqlmirror_applied_file (key, records, applied_at) VALUES (%s, %s, %s) "
|
|
275
|
+
"ON CONFLICT (key) DO NOTHING",
|
|
276
|
+
(key, records, utcnow()),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def issue_states(self, ids: list[int]) -> dict[int, tuple[datetime, bool]]:
|
|
280
|
+
if not ids:
|
|
281
|
+
return {}
|
|
282
|
+
with self.conn.cursor() as cur:
|
|
283
|
+
cur.execute(
|
|
284
|
+
"SELECT issue_id, fetched_at, deleted FROM sqlmirror_issue_state WHERE issue_id = ANY(%s)",
|
|
285
|
+
(ids,),
|
|
286
|
+
)
|
|
287
|
+
return {int(r[0]): (r[1], r[2]) for r in cur.fetchall()}
|
|
288
|
+
|
|
289
|
+
def project_issue_ids(self, project_id: int | None) -> list[int]:
|
|
290
|
+
with self.conn.cursor() as cur:
|
|
291
|
+
cur.execute("SELECT id FROM jiraissue WHERE project = %s", (project_id,))
|
|
292
|
+
return [int(r[0]) for r in cur.fetchall()]
|
|
293
|
+
|
|
294
|
+
def _save_issue_states(self, cur: psycopg.Cursor, rows: list[tuple]) -> None:
|
|
295
|
+
cur.executemany(
|
|
296
|
+
"INSERT INTO sqlmirror_issue_state (issue_id, fetched_at, deleted, source, applied_at) "
|
|
297
|
+
"VALUES (%s, %s, %s, %s, %s) ON CONFLICT (issue_id) DO UPDATE SET "
|
|
298
|
+
"fetched_at = EXCLUDED.fetched_at, deleted = EXCLUDED.deleted, "
|
|
299
|
+
"source = EXCLUDED.source, applied_at = EXCLUDED.applied_at",
|
|
300
|
+
rows,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def _delete_issue_rows(self, cur: psycopg.Cursor, ids: list[int], deleted_ids: list[int]) -> None:
|
|
304
|
+
cur.execute(
|
|
305
|
+
"DELETE FROM changeitem WHERE groupid IN (SELECT id FROM changegroup WHERE issueid = ANY(%s))", (ids,)
|
|
306
|
+
)
|
|
307
|
+
for table, col in (("changegroup", "issueid"), ("jiraaction", "issueid"), ("worklog", "issueid"),
|
|
308
|
+
("fileattachment", "issueid"), ("customfieldvalue", "issue"), ("label", "issue")):
|
|
309
|
+
cur.execute(sql.SQL("DELETE FROM {} WHERE {} = ANY(%s)").format(sql.Identifier(table),
|
|
310
|
+
sql.Identifier(col)), (ids,))
|
|
311
|
+
cur.execute(
|
|
312
|
+
"DELETE FROM nodeassociation WHERE source_node_entity = 'Issue' AND source_node_id = ANY(%s)", (ids,)
|
|
313
|
+
)
|
|
314
|
+
synthetic = list(SYNTHETIC_LINKTYPE_IDS)
|
|
315
|
+
# Ordinary links belong to both issues; parent/child links belong to the child.
|
|
316
|
+
cur.execute(
|
|
317
|
+
"DELETE FROM issuelink WHERE (source = ANY(%s) OR destination = ANY(%s)) AND NOT (linktype = ANY(%s))",
|
|
318
|
+
(ids, ids, synthetic),
|
|
319
|
+
)
|
|
320
|
+
cur.execute("DELETE FROM issuelink WHERE destination = ANY(%s) AND linktype = ANY(%s)", (ids, synthetic))
|
|
321
|
+
if deleted_ids:
|
|
322
|
+
cur.execute("DELETE FROM issuelink WHERE source = ANY(%s)", (deleted_ids,))
|
|
323
|
+
cur.execute("DELETE FROM jiraissue WHERE id = ANY(%s)", (ids,))
|
|
324
|
+
|
|
325
|
+
def _project_categories(self, cur: psycopg.Cursor, assoc: list[dict[str, Any]], all_projects: list[int]) -> None:
|
|
326
|
+
projects = sorted({a["project"] for a in assoc} | set(all_projects))
|
|
327
|
+
if not projects:
|
|
328
|
+
return
|
|
329
|
+
cur.execute(
|
|
330
|
+
"DELETE FROM nodeassociation WHERE source_node_entity = 'Project' AND association_type = 'ProjectCategory' "
|
|
331
|
+
"AND source_node_id = ANY(%s)",
|
|
332
|
+
(projects,),
|
|
333
|
+
)
|
|
334
|
+
seen = set()
|
|
335
|
+
for a in assoc:
|
|
336
|
+
key = (a["project"], a["category"])
|
|
337
|
+
if key in seen:
|
|
338
|
+
continue
|
|
339
|
+
seen.add(key)
|
|
340
|
+
cur.execute(
|
|
341
|
+
"INSERT INTO nodeassociation (source_node_id, source_node_entity, sink_node_id, sink_node_entity, "
|
|
342
|
+
"association_type, sequence) VALUES (%s, 'Project', %s, 'ProjectCategory', 'ProjectCategory', NULL) "
|
|
343
|
+
"ON CONFLICT DO NOTHING",
|
|
344
|
+
key,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# ---------------------------------------------------------- low level
|
|
348
|
+
def _upsert(self, cur: psycopg.Cursor, t: Table, rows: list[dict[str, Any]], overwrite: bool = False) -> int:
|
|
349
|
+
rows = self._dedupe(t, rows)
|
|
350
|
+
cols = t.column_names
|
|
351
|
+
non_pk = [c for c in cols if c not in t.pk]
|
|
352
|
+
col_sql = sql.SQL(", ").join(sql.Identifier(c) if t.quoted else sql.SQL(c) for c in cols)
|
|
353
|
+
ph = sql.SQL(", ").join(sql.Placeholder() for _ in cols)
|
|
354
|
+
pk_sql = sql.SQL(", ").join(sql.Identifier(c) if t.quoted else sql.SQL(c) for c in t.pk)
|
|
355
|
+
tname = sql.Identifier(t.name) if t.quoted else sql.SQL(t.name)
|
|
356
|
+
|
|
357
|
+
def ident(c: str) -> sql.Composable:
|
|
358
|
+
return sql.Identifier(c) if t.quoted else sql.SQL(c)
|
|
359
|
+
|
|
360
|
+
if overwrite:
|
|
361
|
+
sets = [sql.SQL("{c} = EXCLUDED.{c}").format(c=ident(c)) for c in non_pk]
|
|
362
|
+
else:
|
|
363
|
+
sets = [sql.SQL("{c} = COALESCE(EXCLUDED.{c}, {t}.{c})").format(c=ident(c), t=tname) for c in non_pk]
|
|
364
|
+
stmt = sql.SQL("INSERT INTO {t} ({cols}) VALUES ({ph}) ON CONFLICT ({pk}) DO UPDATE SET {sets}").format(
|
|
365
|
+
t=tname, cols=col_sql, ph=ph, pk=pk_sql, sets=sql.SQL(", ").join(sets)
|
|
366
|
+
)
|
|
367
|
+
cur.executemany(stmt, [tuple(r.get(c) for c in cols) for r in rows])
|
|
368
|
+
return len(rows)
|
|
369
|
+
|
|
370
|
+
def _insert_rows(self, cur: psycopg.Cursor, t: Table, rows: list[dict[str, Any]]) -> int:
|
|
371
|
+
return self._copy(cur, t, rows)
|
|
372
|
+
|
|
373
|
+
def _copy(self, cur: psycopg.Cursor, t: Table, rows: list[dict[str, Any]]) -> int:
|
|
374
|
+
rows = self._dedupe(t, rows)
|
|
375
|
+
cols = t.column_names
|
|
376
|
+
stmt = sql.SQL("COPY {t} ({cols}) FROM STDIN").format(
|
|
377
|
+
t=sql.Identifier(t.name) if t.quoted else sql.SQL(t.name),
|
|
378
|
+
cols=sql.SQL(", ").join(sql.Identifier(c) if t.quoted else sql.SQL(c) for c in cols),
|
|
379
|
+
)
|
|
380
|
+
with cur.copy(stmt) as copy:
|
|
381
|
+
for r in rows:
|
|
382
|
+
copy.write_row(tuple(r.get(c) for c in cols))
|
|
383
|
+
return len(rows)
|
|
384
|
+
|
|
385
|
+
# ------------------------------------------------------------- sync log
|
|
386
|
+
def log_run(self, kind: str, source: str, started: datetime, *, files: int = 0, stats: "ApplyStats | None" = None,
|
|
387
|
+
error: str | None = None) -> None:
|
|
388
|
+
"""Record one apply / backfill / reconcile run in sqlmirror_sync_log."""
|
|
389
|
+
self.conn.rollback() # in case the run failed mid-transaction
|
|
390
|
+
with self.conn.cursor() as cur:
|
|
391
|
+
cur.execute(
|
|
392
|
+
"INSERT INTO sqlmirror_sync_log (id, started_at, finished_at, kind, source, files, issues, tombstones, "
|
|
393
|
+
"skipped, error) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
|
|
394
|
+
(int(started.timestamp() * 1_000_000), started, utcnow(), kind, source, files,
|
|
395
|
+
stats.snapshots if stats else 0, stats.tombstones if stats else 0,
|
|
396
|
+
stats.skipped_stale if stats else 0, error),
|
|
397
|
+
)
|
|
398
|
+
self.conn.commit()
|
|
399
|
+
|
|
400
|
+
def recent_log(self, limit: int = 20) -> list[tuple]:
|
|
401
|
+
with self.conn.cursor() as cur:
|
|
402
|
+
cur.execute("SELECT started_at, finished_at, kind, source, files, issues, tombstones, skipped, error "
|
|
403
|
+
"FROM sqlmirror_sync_log ORDER BY started_at DESC LIMIT %s", (limit,))
|
|
404
|
+
return cur.fetchall()
|
|
405
|
+
|
|
406
|
+
# -------------------------------------------------------------- queries
|
|
407
|
+
def project_counts(self) -> dict[str, int]:
|
|
408
|
+
with self.conn.cursor() as cur:
|
|
409
|
+
cur.execute(
|
|
410
|
+
"SELECT p.pkey, count(i.id) FROM project p LEFT JOIN jiraissue i ON i.project = p.id "
|
|
411
|
+
"GROUP BY p.pkey ORDER BY p.pkey"
|
|
412
|
+
)
|
|
413
|
+
return {r[0]: int(r[1]) for r in cur.fetchall()}
|
|
414
|
+
|
|
415
|
+
def issue_updated(self, project_key: str) -> dict[int, datetime]:
|
|
416
|
+
with self.conn.cursor() as cur:
|
|
417
|
+
cur.execute(
|
|
418
|
+
"SELECT i.id, i.updated FROM jiraissue i JOIN project p ON p.id = i.project WHERE p.pkey = %s",
|
|
419
|
+
(project_key,),
|
|
420
|
+
)
|
|
421
|
+
return {int(r[0]): r[1] for r in cur.fetchall()}
|
|
422
|
+
|
|
423
|
+
def table_counts(self) -> dict[str, int]:
|
|
424
|
+
out = {}
|
|
425
|
+
with self.conn.cursor() as cur:
|
|
426
|
+
for name, t in TABLE_BY_NAME.items():
|
|
427
|
+
cur.execute(sql.SQL("SELECT count(*) FROM {}").format(
|
|
428
|
+
sql.Identifier(t.name) if t.quoted else sql.SQL(t.name)))
|
|
429
|
+
out[name] = int(cur.fetchone()[0])
|
|
430
|
+
return out
|
|
431
|
+
|
|
432
|
+
def freshness(self) -> tuple[datetime | None, datetime | None]:
|
|
433
|
+
"""(last write, newest data fetched from Jira)."""
|
|
434
|
+
with self.conn.cursor() as cur:
|
|
435
|
+
cur.execute("SELECT max(applied_at), max(fetched_at) FROM sqlmirror_issue_state")
|
|
436
|
+
row = cur.fetchone()
|
|
437
|
+
return row[0], row[1]
|
|
438
|
+
|
|
439
|
+
def sql_name(self, name: str) -> str:
|
|
440
|
+
t = TABLE_BY_NAME.get(name)
|
|
441
|
+
return f'"{name}"' if (t is not None and t.quoted) or name != name.lower() else name
|
|
442
|
+
|
|
443
|
+
def read_table(self, t: Table) -> list[tuple]:
|
|
444
|
+
ident = (lambda c: sql.Identifier(c)) if t.quoted else (lambda c: sql.SQL(c))
|
|
445
|
+
q = sql.SQL("SELECT {} FROM {}").format(sql.SQL(", ").join(ident(c) for c in t.column_names), ident(t.name))
|
|
446
|
+
with self.conn.cursor() as cur:
|
|
447
|
+
cur.execute(q)
|
|
448
|
+
return cur.fetchall()
|
|
449
|
+
|
|
450
|
+
def explain(self, sql_text: str) -> str:
|
|
451
|
+
""""ok" if the statement compiles against the mirror, else the error."""
|
|
452
|
+
try:
|
|
453
|
+
with self.conn.cursor() as cur:
|
|
454
|
+
cur.execute("EXPLAIN " + sql_text)
|
|
455
|
+
self.conn.rollback()
|
|
456
|
+
return "ok"
|
|
457
|
+
except psycopg.Error as exc:
|
|
458
|
+
self.conn.rollback()
|
|
459
|
+
return str(exc).strip().splitlines()[0]
|
|
460
|
+
|
|
461
|
+
def query(self, sql_text: str, params: tuple = (), timeout_s: int = 60) -> list[tuple]:
|
|
462
|
+
try:
|
|
463
|
+
with self.conn.cursor() as cur:
|
|
464
|
+
cur.execute(f"SET LOCAL statement_timeout = '{int(timeout_s)}s'")
|
|
465
|
+
cur.execute(sql_text, params or None)
|
|
466
|
+
rows = cur.fetchall() if cur.description else []
|
|
467
|
+
return rows
|
|
468
|
+
finally:
|
|
469
|
+
self.conn.rollback()
|