hinge 0.1.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 (111) hide show
  1. hinge/__init__.py +3 -0
  2. hinge/config/__init__.py +0 -0
  3. hinge/config/logging_setup.py +63 -0
  4. hinge/config/settings.py +23 -0
  5. hinge/config/types.yaml +42 -0
  6. hinge/dbt/.user.yml +1 -0
  7. hinge/dbt/dbt_project.yml +18 -0
  8. hinge/dbt/macros/apply_time_window.sql +11 -0
  9. hinge/dbt/macros/assert_recipe_supported.sql +59 -0
  10. hinge/dbt/macros/build_incidence.sql +46 -0
  11. hinge/dbt/macros/collapse_path.sql +26 -0
  12. hinge/dbt/macros/filter_bots.sql +23 -0
  13. hinge/dbt/macros/network_edges.sql +39 -0
  14. hinge/dbt/macros/project_bipartite.sql +33 -0
  15. hinge/dbt/macros/slice_edges.sql +41 -0
  16. hinge/dbt/models/hin/hin_accounts.sql +14 -0
  17. hinge/dbt/models/hin/hin_artifacts.sql +23 -0
  18. hinge/dbt/models/hin/hin_capabilities.sql +42 -0
  19. hinge/dbt/models/hin/hin_edges.sql +57 -0
  20. hinge/dbt/models/hin/hin_nodes.sql +44 -0
  21. hinge/dbt/models/hin/hin_repositories.sql +15 -0
  22. hinge/dbt/models/intermediate/int_developer_repo_affiliation.sql +27 -0
  23. hinge/dbt/models/intermediate/int_user_artifact_incidence.sql +21 -0
  24. hinge/dbt/models/networks/artifact_reference.sql +27 -0
  25. hinge/dbt/models/networks/co_commit_user_user.sql +48 -0
  26. hinge/dbt/models/networks/co_edit_file_user_user.sql +48 -0
  27. hinge/dbt/models/networks/co_edit_line_user_user.sql +48 -0
  28. hinge/dbt/models/networks/dev_interaction.sql +98 -0
  29. hinge/dbt/models/networks/developer_repo_affiliation.sql +20 -0
  30. hinge/dbt/models/networks/follow_user_user.sql +27 -0
  31. hinge/dbt/models/networks/fork_repo_repo.sql +27 -0
  32. hinge/dbt/models/networks/issue_co_participation.sql +50 -0
  33. hinge/dbt/models/networks/issue_participation.sql +40 -0
  34. hinge/dbt/models/networks/pr_author_reviewer.sql +66 -0
  35. hinge/dbt/models/networks/pr_participation.sql +40 -0
  36. hinge/dbt/models/networks/pr_reviewer_coreview.sql +48 -0
  37. hinge/dbt/models/networks/repo_shared_contributors.sql +41 -0
  38. hinge/dbt/models/networks/star_user_repo.sql +27 -0
  39. hinge/dbt/models/networks/user_mention_user.sql +60 -0
  40. hinge/dbt/models/networks/watch_user_repo.sql +27 -0
  41. hinge/dbt/models/sources/sources.yml +63 -0
  42. hinge/dbt/models/staging/stg_accounts.sql +4 -0
  43. hinge/dbt/models/staging/stg_adapter_manifest.sql +4 -0
  44. hinge/dbt/models/staging/stg_artifacts.sql +4 -0
  45. hinge/dbt/models/staging/stg_relations.sql +4 -0
  46. hinge/dbt/models/staging/stg_repositories.sql +4 -0
  47. hinge/dbt/profiles.yml +9 -0
  48. hinge/dbt/seeds/ref_recipe_requirements.csv +35 -0
  49. hinge/frontends/__init__.py +0 -0
  50. hinge/frontends/cli/__init__.py +3 -0
  51. hinge/frontends/cli/app.py +189 -0
  52. hinge/frontends/lib.py +148 -0
  53. hinge/frontends/tui/__init__.py +7 -0
  54. hinge/frontends/web/__init__.py +6 -0
  55. hinge/kernel/__init__.py +0 -0
  56. hinge/kernel/projection/__init__.py +9 -0
  57. hinge/kernel/projection/projected_graph.py +32 -0
  58. hinge/kernel/projection/projection_spec.py +35 -0
  59. hinge/kernel/protocols/__init__.py +24 -0
  60. hinge/kernel/protocols/dataset_view.py +30 -0
  61. hinge/kernel/protocols/exporter_stage.py +24 -0
  62. hinge/kernel/protocols/projection_stage.py +36 -0
  63. hinge/kernel/protocols/reader_stage.py +52 -0
  64. hinge/kernel/protocols/store_stage.py +71 -0
  65. hinge/kernel/registry.py +71 -0
  66. hinge/kernel/runner.py +236 -0
  67. hinge/kernel/schema/__init__.py +12 -0
  68. hinge/kernel/schema/hin_schema.py +38 -0
  69. hinge/kernel/schema/schema_violation.py +21 -0
  70. hinge/kernel/schema/typed_edge.py +20 -0
  71. hinge/kernel/schema/typed_node.py +17 -0
  72. hinge/stages/__init__.py +0 -0
  73. hinge/stages/exporters/__init__.py +0 -0
  74. hinge/stages/exporters/csv_exporter.py +77 -0
  75. hinge/stages/exporters/dot_exporter.py +13 -0
  76. hinge/stages/exporters/gexf_exporter.py +13 -0
  77. hinge/stages/exporters/gml_exporter.py +79 -0
  78. hinge/stages/exporters/graphml_exporter.py +13 -0
  79. hinge/stages/exporters/jsonld_exporter.py +13 -0
  80. hinge/stages/exporters/networkx_exporter.py +57 -0
  81. hinge/stages/exporters/parquet_exporter.py +13 -0
  82. hinge/stages/projection/__init__.py +0 -0
  83. hinge/stages/projection/dbt_projection.py +342 -0
  84. hinge/stages/projection/specs/__init__.py +0 -0
  85. hinge/stages/projection/specs/artifact_reference.py +14 -0
  86. hinge/stages/projection/specs/co_commit_user_user.py +14 -0
  87. hinge/stages/projection/specs/co_edit_file_user_user.py +14 -0
  88. hinge/stages/projection/specs/co_edit_line_user_user.py +14 -0
  89. hinge/stages/projection/specs/dev_interaction.py +26 -0
  90. hinge/stages/projection/specs/developer_repo_affiliation.py +15 -0
  91. hinge/stages/projection/specs/follow_user_user.py +14 -0
  92. hinge/stages/projection/specs/fork_repo_repo.py +14 -0
  93. hinge/stages/projection/specs/issue_co_participation.py +14 -0
  94. hinge/stages/projection/specs/issue_participation.py +14 -0
  95. hinge/stages/projection/specs/pr_author_reviewer.py +14 -0
  96. hinge/stages/projection/specs/pr_participation.py +14 -0
  97. hinge/stages/projection/specs/pr_reviewer_coreview.py +14 -0
  98. hinge/stages/projection/specs/repo_shared_contributors.py +14 -0
  99. hinge/stages/projection/specs/star_user_repo.py +15 -0
  100. hinge/stages/projection/specs/user_mention_user.py +14 -0
  101. hinge/stages/projection/specs/watch_user_repo.py +14 -0
  102. hinge/stages/readers/__init__.py +0 -0
  103. hinge/stages/readers/_numfocus_contract_adapter.py +314 -0
  104. hinge/stages/readers/numfocus_reader.py +69 -0
  105. hinge/stages/store/__init__.py +0 -0
  106. hinge/stages/store/duckdb_store.py +466 -0
  107. hinge-0.1.0.dist-info/METADATA +309 -0
  108. hinge-0.1.0.dist-info/RECORD +111 -0
  109. hinge-0.1.0.dist-info/WHEEL +4 -0
  110. hinge-0.1.0.dist-info/entry_points.txt +35 -0
  111. hinge-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,69 @@
1
+ """Fast reader for the NumFocus flat GitHub activity event format.
2
+
3
+ Supports JSONL files that follow the schema:
4
+
5
+ {
6
+ "action": "OpenPullRequest",
7
+ "event_id": "...",
8
+ "date": "2022-01-01T00:14:19Z",
9
+ "actor": { "id": ..., "login": "..." },
10
+ "repository": { "id": ..., "name": "org/repo", "organisation": "..." },
11
+ "details": { ... }
12
+ }
13
+
14
+ The reader implements the ``BulkIngestReader`` protocol: instead of yielding
15
+ ``TypedNode`` / ``TypedEdge`` objects one row at a time, it asks the DuckDB
16
+ store to ingest the source file in a single set-based SQL pass. The actual
17
+ JSON-to-contract-table mapping lives in ``_numfocus_contract_adapter``.
18
+
19
+ For very different flat schemas, write a sibling reader rather than
20
+ subclassing — the adapter SQL is the meaningful surface to override, and
21
+ subclassing through a thin reader buys little.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from pathlib import Path
27
+
28
+ from hinge.kernel.protocols.reader_stage import ReaderDescriptor
29
+ from hinge.kernel.protocols.store_stage import StoreStage
30
+ from hinge.stages.readers import _numfocus_contract_adapter
31
+
32
+ _SUPPORTED_FORMATS = frozenset({"jsonl"})
33
+
34
+
35
+ class NumFocusReader:
36
+ """Bulk-ingest reader for NumFocus GitHub activity JSONL files."""
37
+
38
+ def __init__(self, path: str | Path, format: str | None = None) -> None:
39
+ self._path = Path(path)
40
+ self._format = format or self._infer_format(self._path)
41
+ if self._format not in _SUPPORTED_FORMATS:
42
+ raise ValueError(
43
+ f"Unsupported format {self._format!r}. Supported: {sorted(_SUPPORTED_FORMATS)}"
44
+ )
45
+
46
+ def describe(self) -> ReaderDescriptor:
47
+ return ReaderDescriptor(
48
+ dataset="numfocus-hin",
49
+ format=self._format,
50
+ path=self._path,
51
+ byte_size=self._path.stat().st_size,
52
+ )
53
+
54
+ def bulk_ingest(self, store: StoreStage, dataset_id: str) -> tuple[int, int, int]:
55
+ # The adapter speaks DuckDB SQL directly; reach into the underlying
56
+ # connection rather than indirecting through a generic store API that
57
+ # would only ever have one implementation.
58
+ conn = store._c() # type: ignore[attr-defined]
59
+ return _numfocus_contract_adapter.ingest(conn, dataset_id, self._path)
60
+
61
+ @staticmethod
62
+ def _infer_format(path: Path) -> str:
63
+ ext = path.suffix.lower().lstrip(".")
64
+ if ext in _SUPPORTED_FORMATS:
65
+ return ext
66
+ raise ValueError(
67
+ f"Cannot infer format from extension {path.suffix!r}. "
68
+ f"Pass format= explicitly. Supported: {sorted(_SUPPORTED_FORMATS)}"
69
+ )
File without changes
@@ -0,0 +1,466 @@
1
+ """DuckDB-backed implementation of StoreStage.
2
+
3
+ Tables
4
+ ------
5
+ meta key/value store for the storage-layout version.
6
+ datasets one row per ingest run — tracks dataset_id, reader, path, counts.
7
+ nodes typed nodes, partitioned by dataset_id.
8
+ edges typed edges, partitioned by dataset_id.
9
+
10
+ Each ingest run gets a unique dataset_id (UUID hex). Projections reference a
11
+ specific dataset_id so multiple datasets can coexist in the same file.
12
+
13
+ The class is a context manager. Open it in a ``with`` block — DuckDB's single-
14
+ writer lock is released on exit, which is what lets the dbt subprocess take
15
+ over the file during a projection run.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import logging
23
+ import re
24
+ from collections.abc import Iterable, Iterator
25
+ from dataclasses import dataclass
26
+ from datetime import UTC, datetime
27
+ from pathlib import Path
28
+ from types import TracebackType
29
+ from typing import Any
30
+
31
+ import duckdb
32
+
33
+ from hinge.config import settings
34
+ from hinge.kernel.protocols.store_stage import DatasetMeta, StoreCensus, StoreFingerprint
35
+ from hinge.kernel.schema.typed_edge import TypedEdge
36
+ from hinge.kernel.schema.typed_node import TypedNode
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ _STORAGE_VERSION = 2 # storage layout version (table shapes). Independent of HIN schema_version.
41
+ _UUID_HEX = re.compile(r"^[0-9a-f]{32}$")
42
+
43
+
44
+ def _scalar(row: tuple[Any, ...] | None) -> Any:
45
+ if row is None:
46
+ raise RuntimeError("expected one row from scalar query, got none")
47
+ return row[0]
48
+
49
+
50
+ class SchemaMismatchError(RuntimeError):
51
+ """Raised when an existing .duckdb file was written by a different storage version."""
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class DuckDbDatasetView:
56
+ """DatasetView returned by DuckDBStore. SQL-backed by definition.
57
+
58
+ The scoped ``active_*`` views are reused across runs; ``scope_to_dataset``
59
+ rewrites them to point at the requested dataset_id.
60
+ Concurrent projections against different datasets are therefore *not*
61
+ supported — sequential runs only.
62
+ """
63
+
64
+ dataset_id: str
65
+ db_path: Path
66
+ nodes_view: str = "active_nodes"
67
+ edges_view: str = "active_edges"
68
+
69
+ def close(self) -> None:
70
+ # Views are kept; the next scope_to_dataset overwrites them.
71
+ return None
72
+
73
+
74
+ class DuckDBStore:
75
+ def __init__(self, path: str | Path | None = None, read_only: bool = False) -> None:
76
+ self._path = Path(path) if path is not None else settings.store_path()
77
+ self._path.parent.mkdir(parents=True, exist_ok=True)
78
+ self._read_only = read_only
79
+ self._conn: duckdb.DuckDBPyConnection | None = None
80
+
81
+ # ---- context manager ----
82
+
83
+ def __enter__(self) -> DuckDBStore:
84
+ self._conn = duckdb.connect(str(self._path), read_only=self._read_only)
85
+ logger.debug("store opened — path=%s read_only=%s", self._path, self._read_only)
86
+ if not self._read_only:
87
+ self._init_schema()
88
+ return self
89
+
90
+ def __exit__(
91
+ self,
92
+ exc_type: type[BaseException] | None,
93
+ exc: BaseException | None,
94
+ tb: TracebackType | None,
95
+ ) -> None:
96
+ if self._conn is not None:
97
+ self._conn.close()
98
+ self._conn = None
99
+ logger.debug("store closed — path=%s", self._path)
100
+
101
+ # ---- ingest ----
102
+
103
+ def begin_dataset(self, dataset_id: str, reader: str, path: str) -> None:
104
+ self._require_dataset_id(dataset_id)
105
+ self._c().execute(
106
+ "INSERT INTO datasets (dataset_id, reader, path, ingested_at) VALUES (?, ?, ?, ?)",
107
+ [dataset_id, reader, path, datetime.now(UTC)],
108
+ )
109
+ logger.debug("dataset created — dataset_id=%s reader=%s", dataset_id, reader)
110
+
111
+ def finalise_dataset(self, dataset_id: str, node_count: int, edge_count: int) -> None:
112
+ self._c().execute(
113
+ "UPDATE datasets SET node_count = ?, edge_count = ? WHERE dataset_id = ?",
114
+ [node_count, edge_count, dataset_id],
115
+ )
116
+ logger.debug(
117
+ "dataset finalised — dataset_id=%s nodes=%d edges=%d",
118
+ dataset_id,
119
+ node_count,
120
+ edge_count,
121
+ )
122
+
123
+ def upsert(
124
+ self, dataset_id: str, nodes: Iterable[TypedNode], edges: Iterable[TypedEdge]
125
+ ) -> int:
126
+ self._require_dataset_id(dataset_id)
127
+ n_rows = [
128
+ (dataset_id, n.type, n.id, n.timestamp, json.dumps(n.attrs, default=str)) for n in nodes
129
+ ]
130
+ e_rows = [
131
+ (dataset_id, e.type, e.src_id, e.dst_id, e.timestamp, json.dumps(e.attrs, default=str))
132
+ for e in edges
133
+ ]
134
+ if n_rows:
135
+ self._c().executemany(
136
+ "INSERT OR REPLACE INTO nodes (dataset_id, type, id, ts, attrs) "
137
+ "VALUES (?, ?, ?, ?, ?)",
138
+ n_rows,
139
+ )
140
+ if e_rows:
141
+ self._c().executemany(
142
+ "INSERT INTO edges (dataset_id, type, src_id, dst_id, ts, attrs) "
143
+ "VALUES (?, ?, ?, ?, ?, ?)",
144
+ e_rows,
145
+ )
146
+ return len(n_rows) + len(e_rows)
147
+
148
+ def discard_dataset(self, dataset_id: str) -> None:
149
+ """Remove every trace of a dataset. Called by the runner on ingest failure."""
150
+ self._require_dataset_id(dataset_id)
151
+ c = self._c()
152
+ c.execute("DELETE FROM nodes WHERE dataset_id = ?", [dataset_id])
153
+ c.execute("DELETE FROM edges WHERE dataset_id = ?", [dataset_id])
154
+ c.execute("DELETE FROM contract_relations WHERE dataset_id = ?", [dataset_id])
155
+ c.execute("DELETE FROM contract_artifacts WHERE dataset_id = ?", [dataset_id])
156
+ c.execute("DELETE FROM contract_repositories WHERE dataset_id = ?", [dataset_id])
157
+ c.execute("DELETE FROM contract_accounts WHERE dataset_id = ?", [dataset_id])
158
+ c.execute("DELETE FROM contract_adapter_manifest WHERE adapter_run_id = ?", [dataset_id])
159
+ c.execute("DELETE FROM datasets WHERE dataset_id = ?", [dataset_id])
160
+ logger.warning("dataset discarded — dataset_id=%s", dataset_id)
161
+
162
+ # ---- read / project ----
163
+
164
+ def scope_to_dataset(self, dataset_id: str) -> DuckDbDatasetView:
165
+ """Create the dataset-scoped views the projection will read."""
166
+ self._require_dataset_id(dataset_id)
167
+ # dataset_id is validated UUID-hex so direct interpolation is safe;
168
+ # DuckDB doesn't accept ``?`` placeholders inside view definitions.
169
+ c = self._c()
170
+ # Prefer the canonical HIN views. Legacy row-by-row ingests still
171
+ # populate nodes/edges directly; contract adapters backfill them from
172
+ # contract tables. Either way projections see the same active_* shape.
173
+ c.execute(
174
+ f"CREATE OR REPLACE VIEW active_nodes AS "
175
+ f"SELECT type, id, ts, attrs FROM nodes WHERE dataset_id = '{dataset_id}'"
176
+ )
177
+ c.execute(
178
+ f"CREATE OR REPLACE VIEW active_edges AS "
179
+ f"SELECT type, src_id, dst_id, ts, attrs FROM edges WHERE dataset_id = '{dataset_id}'"
180
+ )
181
+ c.execute(
182
+ f"CREATE OR REPLACE VIEW active_contract_accounts AS "
183
+ f"SELECT * FROM contract_accounts WHERE dataset_id = '{dataset_id}'"
184
+ )
185
+ c.execute(
186
+ f"CREATE OR REPLACE VIEW active_contract_repositories AS "
187
+ f"SELECT * FROM contract_repositories WHERE dataset_id = '{dataset_id}'"
188
+ )
189
+ c.execute(
190
+ f"CREATE OR REPLACE VIEW active_contract_artifacts AS "
191
+ f"SELECT * FROM contract_artifacts WHERE dataset_id = '{dataset_id}'"
192
+ )
193
+ c.execute(
194
+ f"CREATE OR REPLACE VIEW active_contract_relations AS "
195
+ f"SELECT * FROM contract_relations WHERE dataset_id = '{dataset_id}'"
196
+ )
197
+ c.execute(
198
+ f"CREATE OR REPLACE VIEW active_contract_adapter_manifest AS "
199
+ f"SELECT * FROM contract_adapter_manifest WHERE adapter_run_id = '{dataset_id}'"
200
+ )
201
+ c.execute(
202
+ f"CREATE OR REPLACE VIEW active_hin_nodes AS "
203
+ f"SELECT * FROM _store_hin_nodes WHERE dataset_id = '{dataset_id}'"
204
+ )
205
+ c.execute(
206
+ f"CREATE OR REPLACE VIEW active_hin_edges AS "
207
+ f"SELECT * FROM _store_hin_edges WHERE dataset_id = '{dataset_id}'"
208
+ )
209
+ logger.debug("views scoped — dataset_id=%s", dataset_id)
210
+ return DuckDbDatasetView(dataset_id=dataset_id, db_path=self._path)
211
+
212
+ def query(self, dataset_id: str, node_types: list[str]) -> Iterator[TypedNode]:
213
+ self._require_dataset_id(dataset_id)
214
+ if node_types:
215
+ placeholders = ",".join("?" for _ in node_types)
216
+ cur = self._c().execute(
217
+ f"SELECT type, id, ts, attrs FROM nodes "
218
+ f"WHERE dataset_id = ? AND type IN ({placeholders})",
219
+ [dataset_id, *node_types],
220
+ )
221
+ else:
222
+ cur = self._c().execute(
223
+ "SELECT type, id, ts, attrs FROM nodes WHERE dataset_id = ?",
224
+ [dataset_id],
225
+ )
226
+ for type_, id_, ts, attrs in cur.fetchall():
227
+ yield TypedNode(
228
+ type=type_,
229
+ id=id_,
230
+ timestamp=ts,
231
+ attrs=json.loads(attrs) if attrs else {},
232
+ )
233
+
234
+ def census(self, dataset_id: str) -> StoreCensus:
235
+ self._require_dataset_id(dataset_id)
236
+ c = self._c()
237
+ node_rows = c.execute(
238
+ "SELECT type, COUNT(*) FROM nodes WHERE dataset_id = ? GROUP BY type",
239
+ [dataset_id],
240
+ ).fetchall()
241
+ edge_rows = c.execute(
242
+ "SELECT type, COUNT(*) FROM edges WHERE dataset_id = ? GROUP BY type",
243
+ [dataset_id],
244
+ ).fetchall()
245
+ return StoreCensus(
246
+ nodes_by_type={t: c for t, c in node_rows},
247
+ edges_by_type={t: c for t, c in edge_rows},
248
+ )
249
+
250
+ def fingerprint(self, dataset_id: str) -> StoreFingerprint:
251
+ """Content hash of exactly one dataset — not the whole file."""
252
+ self._require_dataset_id(dataset_id)
253
+ c = self._c()
254
+ n_count = _scalar(
255
+ c.execute("SELECT COUNT(*) FROM nodes WHERE dataset_id = ?", [dataset_id]).fetchone()
256
+ )
257
+ e_count = _scalar(
258
+ c.execute("SELECT COUNT(*) FROM edges WHERE dataset_id = ?", [dataset_id]).fetchone()
259
+ )
260
+ digest = _scalar(
261
+ c.execute(
262
+ "SELECT md5(string_agg(t, '|' ORDER BY t)) FROM ("
263
+ " SELECT type || ':' || id AS t FROM nodes WHERE dataset_id = ?"
264
+ " UNION ALL"
265
+ " SELECT type || ':' || src_id || '->' || dst_id AS t "
266
+ " FROM edges WHERE dataset_id = ?"
267
+ ")",
268
+ [dataset_id, dataset_id],
269
+ ).fetchone()
270
+ )
271
+ return StoreFingerprint(
272
+ dataset_id=dataset_id,
273
+ schema_version=_STORAGE_VERSION,
274
+ node_count=n_count,
275
+ edge_count=e_count,
276
+ content_hash=hashlib.sha256((digest or "").encode("utf-8")).hexdigest(),
277
+ )
278
+
279
+ def list_datasets(self) -> list[DatasetMeta]:
280
+ rows = (
281
+ self._c()
282
+ .execute(
283
+ "SELECT dataset_id, reader, path, ingested_at, node_count, edge_count "
284
+ "FROM datasets ORDER BY ingested_at DESC"
285
+ )
286
+ .fetchall()
287
+ )
288
+ return [
289
+ DatasetMeta(
290
+ dataset_id=r[0],
291
+ reader=r[1],
292
+ path=r[2],
293
+ ingested_at=r[3],
294
+ node_count=r[4],
295
+ edge_count=r[5],
296
+ )
297
+ for r in rows
298
+ ]
299
+
300
+ @property
301
+ def db_path(self) -> Path:
302
+ return self._path
303
+
304
+ # ---- private ----
305
+
306
+ def _c(self) -> duckdb.DuckDBPyConnection:
307
+ if self._conn is None:
308
+ raise RuntimeError(
309
+ "DuckDBStore is not open. Use it as a context manager: `with store:`."
310
+ )
311
+ return self._conn
312
+
313
+ @staticmethod
314
+ def _require_dataset_id(dataset_id: str) -> None:
315
+ if not _UUID_HEX.match(dataset_id):
316
+ raise ValueError(f"dataset_id {dataset_id!r} is not a 32-character lowercase UUID hex")
317
+
318
+ def _init_schema(self) -> None:
319
+ c = self._c()
320
+ c.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)")
321
+ c.execute(
322
+ "CREATE TABLE IF NOT EXISTS datasets ("
323
+ " dataset_id TEXT PRIMARY KEY,"
324
+ " reader TEXT NOT NULL,"
325
+ " path TEXT NOT NULL,"
326
+ " ingested_at TIMESTAMP NOT NULL,"
327
+ " node_count INTEGER,"
328
+ " edge_count INTEGER"
329
+ ")"
330
+ )
331
+ c.execute(
332
+ "CREATE TABLE IF NOT EXISTS nodes ("
333
+ " dataset_id TEXT NOT NULL,"
334
+ " type TEXT NOT NULL,"
335
+ " id TEXT NOT NULL,"
336
+ " ts TIMESTAMP,"
337
+ " attrs JSON,"
338
+ " PRIMARY KEY (dataset_id, type, id)"
339
+ ")"
340
+ )
341
+ c.execute(
342
+ "CREATE TABLE IF NOT EXISTS edges ("
343
+ " dataset_id TEXT NOT NULL,"
344
+ " type TEXT NOT NULL,"
345
+ " src_id TEXT NOT NULL,"
346
+ " dst_id TEXT NOT NULL,"
347
+ " ts TIMESTAMP,"
348
+ " attrs JSON"
349
+ ")"
350
+ )
351
+ self._init_contract_schema()
352
+ self._create_hin_views()
353
+ existing = c.execute("SELECT value FROM meta WHERE key = 'storage_version'").fetchone()
354
+ current = str(_STORAGE_VERSION)
355
+ if existing is None:
356
+ c.execute("INSERT INTO meta (key, value) VALUES ('storage_version', ?)", [current])
357
+ elif existing[0] != current:
358
+ raise SchemaMismatchError(
359
+ f"store at {self._path} was written with storage_version={existing[0]}, "
360
+ f"but current is {current}. Delete the file and re-ingest."
361
+ )
362
+
363
+ def _init_contract_schema(self) -> None:
364
+ c = self._c()
365
+ c.execute(
366
+ "CREATE TABLE IF NOT EXISTS contract_accounts ("
367
+ " dataset_id TEXT NOT NULL, account_key TEXT NOT NULL, platform TEXT,"
368
+ " github_id BIGINT, login TEXT, account_type TEXT, is_bot BOOLEAN,"
369
+ " bot_confidence DOUBLE, bot_source TEXT, created_at TIMESTAMP, updated_at TIMESTAMP,"
370
+ " profile_json JSON, observed_at TIMESTAMP, adapter_run_id TEXT,"
371
+ " PRIMARY KEY (dataset_id, account_key)"
372
+ ")"
373
+ )
374
+ c.execute(
375
+ "CREATE TABLE IF NOT EXISTS contract_repositories ("
376
+ " dataset_id TEXT NOT NULL, repo_key TEXT NOT NULL, platform TEXT, github_id BIGINT,"
377
+ " full_name TEXT, owner_account_key TEXT, name TEXT, description TEXT,"
378
+ " primary_language TEXT, is_fork BOOLEAN, forked_from_repo_key TEXT,"
379
+ " default_branch TEXT, created_at TIMESTAMP, pushed_at TIMESTAMP, updated_at TIMESTAMP,"
380
+ " archived_at TIMESTAMP, deleted_at TIMESTAMP, repo_json JSON, observed_at TIMESTAMP,"
381
+ " adapter_run_id TEXT, PRIMARY KEY (dataset_id, repo_key)"
382
+ ")"
383
+ )
384
+ c.execute(
385
+ "CREATE TABLE IF NOT EXISTS contract_artifacts ("
386
+ " dataset_id TEXT NOT NULL, artifact_key TEXT NOT NULL, platform TEXT, artifact_type TEXT,"
387
+ " repo_key TEXT, parent_artifact_key TEXT, github_id BIGINT, node_id TEXT, number INTEGER,"
388
+ " sha TEXT, url TEXT, title TEXT, state TEXT, body_text TEXT, body_text_hash TEXT,"
389
+ " file_path TEXT, old_file_path TEXT, start_line INTEGER, end_line INTEGER,"
390
+ " created_at TIMESTAMP, updated_at TIMESTAMP, closed_at TIMESTAMP, merged_at TIMESTAMP,"
391
+ " artifact_json JSON, observed_at TIMESTAMP, adapter_run_id TEXT,"
392
+ " PRIMARY KEY (dataset_id, artifact_key)"
393
+ ")"
394
+ )
395
+ c.execute(
396
+ "CREATE TABLE IF NOT EXISTS contract_relations ("
397
+ " dataset_id TEXT NOT NULL, relation_key TEXT NOT NULL,"
398
+ " source_node_key TEXT NOT NULL, source_node_type TEXT NOT NULL,"
399
+ " target_node_key TEXT NOT NULL, target_node_type TEXT NOT NULL,"
400
+ " relation_type TEXT NOT NULL, relation_subtype TEXT, directed BOOLEAN DEFAULT TRUE,"
401
+ " occurred_at TIMESTAMP, observed_at TIMESTAMP, valid_from TIMESTAMP, valid_to TIMESTAMP,"
402
+ " event_count INTEGER DEFAULT 1, weight DOUBLE DEFAULT 1.0, source_record_id TEXT,"
403
+ " source_table TEXT, adapter_run_id TEXT, properties JSON,"
404
+ " PRIMARY KEY (dataset_id, relation_key)"
405
+ ")"
406
+ )
407
+ c.execute(
408
+ "CREATE TABLE IF NOT EXISTS contract_adapter_manifest ("
409
+ " adapter_run_id TEXT PRIMARY KEY, adapter_name TEXT, adapter_version TEXT, source_name TEXT,"
410
+ " extracted_at TIMESTAMP, has_accounts BOOLEAN, has_repositories BOOLEAN, has_commits BOOLEAN,"
411
+ " has_file_touches BOOLEAN, has_line_touches BOOLEAN, has_pull_requests BOOLEAN,"
412
+ " has_pr_reviews BOOLEAN, has_issues BOOLEAN, has_comments BOOLEAN, has_stars BOOLEAN,"
413
+ " has_watches BOOLEAN, has_forks BOOLEAN, has_follows BOOLEAN, has_mentions BOOLEAN,"
414
+ " has_artifact_refs BOOLEAN, coverage_start TIMESTAMP, coverage_end TIMESTAMP,"
415
+ " focal_repo_keys TEXT[], notes TEXT"
416
+ ")"
417
+ )
418
+
419
+ def _create_hin_views(self) -> None:
420
+ c = self._c()
421
+ self._drop_legacy_hin_view_if_present("hin_nodes")
422
+ self._drop_legacy_hin_view_if_present("hin_edges")
423
+ c.execute(
424
+ "CREATE OR REPLACE VIEW _store_hin_nodes AS "
425
+ "SELECT dataset_id, account_key AS node_id, 'user' AS node_type, account_type AS node_subtype, "
426
+ " account_key AS natural_key, login AS display_name, created_at, updated_at, observed_at, "
427
+ " FALSE AS is_stub, profile_json AS properties "
428
+ "FROM contract_accounts "
429
+ "UNION ALL "
430
+ "SELECT dataset_id, repo_key, 'repo', CASE WHEN is_fork THEN 'fork' ELSE 'repository' END, "
431
+ " full_name, full_name, created_at, updated_at, observed_at, FALSE, repo_json "
432
+ "FROM contract_repositories "
433
+ "UNION ALL "
434
+ "SELECT dataset_id, artifact_key, 'artifact', artifact_type, artifact_key, "
435
+ " coalesce(title, artifact_key), created_at, updated_at, observed_at, FALSE, artifact_json "
436
+ "FROM contract_artifacts"
437
+ )
438
+ c.execute(
439
+ "CREATE OR REPLACE VIEW _store_hin_edges AS "
440
+ "SELECT r.dataset_id, r.relation_key AS edge_id, "
441
+ " r.source_node_key AS source_node_id, "
442
+ " CASE WHEN r.source_node_type = 'account' THEN 'user' ELSE r.source_node_type END AS source_node_type, "
443
+ " sn.node_subtype AS source_node_subtype, "
444
+ " r.target_node_key AS target_node_id, "
445
+ " CASE WHEN r.target_node_type = 'account' THEN 'user' ELSE r.target_node_type END AS target_node_type, "
446
+ " tn.node_subtype AS target_node_subtype, "
447
+ " r.relation_type AS edge_type, r.relation_subtype, r.directed, "
448
+ " r.occurred_at, r.observed_at, r.valid_from, r.valid_to, "
449
+ " r.event_count, r.weight, r.adapter_run_id, r.source_record_id, r.properties "
450
+ "FROM contract_relations r "
451
+ "LEFT JOIN _store_hin_nodes sn ON sn.dataset_id = r.dataset_id AND sn.node_id = r.source_node_key "
452
+ "LEFT JOIN _store_hin_nodes tn ON tn.dataset_id = r.dataset_id AND tn.node_id = r.target_node_key"
453
+ )
454
+
455
+ def _drop_legacy_hin_view_if_present(self, name: str) -> None:
456
+ row = (
457
+ self._c()
458
+ .execute(
459
+ "SELECT table_type FROM information_schema.tables "
460
+ "WHERE table_schema = 'main' AND table_name = ?",
461
+ [name],
462
+ )
463
+ .fetchone()
464
+ )
465
+ if row is not None and row[0] == "VIEW":
466
+ self._c().execute(f"DROP VIEW {name}")