Files
arc53--docsgpt/application/storage/db/repositories/sources.py
T
wehub-resource-sync fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

449 lines
16 KiB
Python

"""Repository for the ``sources`` table."""
from __future__ import annotations
import json
import uuid
from typing import Any, Optional
from sqlalchemy import case, Connection, func, or_, select, text
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
from application.storage.db.models import ingest_chunk_progress_table, sources_table
from application.storage.db.source_config import SourceConfig
_SCALAR_COLUMNS = {
"name", "type", "retriever", "sync_frequency", "tokens", "file_path",
"language", "model", "date",
}
_JSONB_COLUMNS = {"metadata", "remote_data", "directory_structure", "file_name_map", "config"}
_ALLOWED_COLUMNS = _SCALAR_COLUMNS | _JSONB_COLUMNS
# Whitelist for sort columns exposed via ``list_for_user``. Anything not in
# this set falls back to ``date`` so user-supplied sort params can't be
# interpolated into SQL unchecked.
_SORTABLE_COLUMNS = {"date", "name", "tokens", "type", "created_at", "updated_at"}
def _coerce_uuid_ids(extra_ids: Optional[list]) -> list:
"""Coerce id strings to ``uuid.UUID`` for binding against UUID columns.
Non-UUID-looking ids (e.g. legacy/synthetic) are dropped — they can never
match a ``sources.id`` value. Used to OR team-shared source ids into the
owner-scoped queries.
"""
if not extra_ids:
return []
out: list = []
for raw in extra_ids:
s = str(raw)
if looks_like_uuid(s):
out.append(uuid.UUID(s))
return out
def _owned_or_shared_scope(user_id: str, extra_ids: Optional[list]):
"""WHERE predicate matching the owner's rows plus any ``extra_ids``.
Lets the paginated/count queries include team-shared sources (passed by id)
alongside owned ones in a single query, so count/sort/search/pagination
stay correct across the union.
"""
scope = sources_table.c.user_id == user_id
ids = _coerce_uuid_ids(extra_ids)
if ids:
scope = or_(scope, sources_table.c.id.in_(ids))
return scope
def _escape_like(pattern: str) -> str:
"""Escape wildcards so a user-supplied substring is matched literally.
We use ``LIKE ESCAPE '\\'`` on the query side so backslash, percent, and
underscore in the input don't accidentally turn into regex-like wildcards.
"""
return (
pattern
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
def _coerce_jsonb(value: Any) -> Any:
"""Normalize incoming JSONB values for the Core ``Table.update()`` path.
``remote_data`` in particular arrives as either a dict or a JSON string
(the legacy Mongo docs stored both shapes). Strings are parsed so the
stored representation is always structured JSONB; dicts/lists pass
through untouched for the SQLAlchemy JSONB type processor.
"""
if value is None:
return None
if isinstance(value, (dict, list)):
return value
if isinstance(value, str):
stripped = value.strip()
if not stripped:
return None
try:
return json.loads(stripped)
except json.JSONDecodeError:
return {"raw": value}
return value
def _normalize_config(config: Any) -> dict:
"""Strict-validate the write-path ``config`` and return a plain dict.
``None`` becomes ``{}`` (classic defaults). A dict is validated through
``SourceConfig`` (raises on bad input, D7 strict-on-write) and dumped
back to a normalized dict for JSONB storage.
"""
if config is None:
return {}
if not isinstance(config, dict):
raise ValueError("config must be a dict")
return SourceConfig.model_validate(config).model_dump()
def _ingest_status_case():
"""Derive a user-facing ingest status from the joined progress row.
``failed`` — reconciler-escalated stall. ``processing`` — embed in
flight. ``None`` — no progress row, or the embed completed.
"""
icp = ingest_chunk_progress_table
return case(
(icp.c.source_id.is_(None), None),
(icp.c.status == "stalled", "failed"),
(icp.c.embedded_chunks < icp.c.total_chunks, "processing"),
else_=None,
).label("ingest_status")
class SourcesRepository:
def __init__(self, conn: Connection) -> None:
self._conn = conn
def create(
self,
name: str,
*,
source_id: Optional[str] = None,
user_id: str,
type: Optional[str] = None,
metadata: Optional[dict] = None,
config: Optional[dict] = None,
retriever: Optional[str] = None,
sync_frequency: Optional[str] = None,
tokens: Optional[str] = None,
file_path: Optional[str] = None,
remote_data: Any = None,
directory_structure: Any = None,
file_name_map: Any = None,
language: Optional[str] = None,
model: Optional[str] = None,
date: Any = None,
legacy_mongo_id: Optional[str] = None,
) -> dict:
result = self._conn.execute(
text(
"""
INSERT INTO sources (
id, user_id, name, type, metadata, config,
retriever, sync_frequency, tokens, file_path,
remote_data, directory_structure, file_name_map,
language, model, date, legacy_mongo_id
)
VALUES (
COALESCE(CAST(:source_id AS uuid), gen_random_uuid()),
:user_id, :name, :type, CAST(:metadata AS jsonb),
CAST(:config AS jsonb),
:retriever, :sync_frequency, :tokens, :file_path,
CAST(:remote_data AS jsonb),
CAST(:directory_structure AS jsonb),
CAST(:file_name_map AS jsonb),
:language, :model,
COALESCE(:date, now()),
:legacy_mongo_id
)
RETURNING *
"""
),
{
"source_id": source_id,
"user_id": user_id,
"name": name,
"type": type,
"metadata": json.dumps(metadata or {}),
"config": json.dumps(_normalize_config(config)),
"retriever": retriever,
"sync_frequency": sync_frequency,
"tokens": tokens,
"file_path": file_path,
"remote_data": (
None if remote_data is None
else json.dumps(_coerce_jsonb(remote_data))
),
"directory_structure": (
None if directory_structure is None
else json.dumps(_coerce_jsonb(directory_structure))
),
"file_name_map": (
None if file_name_map is None
else json.dumps(_coerce_jsonb(file_name_map))
),
"language": language,
"model": model,
"date": date,
"legacy_mongo_id": legacy_mongo_id,
},
)
return row_to_dict(result.fetchone())
def get(self, source_id: str, user_id: str) -> Optional[dict]:
result = self._conn.execute(
text("SELECT * FROM sources WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
{"id": source_id, "user_id": user_id},
)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
def get_any(self, source_id: str, user_id: str) -> Optional[dict]:
"""Resolve a source by either PG UUID or legacy Mongo ObjectId string.
Cutover helper: URLs / bookmarks may still hold Mongo ObjectIds.
Tries the UUID path first, then falls back to ``legacy_mongo_id``.
Both paths are scoped by ``user_id``.
"""
if looks_like_uuid(source_id):
row = self.get(source_id, user_id)
if row is not None:
return row
return self.get_by_legacy_id(source_id, user_id)
def get_by_id(self, source_id: str) -> Optional[dict]:
"""Fetch a source by id with NO ownership scoping.
Used ONLY after a team-grant authorization check (team sharing). Never
call on a raw user-supplied id without that check.
"""
if not looks_like_uuid(source_id):
return None
result = self._conn.execute(
text("SELECT * FROM sources WHERE id = CAST(:id AS uuid)"),
{"id": source_id},
)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
def list_by_ids(self, source_ids) -> list[dict]:
"""Fetch sources whose id is in ``source_ids`` (team-shared listing path)."""
ids = [str(s) for s in source_ids if looks_like_uuid(str(s))]
if not ids:
return []
result = self._conn.execute(
text("SELECT * FROM sources WHERE id = ANY(CAST(:ids AS uuid[])) ORDER BY created_at DESC"),
{"ids": ids},
)
return [row_to_dict(r) for r in result.fetchall()]
def find_by_name(self, user_id: str, name: str) -> Optional[dict]:
"""Return a user's source whose name matches ``name`` (case-insensitive).
Used by agent YAML import to map a portable source name back to a
concrete source id. Returns the oldest match when names collide.
"""
if not name:
return None
result = self._conn.execute(
text(
"SELECT * FROM sources "
"WHERE user_id = :user_id AND lower(name) = lower(:name) "
"ORDER BY created_at LIMIT 1"
),
{"user_id": user_id, "name": name},
)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
def list_for_user(
self,
user_id: str,
*,
limit: Optional[int] = None,
offset: int = 0,
search_term: Optional[str] = None,
sort_field: str = "created_at",
sort_order: str = "desc",
extra_ids: Optional[list] = None,
) -> list[dict]:
"""Return sources owned by ``user_id``, paginated and optionally filtered.
All pagination, filtering, and sorting are pushed into SQL so large
accounts don't materialize their full source list in Python for every
page. See ``PaginatedSources`` in the sources routes for the matching
call site.
Args:
user_id: Scope rows to this owner.
limit: Page size. ``None`` returns every matching row (legacy
full-list path used by ``CombinedJson``).
offset: Rows to skip before collecting ``limit`` results.
search_term: Case-insensitive substring filter on ``name``.
``%`` and ``_`` in the input are escaped so they match
literally rather than as LIKE wildcards.
sort_field: Column to sort by. Unknown values fall back to
``date``. Resolved against ``sources_table.c`` so the
column identity is bound by SQLAlchemy — user input never
reaches the emitted SQL as a string.
sort_order: ``"asc"`` or ``"desc"``; anything else is treated
as ``"desc"``.
Returns:
A list of source rows as plain dicts (via ``row_to_dict``),
each carrying a derived ``ingest_status`` (``failed`` /
``processing`` / ``None``) from the joined progress row.
"""
column_name = sort_field if sort_field in _SORTABLE_COLUMNS else "date"
sort_column = sources_table.c[column_name]
ascending = sort_order.lower() == "asc"
stmt = (
select(sources_table, _ingest_status_case())
.select_from(
sources_table.outerjoin(
ingest_chunk_progress_table,
ingest_chunk_progress_table.c.source_id
== sources_table.c.id,
)
)
.where(_owned_or_shared_scope(user_id, extra_ids))
)
if search_term:
stmt = stmt.where(
sources_table.c.name.ilike(
f"%{_escape_like(search_term)}%",
escape="\\",
)
)
# ``id`` is appended as a stable tiebreaker so paginated windows
# are deterministic across equal sort keys.
id_column = sources_table.c.id
if ascending:
stmt = stmt.order_by(sort_column.asc(), id_column.asc())
else:
stmt = stmt.order_by(sort_column.desc(), id_column.desc())
if limit is not None:
stmt = stmt.limit(limit).offset(offset)
result = self._conn.execute(stmt)
return [row_to_dict(r) for r in result.fetchall()]
def count_for_user(
self,
user_id: str,
*,
search_term: Optional[str] = None,
extra_ids: Optional[list] = None,
) -> int:
"""Return the count of rows that ``list_for_user`` would produce.
The filter mirrors ``list_for_user`` exactly so ``total`` and the
paginated window stay consistent page-to-page.
Args:
user_id: Scope rows to this owner.
search_term: Same substring filter semantics as
``list_for_user``; ``None``/empty disables the filter.
Returns:
The total number of matching rows.
"""
stmt = (
select(func.count())
.select_from(sources_table)
.where(_owned_or_shared_scope(user_id, extra_ids))
)
if search_term:
stmt = stmt.where(
sources_table.c.name.ilike(
f"%{_escape_like(search_term)}%",
escape="\\",
)
)
result = self._conn.execute(stmt)
row = result.fetchone()
return int(row[0]) if row is not None else 0
def update(self, source_id: str, user_id: str, fields: dict) -> None:
filtered = {k: v for k, v in fields.items() if k in _ALLOWED_COLUMNS}
if not filtered:
return
values: dict = {}
for col, val in filtered.items():
values[col] = _coerce_jsonb(val) if col in _JSONB_COLUMNS else val
values["updated_at"] = func.now()
t = sources_table
stmt = (
t.update()
.where(t.c.id == source_id)
.where(t.c.user_id == user_id)
.values(**values)
)
self._conn.execute(stmt)
def get_by_legacy_id(
self, legacy_mongo_id: str, user_id: Optional[str] = None,
) -> Optional[dict]:
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
sql = "SELECT * FROM sources WHERE legacy_mongo_id = :legacy_id"
params: dict[str, str] = {"legacy_id": legacy_mongo_id}
if user_id is not None:
sql += " AND user_id = :user_id"
params["user_id"] = user_id
result = self._conn.execute(text(sql), params)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
def update_by_legacy_id(
self, legacy_mongo_id: str, user_id: str, fields: dict,
) -> bool:
"""Update a source addressed by the Mongo ObjectId string.
Used by dual_write call sites that hold the Mongo ``_id`` but
haven't resolved the PG UUID yet. Returns ``True`` if a row was
updated (i.e. the legacy id was found).
"""
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
row = self.get_by_legacy_id(legacy_mongo_id, user_id)
if row is None:
return False
self.update(str(row["id"]), user_id, fields)
return True
def delete_by_legacy_id(self, legacy_mongo_id: str, user_id: str) -> bool:
"""Delete by Mongo ObjectId. Used by dual_write in DeleteOldIndexes."""
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
result = self._conn.execute(
text(
"DELETE FROM sources "
"WHERE legacy_mongo_id = :legacy_id AND user_id = :user_id"
),
{"legacy_id": legacy_mongo_id, "user_id": user_id},
)
return result.rowcount > 0
def delete(self, source_id: str, user_id: str) -> bool:
result = self._conn.execute(
text("DELETE FROM sources WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
{"id": source_id, "user_id": user_id},
)
return result.rowcount > 0