Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/spec/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ Beyond the always-present fields, these **conditional / relational sections** ap
| **Referenced by (other institutions)** / 参照元(他機関) | CFItem | ✓ | ✓ | other-tenant associations whose destination is this item. **Public origins only** (private adopters never surfaced); current tenant excluded; excludes `isChildOf` |
| **Referring criteria** | CFItem | ✓ | ✓ | a CFRubricCriterion links to this item via `CFItemURI` |
| **Items setting this subject** / この分野を設定している項目 | CFSubject | ✓ **tenant-wide** (all docs) | ✓ **current document only** (案B) | items whose `subjectURI` references the subject. Top-20 + count + "Show more". Scope = `doc` presence (page None → tenant-wide; pane → that doc). `subject` string-only refs not matched |
| **Items of this type** / この種別の項目 | CFItemType | ✓ **tenant-wide** | ✓ **current document only** (案B) | items whose `cf_item_type_id` FK is this type. Same shape/scope as the subject row; matched by FK (no JSONB). Fragment route `/item-type/{id}/items` |
| **Definitions** (Item Types / Concepts / Subjects / Licenses / Groupings) | CFDocument (tree) | — | ✓ | the lookups this document references (CFPackage `CFDefinitions` scope); each is a navigable tree node |
| **Effective license** | CFItem / CFDocument | ✓ | ✓ | item's own license, else inherited from document (labeled "from document") |

Expand Down Expand Up @@ -244,7 +245,9 @@ When `/uri/{uuid}` resolves to a lookup, show common + specific fields:
- **Standalone `/uri/{subject}` page** — **tenant-wide**: every item in the tenant setting this subject, across all documents. (A subject is a tenant-owned lookup with no owning document, so the page has no document context — `doc` is None.)
- **Tree right pane** (subject selected via the current document's Definitions) — **restricted to the current document**: only that document's items. This keeps the pane consistent with its document-scoped tree (every listed item is a node in the same tree, so links never jump to another document), keeps the list small, and keeps the query cheap. The `doc` query param threads this scope through "Show more"; `subject_items.scope_doc` carries the document identifier (None = tenant-wide). The discriminator is simply whether `_detail_extras` receives a `doc` (pane) or not (page).

Other scope notes: **within-tenant only** (no cross-tenant/private gating); items referencing the subject only via the plain `subject` string (no `subjectURI`) are **not** matched; the reusable `fragments/subject_items.html` partial backs both the inline SSR and the fragment route (and is intended for reuse by a future search results list). Other lookups (CFItemType/Concept/License) do not yet have an equivalent reverse list.
Other scope notes: **within-tenant only** (no cross-tenant/private gating); items referencing the subject only via the plain `subject` string (no `subjectURI`) are **not** matched; the reusable `fragments/subject_items.html` partial backs both the inline SSR and the fragment route (and is intended for reuse by a future search results list).

**CFItemType — "Items of this type" (reverse lookup):** the CFItemType analogue, with the **same** behaviour, surface-based scope (案B), pagination, and `subject_items.html` partial. The only differences: matched by the **`cf_item.cf_item_type_id` FK** (not JSONB; `cf_item_repository.list_items_by_item_type` / `count_items_by_item_type`, keyed by the type's internal PK) — backed by the btree index **`ix_cf_items_tenant_item_type (tenant_id, cf_item_type_id)`** (Postgres does not auto-index FK columns, so the tenant-wide query would otherwise seq-scan), the section label is "Items of this type" / 「この種別の項目」, and the "Show more" fragment route is `GET /{tenant}/item-type/{item-type-id}/items?offset=&limit=[&doc=]`. The shared partial takes an `items_endpoint` so the same markup serves both subject and item-type. CFConcept / CFLicense / CFAssociationGrouping do not yet have an equivalent reverse list (Concept/License would follow the same FK pattern; Grouping lists associations, not items).

### CFRubric

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""add btree index on cf_items (tenant_id, cf_item_type_id)

Speeds up the CFItemType reverse lookup ("items of this type"):
``WHERE tenant_id = ? AND cf_item_type_id = ?``. PostgreSQL does not create an
index on FK columns automatically, so without this the tenant-wide list / count
would seq-scan. The (tenant_id, cf_item_type_id) prefix also serves the count;
the document-scoped pane query adds cf_document_id (covered by the existing
ix_cf_items_tenant_document_coding).

Revision ID: e2b3c4d5f6a7
Revises: d1a2b3c4e5f6
Create Date: 2026-06-20 00:00:00.000000

"""
from typing import Sequence, Union

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "e2b3c4d5f6a7"
down_revision: Union[str, None] = "d1a2b3c4e5f6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_index(
"ix_cf_items_tenant_item_type",
"cf_items",
["tenant_id", "cf_item_type_id"],
unique=False,
)


def downgrade() -> None:
op.drop_index("ix_cf_items_tenant_item_type", table_name="cf_items")
1 change: 1 addition & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"related_other_institution_in": "Other institution ↩",
"incoming_refs_label": "Referenced by (other institutions)",
"subject_items_label": "Items setting this subject",
"item_type_items_label": "Items of this type",
"subject_items_count": "{count} total",
"show_more": "Show more",
"related_external": "External",
Expand Down
1 change: 1 addition & 0 deletions src/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"related_other_institution_in": "他機関 ↩",
"incoming_refs_label": "参照元(他機関)",
"subject_items_label": "この分野を設定している項目",
"item_type_items_label": "この種別の項目",
"subject_items_count": "全 {count} 件",
"show_more": "もっと見る",
"related_external": "外部",
Expand Down
4 changes: 4 additions & 0 deletions src/models/cf_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ class CFItem(Base):
postgresql_using="gin",
postgresql_ops={"subject_uri": "jsonb_path_ops"},
),
# Reverse lookup "items of this type" (CFItemType). Postgres does not
# auto-index FK columns, so the tenant-wide `WHERE tenant_id=? AND
# cf_item_type_id=?` query would seq-scan without this.
Index("ix_cf_items_tenant_item_type", "tenant_id", "cf_item_type_id"),
)

id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
Expand Down
104 changes: 75 additions & 29 deletions src/repositories/cf_item_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,29 +107,13 @@ async def get_cf_item_by_identifier(
return result.scalar_one_or_none()


async def list_items_by_subject(
session: AsyncSession,
tenant_id: uuid.UUID,
subject_identifier: str,
*,
document_id: uuid.UUID | None = None,
offset: int = 0,
limit: int = 20,
) -> list[dict]:
"""CFItems in this tenant whose ``subject_uri`` references the given subject.
async def _list_items_where(session: AsyncSession, conditions: list, offset: int, limit: int) -> list[dict]:
"""Shared body of the reverse-lookup list queries (items using a definition).

Reverse lookup for the CFSubject detail page ("items setting this subject").
Matches via JSONB containment ``subject_uri @> '[{"identifier": <id>}]'``
(GIN-indexed). Tenant-scoped. When ``document_id`` is given, additionally
restricts to that one document (used by the tree right pane, which is
document-scoped; the standalone /uri/ page passes None for the tenant-wide
list). Ordered by human_coding_scheme → full_statement → identifier for a
stable offset-pagination boundary. Returns
Selects the label/link columns, joins the owning document, and applies a
stable order for offset pagination. Returns
``[{identifier, human_coding_scheme, full_statement, doc_identifier, doc_title}]``.
"""
conditions = [CFItem.tenant_id == tenant_id, _subject_uri_contains(subject_identifier)]
if document_id is not None:
conditions.append(CFItem.cf_document_id == document_id)
rows = await session.execute(
select(
CFItem.identifier,
Expand Down Expand Up @@ -160,6 +144,45 @@ async def list_items_by_subject(
]


async def _count_items_where(session: AsyncSession, conditions: list) -> int:
result = await session.execute(select(func.count()).select_from(CFItem).where(*conditions))
return int(result.scalar_one())


def _subject_conditions(tenant_id: uuid.UUID, subject_identifier: str, document_id: uuid.UUID | None) -> list:
conditions = [CFItem.tenant_id == tenant_id, _subject_uri_contains(subject_identifier)]
if document_id is not None:
conditions.append(CFItem.cf_document_id == document_id)
return conditions


def _item_type_conditions(tenant_id: uuid.UUID, item_type_id: uuid.UUID, document_id: uuid.UUID | None) -> list:
conditions = [CFItem.tenant_id == tenant_id, CFItem.cf_item_type_id == item_type_id]
if document_id is not None:
conditions.append(CFItem.cf_document_id == document_id)
return conditions


async def list_items_by_subject(
session: AsyncSession,
tenant_id: uuid.UUID,
subject_identifier: str,
*,
document_id: uuid.UUID | None = None,
offset: int = 0,
limit: int = 20,
) -> list[dict]:
"""CFItems in this tenant whose ``subject_uri`` references the given subject.

Reverse lookup for the CFSubject detail page ("items setting this subject").
Matches via JSONB containment ``subject_uri @> '[{"identifier": <id>}]'``
(GIN-indexed). Tenant-scoped. When ``document_id`` is given, additionally
restricts to that one document (the tree right pane is document-scoped; the
standalone /uri/ page passes None for the tenant-wide list)."""
conditions = _subject_conditions(tenant_id, subject_identifier, document_id)
return await _list_items_where(session, conditions, offset, limit)


async def count_items_by_subject(
session: AsyncSession,
tenant_id: uuid.UUID,
Expand All @@ -168,12 +191,35 @@ async def count_items_by_subject(
document_id: uuid.UUID | None = None,
) -> int:
"""Total CFItems referencing the given subject (for the count label).
Tenant-scoped; restricted to one document when ``document_id`` is given
(same scoping as ``list_items_by_subject``). Call once on the SSR page; the
"load more" fragment derives has_more from a limit+1 fetch instead of
recounting."""
conditions = [CFItem.tenant_id == tenant_id, _subject_uri_contains(subject_identifier)]
if document_id is not None:
conditions.append(CFItem.cf_document_id == document_id)
result = await session.execute(select(func.count()).select_from(CFItem).where(*conditions))
return int(result.scalar_one())
Same scoping as ``list_items_by_subject``."""
return await _count_items_where(session, _subject_conditions(tenant_id, subject_identifier, document_id))


async def list_items_by_item_type(
session: AsyncSession,
tenant_id: uuid.UUID,
item_type_id: uuid.UUID,
*,
document_id: uuid.UUID | None = None,
offset: int = 0,
limit: int = 20,
) -> list[dict]:
"""CFItems of the given CFItemType ("items of this type").

Reverse lookup for the CFItemType detail page. Matches the FK
``cf_item.cf_item_type_id`` (indexed via the FK) — ``item_type_id`` is the
CFItemType's internal PK (``CFItemType.id``), not its CASE identifier.
Tenant-scoped; ``document_id`` restricts to one document (pane scope)."""
return await _list_items_where(session, _item_type_conditions(tenant_id, item_type_id, document_id), offset, limit)


async def count_items_by_item_type(
session: AsyncSession,
tenant_id: uuid.UUID,
item_type_id: uuid.UUID,
*,
document_id: uuid.UUID | None = None,
) -> int:
"""Total CFItems of the given CFItemType (count label). Same scoping as
``list_items_by_item_type``."""
return await _count_items_where(session, _item_type_conditions(tenant_id, item_type_id, document_id))
Loading
Loading