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.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()