"""L1 owner metadata assembly for the read-only DAX lineage graph.""" from __future__ import annotations from collections.abc import Iterable from dataclasses import dataclass from typing import TypedDict from uuid import UUID from sqlalchemy import select, tuple_ from sqlalchemy.ext.asyncio import AsyncSession from app.domains.data_access import constants as c from app.domains.data_access.lineage import DataAccessLineageNode from app.domains.data_access.models import RESOURCE_STATUS_ACTIVE, DataAccessResource from app.identity.person_models import IdentityPerson @dataclass(frozen=True, slots=True) class DataAccessLineageDatasetOwner: dataset_resource_id: UUID owner_person_id: str | None owner_label: str | None owner_source: str requestable: bool class DataAccessLineageDatasetOwnerFields(TypedDict): dataset_cataloged: bool dataset_requestable: bool dataset_owner_person_id: str | None dataset_owner_label: str | None dataset_owner_source: str | None async def resolve_lineage_dataset_owners( db: AsyncSession, *, integration_id: str, nodes: Iterable[DataAccessLineageNode], ) -> dict[tuple[str, str], DataAccessLineageDatasetOwner]: """Resolve one active DAX dataset row per lineage dataset locator. The lookup is scoped to the selected dataset's integration, so a second development integration cannot lend its owner to a Production graph. All locators are fetched in one query; no per-node owner waterfall is introduced. """ locators = sorted({(node.project_id, node.dataset_id) for node in nodes}) if not locators: return {} rows = ( await db.execute( select( DataAccessResource, IdentityPerson.display_name, IdentityPerson.work_email, ) .outerjoin( IdentityPerson, IdentityPerson.id == DataAccessResource.owner_person_id, ) .where( DataAccessResource.integration_id == integration_id, DataAccessResource.provider == c.PROVIDER_BIGQUERY, DataAccessResource.resource_type == c.RESOURCE_TYPE_DATASET, DataAccessResource.status == RESOURCE_STATUS_ACTIVE, tuple_( DataAccessResource.project_id, DataAccessResource.dataset_id, ).in_(locators), ), ) ).all() owners: dict[tuple[str, str], DataAccessLineageDatasetOwner] = {} for resource, display_name, work_email in rows: owner_person_id = str(resource.owner_person_id) if resource.owner_person_id else None owners[(resource.project_id, resource.dataset_id)] = DataAccessLineageDatasetOwner( dataset_resource_id=resource.id, owner_person_id=owner_person_id, owner_label=_owner_label( owner_person_id=owner_person_id, display_name=display_name, work_email=work_email, ), owner_source=resource.owner_source, requestable=resource.requestable, ) return owners def _owner_label( *, owner_person_id: str | None, display_name: str | None, work_email: str | None, ) -> str | None: if owner_person_id is None: return None return display_name or work_email or owner_person_id def lineage_dataset_owner_fields( node: DataAccessLineageNode, owners: dict[tuple[str, str], DataAccessLineageDatasetOwner], ) -> DataAccessLineageDatasetOwnerFields: """Serialize owner fields only for a manifest-declared source node. The owner map is keyed at dataset grain. A dbt model can live in the same dataset as a source declaration, but that must not lend source ownership to an intermediate or serving node in HTTP or MCP output. """ owner = ( owners.get((node.project_id, node.dataset_id)) if node.node_type == "source" else None ) return { "dataset_cataloged": owner is not None, "dataset_requestable": owner.requestable if owner else False, "dataset_owner_person_id": owner.owner_person_id if owner else None, "dataset_owner_label": owner.owner_label if owner else None, "dataset_owner_source": owner.owner_source if owner else None, }