File size: 7,835 Bytes
3743cfe 6bff5d9 3743cfe 6bff5d9 3743cfe 6bff5d9 49b0848 6bff5d9 3743cfe 6bff5d9 3743cfe 49b0848 6bff5d9 fd4865b b9dfc76 fd4865b b9dfc76 fd4865b b9dfc76 fd4865b b9dfc76 fd4865b b9dfc76 49b0848 b9dfc76 6bff5d9 3743cfe 6bff5d9 3743cfe 6bff5d9 3743cfe 6bff5d9 3743cfe 6bff5d9 3743cfe 6bff5d9 3743cfe 6bff5d9 b9dfc76 6bff5d9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 | """CatalogStore — reads the per-user catalog from the dedorch `data_catalog` table.
Storage shape (Go-owned): one row per scope in `data_catalog`
(id, scope_type, user_id, analysis_id, catalog_payload jsonb, schema_version,
generated_at, updated_at). Python reads the user-scoped row (scope_type='user');
Go's `catalog.Service` owns all writes, so `upsert`/`remove_source` are legacy.
"""
from sqlalchemy import case, delete, func, select
from sqlalchemy.dialects.postgresql import insert
from src.db.postgres.connection import AsyncSessionLocal
from src.db.postgres.models import Catalog as CatalogRow
from src.middlewares.logging import get_logger
from .fk_inference import infer_foreign_keys
from .models import Catalog
from .sample_decode import decode_sample_values
logger = get_logger("catalog_store")
class CatalogStore:
"""Read/write catalogs keyed by user_id.
Each method opens its own AsyncSession. Callers needing transactional
coordination across multiple stores can be refactored to accept an
explicit AsyncSession in a later PR.
"""
async def get(self, user_id: str) -> Catalog | None:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(CatalogRow.catalog_payload).where(
CatalogRow.user_id == user_id,
CatalogRow.scope_type == "user",
)
)
row = result.scalar_one_or_none()
if row is None:
return None
# dedorch catalogs ship no foreign_keys (Go introspection drops them),
# but the IR validator only allows FK-backed joins. Infer the obvious
# edges so the planner and validator agree. No-op once Go emits real FKs.
catalog = infer_foreign_keys(Catalog.model_validate(row))
# dedorch also JSON-marshals numeric sample bytes as base64 (Go bug) —
# decode them so the planner sees value ranges, not gibberish.
# No-op once Go emits plain numeric samples.
decode_sample_values(catalog)
return catalog
async def get_by_analysis(
self, analysis_id: str, user_id: str | None = None
) -> Catalog | None:
"""Read the `scope_type='analysis'` catalog row for an analysis.
Distinct from `get()` (which reads the user-scope row): the analysis-scope
payload carries the sources actually bound to this analysis AND their
real names (a database is named e.g. "xl test" here, vs the auto-generated
`postgres_<hash>` placeholder in the user-scope row). Returns None when the
analysis has no catalog row (legacy / not yet bound), so callers fall back
to the user-scope catalog.
**Tenant scoping (2026-07-23).** When `user_id` is supplied the row must be
owned by that user. Go enforces exactly this pair on every equivalent read
(`catalog_repo.go`: `WHERE scope_type='analysis' AND analysis_id=$1 AND
user_id=$2`); Python filtered on `analysis_id` alone, so a caller who knew
another tenant's `analysis_id` received that tenant's catalog — and, because
the payload also carries the owner's `user_id`, `DbExecutor`'s ownership
check then compared the victim's id against itself and passed, executing SQL
against their database.
`user_id` is optional so the legacy/unthreaded call sites keep working, but
an unscoped read is logged: those call sites are the remaining work.
"""
async with AsyncSessionLocal() as session:
where = [
CatalogRow.analysis_id == analysis_id,
CatalogRow.scope_type == "analysis",
]
if user_id is not None:
where.append(CatalogRow.user_id == user_id)
result = await session.execute(
select(CatalogRow.catalog_payload).where(*where)
)
row = result.scalar_one_or_none()
if row is None and user_id is not None:
# Diagnose the miss: an owned row that we just refused is either a
# genuine cross-tenant attempt or a `user_id` format mismatch between
# what Go wrote and what the caller sent. Both need to be loud; the
# answer is the same either way (deny).
probe = await session.execute(
select(CatalogRow.user_id).where(
CatalogRow.analysis_id == analysis_id,
CatalogRow.scope_type == "analysis",
)
)
owner = probe.scalar_one_or_none()
if owner is not None:
logger.error(
"analysis catalog owner mismatch — denied",
analysis_id=analysis_id,
requested_by=user_id,
owner=owner,
)
if user_id is None:
logger.warning(
"analysis catalog read is UNSCOPED (no user_id) — call site needs threading",
analysis_id=analysis_id,
)
if row is None:
return None
catalog = infer_foreign_keys(Catalog.model_validate(row))
decode_sample_values(catalog)
return catalog
async def upsert(self, catalog: Catalog) -> None:
# Legacy: Go's catalog.Service owns catalog writes now. Kept working (and
# reconciled to the dedorch shape) but no longer on any live Python path.
payload = catalog.model_dump(mode="json")
async with AsyncSessionLocal() as session:
stmt = insert(CatalogRow).values(
scope_type="user",
user_id=catalog.user_id,
catalog_payload=payload,
schema_version=catalog.schema_version,
generated_at=catalog.generated_at,
updated_at=func.now(),
)
stmt = stmt.on_conflict_do_update(
index_elements=[CatalogRow.user_id],
index_where=CatalogRow.scope_type == "user",
set_={
"catalog_payload": stmt.excluded.catalog_payload,
"schema_version": stmt.excluded.schema_version,
"updated_at": case(
(
stmt.excluded.catalog_payload != CatalogRow.catalog_payload,
func.now(),
),
else_=CatalogRow.updated_at,
),
},
)
await session.execute(stmt)
await session.commit()
logger.info(
"catalog upserted",
user_id=catalog.user_id,
sources=len(catalog.sources),
)
async def remove_source(self, user_id: str, source_id: str) -> None:
existing = await self.get(user_id)
if existing is None:
logger.info("remove_source: no catalog found", user_id=user_id, source_id=source_id)
return
filtered = [s for s in existing.sources if s.source_id != source_id]
if len(filtered) == len(existing.sources):
logger.info(
"remove_source: source not in catalog", user_id=user_id, source_id=source_id
)
return
await self.upsert(existing.model_copy(update={"sources": filtered}))
logger.info("remove_source: source removed", user_id=user_id, source_id=source_id)
async def delete(self, user_id: str) -> None:
async with AsyncSessionLocal() as session:
await session.execute(delete(CatalogRow).where(CatalogRow.user_id == user_id))
await session.commit()
logger.info("catalog deleted", user_id=user_id)
|