statcatlib 0.8.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.
@@ -0,0 +1,30 @@
1
+ """Row — typed dict returned by .select() projections."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class Row(dict[str, object]):
7
+ """A dict subclass returned by ``.select()`` projections.
8
+
9
+ Supports both dict-style and attribute-style field access::
10
+
11
+ row = store.query(BaseDataset).select("identifier", "status").first()
12
+ row["identifier"] # dict-style
13
+ row.identifier # attribute-style
14
+
15
+ ``Row`` is a plain ``dict`` subclass — ``isinstance(row, dict)`` is
16
+ ``True``, JSON serialisation works as normal, and all dict methods
17
+ (``keys()``, ``items()``, ``values()``, etc.) are inherited.
18
+ """
19
+
20
+ __slots__ = ()
21
+
22
+ def __getattr__(self, name: str) -> object:
23
+ try:
24
+ return self[name]
25
+ except KeyError:
26
+ msg = f"Row has no field {name!r}"
27
+ raise AttributeError(msg) from None
28
+
29
+ def __repr__(self) -> str:
30
+ return f"Row({dict.__repr__(self)})"
@@ -0,0 +1,403 @@
1
+ """DuckDB star schema for the Catalog — ``statcat`` namespace.
2
+
3
+ Single entry point: ``create_schema(conn)`` creates all catalog tables and
4
+ returns a cursor with ``search_path = 'statcat'``. Safe to call on an
5
+ existing database — every DDL statement uses ``IF NOT EXISTS``.
6
+
7
+ Multilingual columns
8
+ --------------------
9
+ All multilingual text fields use ``MAP(VARCHAR, VARCHAR)`` — no upfront
10
+ language configuration needed. Any language is queryable at any time
11
+ via ``element_at(title, 'en')`` or ``title['en']``.
12
+
13
+ Table groups
14
+ ------------
15
+ Spine dataset
16
+ Dimension agent, contact, frequency, licence, standard,
17
+ concept_scheme, concept
18
+ Bridge dataset_theme, dataset_subject, dataset_keyword,
19
+ dataset_spatial, dataset_standard, distribution,
20
+ data_service, service_dataset, quality_annotation,
21
+ provenance, variable, feature_type, author,
22
+ dimension_name, concept_relation,
23
+ concept_scheme_top_concept, catalog_theme_taxonomy
24
+ Lineage lineage
25
+ Meta _meta
26
+
27
+ SKOS identity model
28
+ -------------------
29
+ ``concept.uri`` is the storage PK used for external reference resolution.
30
+ The domain ``Concept`` type identifies concepts by ``notation`` within their
31
+ scheme (Pythonic, mirrors sdmxlib Code/Codelist pattern). URI is reconstructed
32
+ from ``{scheme.uri}/{concept.notation}`` at serialization time.
33
+
34
+ ``concept_relation.relation`` is VARCHAR (not an enum) so future XKOS
35
+ relation types (``xkos:generalizes``, ``xkos:isPartOf``, etc.) can be
36
+ added without schema changes.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ from typing import TYPE_CHECKING
42
+
43
+ if TYPE_CHECKING:
44
+ import duckdb
45
+
46
+ _SCHEMA_VERSION = "2.0"
47
+
48
+ # ── DDL ────────────────────────────────────────────────────────────────
49
+
50
+ _SCHEMA_DDL = """\
51
+ CREATE SCHEMA IF NOT EXISTS statcat;
52
+
53
+ -- ── Meta ──────────────────────────────────────────────────────────────
54
+ CREATE TABLE IF NOT EXISTS _meta (
55
+ key TEXT PRIMARY KEY,
56
+ value TEXT NOT NULL
57
+ );
58
+ INSERT OR IGNORE INTO _meta VALUES ('schema_version', '2.0');
59
+
60
+ -- ── Layer 1: Dataset spine ────────────────────────────────────────────
61
+ CREATE TABLE IF NOT EXISTS dataset (
62
+ identifier TEXT PRIMARY KEY,
63
+ kind TEXT NOT NULL,
64
+
65
+ -- Multilingual metadata
66
+ title MAP(VARCHAR, VARCHAR) NOT NULL,
67
+ description MAP(VARCHAR, VARCHAR) NOT NULL,
68
+ version_notes MAP(VARCHAR, VARCHAR),
69
+
70
+ -- Foreign keys to dimension tables
71
+ publisher_id TEXT,
72
+ creator_id TEXT,
73
+ contact_id TEXT,
74
+ frequency_id TEXT,
75
+ licence_id TEXT,
76
+
77
+ -- Temporal coverage (inline — one per dataset)
78
+ temporal_start TIMESTAMP,
79
+ temporal_end TIMESTAMP,
80
+
81
+ -- Core scalar fields
82
+ status TEXT,
83
+ access_rights TEXT,
84
+ issued TIMESTAMP,
85
+ modified TIMESTAMP,
86
+ landing_page TEXT,
87
+ version TEXT,
88
+ language TEXT[],
89
+
90
+ -- Aggregate (StatDCAT-AP) fields
91
+ num_series INTEGER,
92
+ sdmx_dataflow_urn TEXT,
93
+ measure_dim TEXT,
94
+
95
+ -- Microdata fields
96
+ sample_size INTEGER,
97
+ universe TEXT,
98
+ collection_method TEXT,
99
+ anonymization TEXT,
100
+ survey_id TEXT,
101
+
102
+ -- Geospatial fields
103
+ crs TEXT,
104
+ spatial_resolution TEXT,
105
+ scale TEXT,
106
+
107
+ -- Publication fields
108
+ doi TEXT,
109
+ isbn TEXT,
110
+ publication_type TEXT,
111
+ pages INTEGER,
112
+ series_title MAP(VARCHAR, VARCHAR),
113
+
114
+ -- SKOS concept citations (dcterms:type, URI ref to a Concept)
115
+ dataset_type TEXT,
116
+
117
+ -- Extensibility
118
+ extras MAP(VARCHAR, VARCHAR)
119
+ );
120
+
121
+ CREATE INDEX IF NOT EXISTS idx_ds_kind ON dataset(kind);
122
+ CREATE INDEX IF NOT EXISTS idx_ds_publisher ON dataset(publisher_id);
123
+ CREATE INDEX IF NOT EXISTS idx_ds_creator ON dataset(creator_id);
124
+ CREATE INDEX IF NOT EXISTS idx_ds_sdmx ON dataset(sdmx_dataflow_urn);
125
+ CREATE INDEX IF NOT EXISTS idx_ds_issued ON dataset(issued);
126
+
127
+ -- ── Layer 2: Dimension tables ─────────────────────────────────────────
128
+ CREATE TABLE IF NOT EXISTS agent (
129
+ id TEXT PRIMARY KEY,
130
+ name MAP(VARCHAR, VARCHAR),
131
+ type TEXT,
132
+ homepage TEXT
133
+ );
134
+
135
+ CREATE TABLE IF NOT EXISTS contact (
136
+ id TEXT PRIMARY KEY,
137
+ name MAP(VARCHAR, VARCHAR),
138
+ email TEXT,
139
+ phone TEXT,
140
+ url TEXT
141
+ );
142
+
143
+ CREATE TABLE IF NOT EXISTS frequency (
144
+ id TEXT PRIMARY KEY,
145
+ label MAP(VARCHAR, VARCHAR)
146
+ );
147
+
148
+ CREATE TABLE IF NOT EXISTS licence (
149
+ id TEXT PRIMARY KEY,
150
+ label MAP(VARCHAR, VARCHAR),
151
+ uri TEXT
152
+ );
153
+
154
+ CREATE TABLE IF NOT EXISTS standard (
155
+ id TEXT PRIMARY KEY,
156
+ label MAP(VARCHAR, VARCHAR),
157
+ uri TEXT
158
+ );
159
+
160
+ -- ── SKOS concept schemes and concepts ────────────────────────────────
161
+ -- concept.uri is the storage PK for external resolution.
162
+ -- Domain Concept uses notation as identity within its scheme.
163
+ CREATE TABLE IF NOT EXISTS concept_scheme (
164
+ uri VARCHAR PRIMARY KEY,
165
+ label MAP(VARCHAR, VARCHAR) NOT NULL,
166
+ definition MAP(VARCHAR, VARCHAR)
167
+ );
168
+
169
+ CREATE TABLE IF NOT EXISTS concept (
170
+ uri VARCHAR PRIMARY KEY,
171
+ in_scheme VARCHAR NOT NULL,
172
+ notation VARCHAR NOT NULL,
173
+ label MAP(VARCHAR, VARCHAR) NOT NULL,
174
+ alt_label MAP(VARCHAR, VARCHAR),
175
+ definition MAP(VARCHAR, VARCHAR)
176
+ );
177
+
178
+ CREATE INDEX IF NOT EXISTS idx_concept_in_scheme ON concept(in_scheme);
179
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_concept_notation ON concept(in_scheme, notation);
180
+
181
+ -- Concept hierarchy and associative relations.
182
+ -- relation is VARCHAR (not enum) to support future XKOS types without schema changes.
183
+ CREATE TABLE IF NOT EXISTS concept_relation (
184
+ source_uri VARCHAR NOT NULL,
185
+ target_uri VARCHAR NOT NULL,
186
+ relation VARCHAR NOT NULL,
187
+ PRIMARY KEY (source_uri, target_uri, relation)
188
+ );
189
+
190
+ -- Top-level concepts for a scheme (stored as notations, not URIs).
191
+ CREATE TABLE IF NOT EXISTS concept_scheme_top_concept (
192
+ scheme_uri VARCHAR NOT NULL,
193
+ notation VARCHAR NOT NULL,
194
+ PRIMARY KEY (scheme_uri, notation)
195
+ );
196
+
197
+ -- ── Layer 3: Bridge tables ────────────────────────────────────────────
198
+ -- Dataset → theme citations (concept URI only — labels resolved via LEFT JOIN)
199
+ CREATE TABLE IF NOT EXISTS dataset_theme (
200
+ dataset_id TEXT NOT NULL,
201
+ concept_uri TEXT NOT NULL,
202
+ PRIMARY KEY (dataset_id, concept_uri)
203
+ );
204
+
205
+ CREATE INDEX IF NOT EXISTS idx_dt_concept ON dataset_theme(concept_uri);
206
+
207
+ -- Dataset → subject citations
208
+ CREATE TABLE IF NOT EXISTS dataset_subject (
209
+ dataset_id TEXT NOT NULL,
210
+ concept_uri TEXT NOT NULL,
211
+ PRIMARY KEY (dataset_id, concept_uri)
212
+ );
213
+
214
+ CREATE TABLE IF NOT EXISTS dataset_keyword (
215
+ dataset_id TEXT NOT NULL,
216
+ keyword MAP(VARCHAR, VARCHAR),
217
+ ordinal INT NOT NULL,
218
+ PRIMARY KEY (dataset_id, ordinal)
219
+ );
220
+
221
+ CREATE TABLE IF NOT EXISTS dataset_spatial (
222
+ dataset_id TEXT NOT NULL,
223
+ label MAP(VARCHAR, VARCHAR),
224
+ uri TEXT,
225
+ bbox_west DOUBLE,
226
+ bbox_south DOUBLE,
227
+ bbox_east DOUBLE,
228
+ bbox_north DOUBLE,
229
+ ordinal INT NOT NULL,
230
+ PRIMARY KEY (dataset_id, ordinal)
231
+ );
232
+
233
+ CREATE TABLE IF NOT EXISTS dataset_standard (
234
+ dataset_id TEXT NOT NULL,
235
+ standard_id TEXT NOT NULL,
236
+ PRIMARY KEY (dataset_id, standard_id)
237
+ );
238
+
239
+ CREATE TABLE IF NOT EXISTS distribution (
240
+ id TEXT PRIMARY KEY,
241
+ dataset_id TEXT NOT NULL,
242
+ title MAP(VARCHAR, VARCHAR),
243
+ description MAP(VARCHAR, VARCHAR),
244
+ access_url TEXT,
245
+ download_url TEXT,
246
+ media_type TEXT,
247
+ format TEXT,
248
+ byte_size BIGINT,
249
+ issued TIMESTAMP,
250
+ modified TIMESTAMP,
251
+ licence_id TEXT,
252
+ service_id TEXT,
253
+ distribution_type TEXT
254
+ );
255
+
256
+ CREATE INDEX IF NOT EXISTS idx_dist_dataset ON distribution(dataset_id);
257
+ CREATE INDEX IF NOT EXISTS idx_dist_type ON distribution(media_type);
258
+
259
+ CREATE TABLE IF NOT EXISTS data_service (
260
+ id TEXT PRIMARY KEY,
261
+ title MAP(VARCHAR, VARCHAR),
262
+ description MAP(VARCHAR, VARCHAR),
263
+ endpoint_url TEXT
264
+ );
265
+
266
+ CREATE TABLE IF NOT EXISTS service_dataset (
267
+ service_id TEXT NOT NULL,
268
+ dataset_id TEXT NOT NULL,
269
+ PRIMARY KEY (service_id, dataset_id)
270
+ );
271
+
272
+ CREATE TABLE IF NOT EXISTS quality_annotation (
273
+ dataset_id TEXT NOT NULL,
274
+ ordinal INT NOT NULL,
275
+ dimension TEXT,
276
+ label MAP(VARCHAR, VARCHAR),
277
+ body MAP(VARCHAR, VARCHAR),
278
+ PRIMARY KEY (dataset_id, ordinal)
279
+ );
280
+
281
+ CREATE TABLE IF NOT EXISTS provenance (
282
+ dataset_id TEXT NOT NULL,
283
+ ordinal INT NOT NULL,
284
+ label MAP(VARCHAR, VARCHAR),
285
+ uri TEXT,
286
+ PRIMARY KEY (dataset_id, ordinal)
287
+ );
288
+
289
+ CREATE TABLE IF NOT EXISTS variable (
290
+ dataset_id TEXT NOT NULL,
291
+ variable_id TEXT NOT NULL,
292
+ label MAP(VARCHAR, VARCHAR),
293
+ description MAP(VARCHAR, VARCHAR),
294
+ value_type TEXT,
295
+ classification_id TEXT,
296
+ universe TEXT,
297
+ question_text TEXT,
298
+ valid_range TEXT,
299
+ derivation TEXT,
300
+ PRIMARY KEY (dataset_id, variable_id)
301
+ );
302
+
303
+ CREATE TABLE IF NOT EXISTS feature_type (
304
+ dataset_id TEXT NOT NULL,
305
+ type_name TEXT NOT NULL,
306
+ PRIMARY KEY (dataset_id, type_name)
307
+ );
308
+
309
+ CREATE TABLE IF NOT EXISTS author (
310
+ dataset_id TEXT NOT NULL,
311
+ ordinal INT NOT NULL,
312
+ name TEXT NOT NULL,
313
+ PRIMARY KEY (dataset_id, ordinal)
314
+ );
315
+
316
+ CREATE TABLE IF NOT EXISTS dimension_name (
317
+ dataset_id TEXT NOT NULL,
318
+ ordinal INT NOT NULL,
319
+ label MAP(VARCHAR, VARCHAR),
320
+ PRIMARY KEY (dataset_id, ordinal)
321
+ );
322
+
323
+ -- Catalog → theme taxonomy (dcat:themeTaxonomy — scheme-level declaration)
324
+ CREATE TABLE IF NOT EXISTS catalog_theme_taxonomy (
325
+ catalog_id VARCHAR NOT NULL,
326
+ scheme_uri VARCHAR NOT NULL,
327
+ PRIMARY KEY (catalog_id, scheme_uri)
328
+ );
329
+
330
+ -- ── Layer 4: Lineage & Provenance ─────────────────────────────────────
331
+ CREATE TABLE IF NOT EXISTS lineage (
332
+ id TEXT PRIMARY KEY,
333
+ source_id TEXT NOT NULL,
334
+ target_id TEXT NOT NULL,
335
+ relationship TEXT NOT NULL,
336
+ activity_label TEXT,
337
+ activity_type TEXT,
338
+ agent_id TEXT,
339
+ timestamp TIMESTAMP,
340
+ confidence TEXT,
341
+ notes TEXT,
342
+ UNIQUE (source_id, target_id, relationship)
343
+ );
344
+
345
+ CREATE INDEX IF NOT EXISTS idx_lineage_source ON lineage(source_id);
346
+ CREATE INDEX IF NOT EXISTS idx_lineage_target ON lineage(target_id);
347
+ """
348
+
349
+
350
+ def create_schema(conn: duckdb.DuckDBPyConnection) -> duckdb.DuckDBPyConnection:
351
+ """Create the ``statcat`` schema and all tables in *conn*.
352
+
353
+ Safe to call on an existing database — every ``CREATE TABLE`` uses
354
+ ``IF NOT EXISTS``.
355
+
356
+ Returns a **cursor** with ``search_path = 'statcat'`` so that
357
+ subsequent SQL can use unqualified table names.
358
+ """
359
+ cursor = conn.cursor()
360
+ cursor.execute("CREATE SCHEMA IF NOT EXISTS statcat")
361
+ cursor.execute("SET search_path = 'statcat'")
362
+
363
+ for raw_stmt in _SCHEMA_DDL.split(";"):
364
+ if clean := raw_stmt.strip():
365
+ cursor.execute(clean)
366
+
367
+ return cursor
368
+
369
+
370
+ def open_schema_readonly(conn: duckdb.DuckDBPyConnection) -> duckdb.DuckDBPyConnection:
371
+ """Open the ``statcat`` schema on a read-only connection.
372
+
373
+ No DDL is issued — the schema must already exist (built by a prior
374
+ read-write open). Returns a cursor with ``search_path = 'statcat'``.
375
+ Raises ``RuntimeError`` if the schema is missing.
376
+ """
377
+ if not schema_exists(conn):
378
+ msg = "read_only=True requires an existing statcat schema; none found in this DuckDB."
379
+ raise RuntimeError(msg)
380
+ cursor = conn.cursor()
381
+ cursor.execute("SET search_path = 'statcat'")
382
+ return cursor
383
+
384
+
385
+ def schema_exists(conn: duckdb.DuckDBPyConnection) -> bool:
386
+ """Return True if the statcat schema exists and has the _meta table."""
387
+ try:
388
+ row = conn.execute(
389
+ "SELECT 1 FROM information_schema.tables WHERE table_schema = 'statcat' AND table_name = '_meta'"
390
+ ).fetchone()
391
+ except Exception: # noqa: BLE001
392
+ return False
393
+ else:
394
+ return row is not None
395
+
396
+
397
+ def get_schema_version(conn: duckdb.DuckDBPyConnection) -> str | None:
398
+ """Return the schema version from ``statcat._meta``, or None."""
399
+ try:
400
+ row = conn.execute("SELECT value FROM statcat._meta WHERE key = 'schema_version'").fetchone()
401
+ return row[0] if row else None
402
+ except Exception: # noqa: BLE001
403
+ return None