Data layer — Catalog implementation design¶
Concrete backend / frontend / skill design for the physical (Catalog) half of the data layer: data-source layer tags, catalog fields, file ingest, profile / schema, row editing. The semantic half (fields, relationships, metrics, queries) lives in the semantic model spec; the staged work is the refactoring plan.
Reused primitives: app/services/ontology/dataset_tables.py (pg_engine_for_datasource, is_valid_identifier, fetch_dataset_rows), app/services/storage.py (upload_object/get_object), app/services/credentials/credential_encryption.decrypt, procrastinate jobs, chunked_upload.py (documents already do chunked upload).
0. Permission model¶
New Suite-app permission keys (added to permission_catalog.py):
| Key | Grants |
|---|---|
data:read |
view catalog, schema, profile, quality results, and rows; default for all roles (further constrained by dataset ACL) |
data:write |
register datasets, ingest files, manage joins/metrics/quality rules |
console:datasets stays console-scoped. The Data-layer Suite apps gate on data:read/data:write, not on console permissions.
1. Data source layer tag¶
Model¶
data_sources gains one nullable column:
# app/models/data_source.py
layer: Mapped[str | None] = mapped_column(String(16), nullable=True) # "data" | "ontology" | None
None= unassigned (legacy rows stay unchanged;kindstill drives existing behavior).data= backs datasets and file ingest (PostgreSQL).ontology= backs the graph (Neo4j).
Migration¶
Alembic autogenerate: ALTER TABLE data_sources ADD COLUMN layer VARCHAR(16). No backfill.
API¶
schemas/data_source.py:DataSourceCreate.layer,DataSourceUpdate.layer,DataSourceResponse.layer(all optional).api/data_sources.py: create/update already dobody.model_dump(exclude_unset=True); just add the field to the schema and it flows through.
Console¶
dataSourcesApi.ts: addlayer?: string | nullto the type and payloads.ConsoleDataSources.tsx: add a Layer select (未指定 / 数据层 / 本体层) in the create/edit form; show a layer badge in the list.
2. Dataset catalog fields¶
Model¶
datasets gains catalog columns:
| Column | Type | Notes |
|---|---|---|
description |
Text nullable |
human description |
owner_sub |
String(512) nullable |
JWT sub; display name resolved like created_by_name |
domain |
String(128) nullable |
e.g. finance, market |
tags |
JSONB default [] |
array of strings |
business_definition |
Text nullable |
what the data means in business terms |
sensitivity |
String(32) nullable |
public / internal / confidential / restricted |
API / frontend¶
schemas/dataset.py: extendDatasetCreate/DatasetUpdate/DatasetResponse.datasetsApi.ts+DatasetDetailPage.tsx: add a Catalog panel (description, owner, domain, tags, definition, sensitivity) alongside the existing columns/rows/usage tabs.
2.5 Register existing tables (non-file path)¶
Reuses existing endpoints — no new backend needed:
GET /api/datasets/from-source/{data_source_id}lists tables in a PostgreSQL source.POST /api/datasetsregisters one or more selected tables asDatasetrows.
The Datasets app adds the 「Add tables」 flow (see interaction design); the skill adds datasets discover (below).
3. Relationships → ER model¶
Spec + migration: semantic model · refactoring plan (Stage 1, ✅ shipped). dataset_joins re-modelled (name kept): FK from (many) / to (one) + from_columns/to_columns + required; cardinality dropped.
ER rendering (frontend): react-force-graph-2d (already a dependency) — nodes = datasets, directed edges = relationships (many → one), click a node → open the dataset.
4. File ingest pipeline¶
"Access" path that turns an uploaded file into a PostgreSQL table on a data-layer data source.
Data model — new table dataset_ingests¶
| Column | Type | Notes |
|---|---|---|
id |
String(64) PK |
uuid4 |
data_source_id |
FK → data_sources.id |
must be layer = 'data' |
dataset_id |
FK → datasets.id nullable |
set after load |
storage_key |
Text |
object-storage key of the raw file |
filename |
String(512) |
original name |
status |
String(32) |
uploaded → parsing → preview_ready → loading → completed / failed |
schema_name / table_name |
String(128/256) nullable |
target table |
parse_config |
JSONB |
header row, delimiter, sheet, column type overrides |
row_count |
Integer nullable |
loaded rows |
error_message |
Text nullable |
|
created_by / created_by_name |
as elsewhere | |
created_at / updated_at |
timestamps |
Flow (three steps)¶
1) Upload — chunked (reuse chunked_upload.py pattern), stored under datasets/uploads/{ingest_id}/{filename}. Returns ingest_id.
2) Preview — parse head of the file, infer schema, return sample.
- CSV:
pandas.read_csv(nrows=1000, engine="c"); Excel:read_excel(nrows=1000); JSON:read_json(records). Parquet deferred. - Infer per column: pandas dtype → PostgreSQL type (see map below) + nullability from the sample.
- Response:
{ columns: [{name, pg_type, sample_values, null_ratio_in_sample}], total_rows_estimate, warnings }. - Store the chosen
parse_config(header/delimiter/sheet/overrides) so load is reproducible.
3) Load — user confirms target (schema_name, table_name, overwrite vs append) and corrected types.
- Validate
schema_name/table_name/ every column viais_valid_identifier. - Target source must be
layer = 'data'andkind = 'postgresql'. - Create table if new:
CREATE TABLE "schema"."table" ("col" TYPE, …). - Append if existing: verify column intersection; reject on mismatch.
- Bulk-write with
COPY(SQLAlchemycopy_expert) for speed — not row-by-rowINSERT. - Register a
Datasetrow (data_source_id,schema_name,table_name,display_name= filename stem) and linkdataset_ingests.dataset_id. - Run as a procrastinate job (
task_name="dataset_ingest_load") so large files don't block the request; status polled viaGET /api/dataset-ingests/{id}.
pandas → PostgreSQL type map¶
| pandas dtype | PostgreSQL |
|---|---|
int64 |
BIGINT |
float64 |
DOUBLE PRECISION |
bool |
BOOLEAN |
datetime64 / object parsed as date |
TIMESTAMP / DATE |
object (strings) |
TEXT |
User can override any column type at the preview step (stored in parse_config).
API¶
POST /api/dataset-ingests(start upload) ·POST /api/dataset-ingests/{id}/preview·POST /api/dataset-ingests/{id}/load·GET /api/dataset-ingests/{id}.- Permission:
PERM_CONSOLE_DATASETS(data engineer) for ingest; read of the resulting dataset follows normal dataset ACL.
Object-storage keys¶
datasets/uploads/{ingest_id}/{filename} — raw file kept for audit/rebuild; not deleted on load.
5. Profile, schema snapshot (read-time)¶
Profile — table dataset_profiles¶
| Column | Type |
|---|---|
id / dataset_id FK |
uuid / FK CASCADE, unique per dataset |
row_count |
Integer |
column_stats |
JSONB — per column: {null_rate, distinct_count, min, max, quantiles?} |
computed_at |
timestamptz |
Computed on demand (POST /api/datasets/{id}/profile) against the source table using COUNT(*), COUNT(col), COUNT(DISTINCT col), MIN/MAX. v1: full-table scan for small/medium tables; add LIMIT-based sampling later. Results cached in dataset_profiles until invalidated (schema change or manual refresh).
Schema snapshot — table schema_snapshots¶
| Column | Type |
|---|---|
id / dataset_id FK |
uuid / FK CASCADE |
columns |
JSONB — snapshot of ColumnMetadata[] |
captured_at |
timestamptz |
On each GET /api/datasets/{id}/metadata, diff against the latest snapshot; if changed, append a snapshot. Change detection = compare column_name + data_type sets. Expose GET /api/datasets/{id}/schema-history for the UI.
Lineage — removed (nothing to track yet)¶
There was a read-time GET /api/datasets/{id}/lineage: the backing data source as "upstream",
object/link types that reference the dataset plus joined datasets as "downstream"/lateral. It
could not answer a lineage question — no ingest, pipeline or connector run records how rows
arrived (dataset_ingests is upload history, not provenance) — and what it did show repeated the
Usage and Relationships tabs. Removed in favour of those; real lineage needs provenance
first (it would then be a graph, not three lists).
6. openkms-skill extensions¶
New datasets subcommands (all JSON-print, matching existing style):
| Command | Maps to |
|---|---|
datasets profile --id ID |
POST /api/datasets/{id}/profile |
datasets schema-history --id ID |
GET /api/datasets/{id}/schema-history |
datasets joins list --id ID |
GET /api/dataset-joins?dataset_id=ID |
datasets joins add --from-id A --to-id B --join-type left --columns "ts_code=ts_code" --yes |
POST /api/dataset-joins |
datasets joins delete --join-id JOIN_ID --yes |
DELETE /api/dataset-joins/{id} |
datasets discover --data-source-id ID |
GET /api/datasets/from-source/{id} (list tables to register) |
datasets ingest --file x.csv --data-source-id ID --schema imports --table x [--append] --yes |
POST …/ingests + preview + load |
joins add/ingest are write commands → --dry-run / -y gating (reuse add_write_flags / confirm_or_abort).
7. API surface summary (new)¶
| Method & path | Purpose |
|---|---|
POST /api/dataset-ingests |
start file ingest |
POST /api/dataset-ingests/{id}/preview |
parse + schema inference |
POST /api/dataset-ingests/{id}/load |
create/append table + register Dataset |
GET /api/dataset-ingests/{id} |
ingest status |
GET /api/datasets/{id}/profile (POST refresh) |
compute/read profile |
GET /api/datasets/{id}/schema-history |
schema snapshots |
POST/GET/PUT/DELETE /api/datasets/joins… |
join/ER CRUD |
8. Migration checklist (Phase 0–1)¶
data_sources.layer(nullableVARCHAR(16))datasetscatalog columns (description,owner_sub,domain,tagsJSONB,business_definition,sensitivity)dataset_joins(new)dataset_ingests(new)dataset_profiles(new)schema_snapshots(new)
(Phase 2 tables — quality_*, metric_definitions; Phase 3 — dashboards — are listed in the plan but not migrated yet.)