Skip to content

Commit 676061b

Browse files
authored
Merge pull request #15 from nicholsn/feat/lokf-tables
Phase 2: lokf.tables — project a bundle to linked tables
2 parents cac9285 + 5e9f2f9 commit 676061b

8 files changed

Lines changed: 689 additions & 4 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ venv/
88
*.err
99
lokf.context.base.jsonld
1010

11+
# `lokf tables` default output directory
12+
lokf-tables/
13+
1114
# Regenerated by lokf-build (src/lokf/build.py); kept out of source control
1215
examples/*.bundle.json
1316
examples/*.bundle.yaml

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,14 @@ Metric(id="https://acme.example/knowledge/metrics/wau",
164164
type="Metric", title="Weekly Active Users", unit="users")
165165
```
166166

167+
`lokf tables` projects a whole bundle to linked tables — one per type plus a
168+
`relations` edge table (needs `lokf[tables]`):
169+
170+
```bash
171+
lokf tables examples/acme-knowledge --format parquet --output build/tables
172+
lokf tables examples/acme-knowledge --format bigquery --location gs://bucket/lokf
173+
```
174+
167175
## Status
168176

169177
LOKF v0.1 is a **draft profile** and is **not affiliated with or endorsed by

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ dependencies = [
4242
build = [
4343
"linkml>=1.8.5",
4444
]
45+
# Project a bundle to tables/DataFrames (`lokf tables`, lokf.tables). Optional:
46+
# only needed for the tabular writers, not for RDF/graph use.
47+
tables = [
48+
"pandas>=2.0",
49+
"pyarrow>=15",
50+
]
4551

4652
[project.urls]
4753
Documentation = "https://lokf.nolan-nichols.com/"
@@ -57,6 +63,7 @@ lokf-mcp = "lokf.mcp_server:main"
5763
dev = [
5864
"pytest>=8.0",
5965
"lokf[build]",
66+
"lokf[tables]",
6067
]
6168

6269
[build-system]

src/lokf/cli.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,57 @@ def convert(
6363
typer.echo(data, nl=False)
6464

6565

66+
# ---------------------------------------------------------------------------
67+
# tables
68+
# ---------------------------------------------------------------------------
69+
@app.command()
70+
def tables(
71+
source: Path = typer.Argument(
72+
..., exists=True, help="A bundle directory to project to tables."
73+
),
74+
format: str = typer.Option(
75+
"csv", "--format", "-f",
76+
help="csv | parquet | sqlite | bigquery | athena",
77+
),
78+
output: Path = typer.Option(
79+
Path("lokf-tables"), "--output", "-o",
80+
help="Output directory (or a .db path for sqlite).",
81+
),
82+
engine: str = typer.Option("pandas", help="DataFrame engine: pandas | polars."),
83+
location: str = typer.Option(
84+
"gs://your-bucket/lokf", "--location",
85+
help="Parquet location referenced by the bigquery/athena DDL.",
86+
),
87+
) -> None:
88+
"""Project a bundle to linked tables: one per concept type + a relations edge table.
89+
90+
csv/parquet/sqlite write the data; bigquery/athena write the Parquet and
91+
print CREATE EXTERNAL TABLE DDL that registers it as a lakehouse.
92+
"""
93+
from lokf import tables as t
94+
from lokf.model import load_bundle
95+
96+
try:
97+
frames = t.to_frames(load_bundle(source), engine=engine)
98+
except ModuleNotFoundError as exc:
99+
_err(str(exc))
100+
raise typer.Exit(1)
101+
102+
if format == "csv":
103+
typer.echo(f"wrote {t.write_csv(frames, output)}/")
104+
elif format == "parquet":
105+
typer.echo(f"wrote {t.write_parquet(frames, output)}/")
106+
elif format == "sqlite":
107+
typer.echo(f"wrote {t.to_sqlite(frames, output)}")
108+
elif format in ("bigquery", "athena"):
109+
t.write_parquet(frames, output)
110+
_err(f"# wrote Parquet to {output}/ ; register it with the DDL below")
111+
typer.echo(t.external_table_ddl(frames, format, location))
112+
else:
113+
_err(f"unknown format: {format} (csv | parquet | sqlite | bigquery | athena)")
114+
raise typer.Exit(2)
115+
116+
66117
# ---------------------------------------------------------------------------
67118
# query
68119
# ---------------------------------------------------------------------------

src/lokf/tables.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"""Project a LOKF bundle to a set of linked tables — the tabular counterpart to
2+
its RDF graph.
3+
4+
A bundle becomes **one table per concept type** (the nodes) plus a single
5+
``relations`` table (the typed edges: ``source``, ``predicate``, ``target``).
6+
From there the same well-modeled data is analysable as DataFrames, landable as
7+
CSV/Parquet, persistable as SQL, or registerable as a lakehouse via
8+
``CREATE EXTERNAL TABLE`` DDL for BigQuery or Athena.
9+
10+
pandas (and, optionally, polars / pyarrow) are only needed here, so they live in
11+
the ``tables`` extra::
12+
13+
pip install "lokf[tables]"
14+
"""
15+
from __future__ import annotations
16+
17+
import json
18+
import pathlib
19+
from typing import Any
20+
21+
from lokf.model import Bundle, load_bundle
22+
from lokf.schema import vocabulary
23+
24+
#: Name of the edge table that holds every typed relation in the bundle.
25+
RELATIONS_TABLE = "relations"
26+
27+
28+
def _make_frame(rows: list[dict], engine: str):
29+
"""Build a DataFrame of *rows* with the chosen engine (pandas | polars)."""
30+
if engine == "polars":
31+
try:
32+
import polars as pl
33+
except ModuleNotFoundError as exc: # pragma: no cover
34+
raise ModuleNotFoundError(
35+
"engine='polars' needs polars: pip install polars."
36+
) from exc
37+
return pl.DataFrame(rows)
38+
try:
39+
import pandas as pd
40+
except ModuleNotFoundError as exc: # pragma: no cover
41+
raise ModuleNotFoundError(
42+
"lokf.tables needs pandas (and pyarrow for Parquet). "
43+
"Install the extra: pip install 'lokf[tables]'."
44+
) from exc
45+
return pd.DataFrame(rows)
46+
47+
48+
def _scalarize(value: Any) -> Any:
49+
"""Flatten a non-relation frontmatter value into a single table cell."""
50+
if isinstance(value, list):
51+
return "; ".join(str(v) for v in value)
52+
if isinstance(value, dict):
53+
return json.dumps(value, ensure_ascii=False)
54+
return value
55+
56+
57+
def _split(doc: dict, rel_slots: set[str]) -> tuple[dict, list[dict]]:
58+
"""Split one concept into a (node row, edge rows) pair.
59+
60+
Concept-ranged relation slots (and reified ``relations``) become edges;
61+
everything else is a scalar column on the node's type table.
62+
"""
63+
source = doc.get("id")
64+
node: dict[str, Any] = {}
65+
edges: list[dict] = []
66+
for key, value in doc.items():
67+
if key in rel_slots:
68+
for target in value if isinstance(value, list) else [value]:
69+
edges.append({"source": source, "predicate": key, "target": target})
70+
elif key == "relations": # reified Relation objects
71+
for rel in value or []:
72+
edges.append({
73+
"source": source,
74+
"predicate": rel.get("predicate"),
75+
"target": rel.get("target"),
76+
})
77+
else:
78+
node[key] = _scalarize(value)
79+
return node, edges
80+
81+
82+
def to_frames(bundle: Bundle | str | pathlib.Path, engine: str = "pandas") -> dict:
83+
"""Project *bundle* to ``{type_name: nodes_frame, "relations": edges_frame}``.
84+
85+
*bundle* may be a loaded :class:`~lokf.model.Bundle` or a path to a bundle
86+
directory. One frame per concept ``type`` holds that type's scalar fields;
87+
the ``relations`` frame holds every typed edge as ``(source, predicate,
88+
target)``.
89+
"""
90+
if not isinstance(bundle, Bundle):
91+
bundle = load_bundle(bundle)
92+
rel_slots = set(vocabulary().relation_slots)
93+
by_type: dict[str, list[dict]] = {}
94+
edges: list[dict] = []
95+
for doc in bundle.docs():
96+
node, doc_edges = _split(doc, rel_slots)
97+
by_type.setdefault(str(doc.get("type", "Concept")), []).append(node)
98+
edges.extend(doc_edges)
99+
frames = {name: _make_frame(rows, engine) for name, rows in by_type.items()}
100+
frames[RELATIONS_TABLE] = _make_frame(edges, engine)
101+
return frames
102+
103+
104+
# ---------------------------------------------------------------------------
105+
# Writers
106+
# ---------------------------------------------------------------------------
107+
def _to_pandas(df):
108+
return df.to_pandas() if hasattr(df, "to_pandas") else df
109+
110+
111+
def write_csv(frames: dict, outdir) -> pathlib.Path:
112+
"""Write each frame to ``outdir/<name>.csv``."""
113+
out = pathlib.Path(outdir)
114+
out.mkdir(parents=True, exist_ok=True)
115+
for name, df in frames.items():
116+
if hasattr(df, "write_csv"): # polars
117+
df.write_csv(str(out / f"{name}.csv"))
118+
else:
119+
df.to_csv(out / f"{name}.csv", index=False)
120+
return out
121+
122+
123+
def write_parquet(frames: dict, outdir) -> pathlib.Path:
124+
"""Write each frame to ``outdir/<name>.parquet`` (needs pyarrow)."""
125+
out = pathlib.Path(outdir)
126+
out.mkdir(parents=True, exist_ok=True)
127+
for name, df in frames.items():
128+
if hasattr(df, "write_parquet"): # polars
129+
df.write_parquet(str(out / f"{name}.parquet"))
130+
else:
131+
df.to_parquet(out / f"{name}.parquet")
132+
return out
133+
134+
135+
def to_sqlite(frames: dict, path) -> pathlib.Path:
136+
"""Write every frame as a table in a SQLite database at *path*."""
137+
import sqlite3
138+
139+
path = pathlib.Path(path)
140+
con = sqlite3.connect(str(path))
141+
try:
142+
for name, df in frames.items():
143+
_to_pandas(df).to_sql(name, con, if_exists="replace", index=False)
144+
finally:
145+
con.close()
146+
return path
147+
148+
149+
# ---------------------------------------------------------------------------
150+
# External-table DDL (the lakehouse story)
151+
# ---------------------------------------------------------------------------
152+
_BQ_TYPE = {
153+
"object": "STRING", "string": "STRING", "str": "STRING",
154+
"int64": "INT64", "Int64": "INT64",
155+
"float64": "FLOAT64", "float": "FLOAT64",
156+
"bool": "BOOL", "boolean": "BOOL",
157+
"datetime64[ns]": "TIMESTAMP",
158+
}
159+
_ATHENA_TYPE = {
160+
"STRING": "string", "INT64": "bigint", "FLOAT64": "double",
161+
"BOOL": "boolean", "TIMESTAMP": "timestamp",
162+
}
163+
164+
165+
def _columns(df) -> list[tuple[str, str]]:
166+
"""(column, BigQuery type) pairs inferred from a frame's dtypes."""
167+
if hasattr(df, "dtypes") and hasattr(df.dtypes, "items"): # pandas
168+
items = [(c, str(t)) for c, t in df.dtypes.items()]
169+
else: # polars
170+
items = [(c, str(t).lower()) for c, t in df.schema.items()]
171+
return [(c, _BQ_TYPE.get(t, "STRING")) for c, t in items]
172+
173+
174+
def external_table_ddl(frames: dict, dialect: str = "bigquery",
175+
location: str = "gs://your-bucket/lokf",
176+
dataset: str = "lokf") -> str:
177+
"""``CREATE EXTERNAL TABLE`` DDL registering the bundle's Parquet as a lakehouse.
178+
179+
Pair with :func:`write_parquet` (land the files at *location*), then run this
180+
DDL to expose one external table per concept type + the relations table.
181+
"""
182+
base = location.rstrip("/")
183+
stmts = []
184+
for name, df in frames.items():
185+
cols = _columns(df)
186+
if dialect == "bigquery":
187+
defs = ",\n ".join(f"`{c}` {t}" for c, t in cols)
188+
stmts.append(
189+
f"CREATE OR REPLACE EXTERNAL TABLE `{dataset}.{name}` (\n {defs}\n)\n"
190+
f"OPTIONS (format = 'PARQUET', uris = ['{base}/{name}.parquet']);"
191+
)
192+
elif dialect == "athena":
193+
defs = ",\n ".join(f"`{c}` {_ATHENA_TYPE.get(t, 'string')}" for c, t in cols)
194+
stmts.append(
195+
f"CREATE EXTERNAL TABLE IF NOT EXISTS {name} (\n {defs}\n)\n"
196+
f"STORED AS PARQUET\nLOCATION '{base}/{name}/';"
197+
)
198+
else:
199+
raise ValueError(f"unknown dialect: {dialect!r} (use 'bigquery' or 'athena')")
200+
return "\n\n".join(stmts)

tests/test_tables.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""The tabular projection: a bundle -> one table per type + a relations edge table."""
2+
import pathlib
3+
4+
import pytest
5+
6+
pd = pytest.importorskip("pandas")
7+
8+
from lokf.tables import (
9+
RELATIONS_TABLE,
10+
external_table_ddl,
11+
to_frames,
12+
to_sqlite,
13+
write_csv,
14+
)
15+
16+
ROOT = pathlib.Path(__file__).parent.parent
17+
BUNDLE = ROOT / "examples" / "acme-knowledge"
18+
19+
20+
@pytest.fixture(scope="module")
21+
def frames():
22+
return to_frames(BUNDLE)
23+
24+
25+
def test_one_table_per_type_plus_relations(frames):
26+
# every concept type in the reference bundle becomes a node table
27+
for t in ["Metric", "Table", "Dataset", "GlossaryTerm", "Service", "Playbook"]:
28+
assert t in frames, t
29+
assert RELATIONS_TABLE in frames
30+
31+
32+
def test_scalar_columns_not_relations(frames):
33+
metric = frames["Metric"]
34+
assert {"id", "type", "title", "unit"}.issubset(metric.columns)
35+
assert (metric["title"] == "Weekly Active Users").any()
36+
# typed-relation slots are edges, never node columns
37+
assert "derivedFrom" not in metric.columns
38+
assert "dependsOn" not in metric.columns
39+
40+
41+
def test_relations_edges(frames):
42+
edges = frames[RELATIONS_TABLE]
43+
assert {"source", "predicate", "target"}.issubset(edges.columns)
44+
predicates = set(edges["predicate"])
45+
assert {"derivedFrom", "dependsOn", "measures"} & predicates
46+
# the WAU metric was derived from the user-events table
47+
derived = edges[edges["predicate"] == "derivedFrom"]
48+
assert derived["target"].str.contains("user-events").any()
49+
50+
51+
def test_list_field_is_scalarized(frames):
52+
# tags (a multivalued string slot) is joined, not exploded into edges
53+
metric = frames["Metric"]
54+
assert "tags" in metric.columns
55+
assert metric["tags"].str.contains(";").any()
56+
57+
58+
def test_write_csv(tmp_path, frames):
59+
out = write_csv(frames, tmp_path / "t")
60+
assert (out / "Metric.csv").exists()
61+
assert (out / f"{RELATIONS_TABLE}.csv").exists()
62+
63+
64+
def test_sqlite(tmp_path, frames):
65+
db = to_sqlite(frames, tmp_path / "lokf.db")
66+
import sqlite3
67+
68+
con = sqlite3.connect(str(db))
69+
n = con.execute(f"SELECT COUNT(*) FROM {RELATIONS_TABLE}").fetchone()[0]
70+
con.close()
71+
assert n > 0
72+
73+
74+
def test_external_table_ddl_bigquery(frames):
75+
ddl = external_table_ddl(frames, "bigquery", "gs://bucket/lokf")
76+
assert "CREATE OR REPLACE EXTERNAL TABLE `lokf.Metric`" in ddl
77+
assert "format = 'PARQUET'" in ddl
78+
assert "gs://bucket/lokf/relations.parquet" in ddl
79+
80+
81+
def test_external_table_ddl_athena(frames):
82+
ddl = external_table_ddl(frames, "athena", "s3://bucket/lokf")
83+
assert "CREATE EXTERNAL TABLE IF NOT EXISTS Metric" in ddl
84+
assert "STORED AS PARQUET" in ddl
85+
assert "LOCATION 's3://bucket/lokf/Metric/'" in ddl
86+
87+
88+
def test_unknown_dialect(frames):
89+
with pytest.raises(ValueError):
90+
external_table_ddl(frames, "duckdb")

0 commit comments

Comments
 (0)