perf(core): define bounded reference search

This commit is contained in:
2026-07-30 01:29:45 +02:00
parent af3e0a055d
commit f3b388fe7e
5 changed files with 268 additions and 28 deletions

View File

@@ -91,6 +91,7 @@ The following contracts are the baseline API that modules can rely on:
- capability factory contract - capability factory contract
- access DTO/protocol contracts in `govoplan_core.core.access` - access DTO/protocol contracts in `govoplan_core.core.access`
- resource ACL provider contract - resource ACL provider contract
- bounded reference-option search provider contract
- single-tenant and optional batched tenant summary provider contracts - single-tenant and optional batched tenant summary provider contracts
- tenant delete-veto provider contract - tenant delete-veto provider contract
- WebUI module contribution contract - WebUI module contribution contract
@@ -346,6 +347,31 @@ full snapshot with `full: true`. A first-use `seq:0` watermark remains valid
until such a floor exists, even if unrelated collections have advanced the until such a floor exists, even if unrelated collections have advanced the
global sequence. global sequence.
### Bounded Reference Selectors
Cross-module selectors use the module-neutral contract in
`govoplan_core.core.references`; consumers must not load an optional module's
complete directory and filter it in memory.
- Providers receive a normalized `ReferenceSearchRequest` with `kind`,
`tenant_id`, `query`, `selected_values`, `limit`, optional opaque `cursor`,
and policy context.
- Providers apply visibility and text filtering before materializing rows and
return `ReferenceSearchPage(options, next_cursor, has_more)`.
- A page contains at most the requested bounded search results. Already-selected
references are retained in addition to that bound so historical values remain
readable and removable even when they are inactive, deleted, or outside the
current search page.
- API consumers expose `next_cursor` and `has_more`. The current searchable
selector requests the first bounded page for each query; later load-more UI
can use the same cursor without changing the provider contract.
- `access.reference_options` supplies SQL-backed account, membership, and group
searches. When it is absent, Core degrades to the legacy Access directory or
to principal-only/unavailable references without importing Access.
- The shared WebUI `apiReferenceOptionProvider` resolves selected values in
chunks of at most 200, preventing a large existing selection from turning
into an unbounded request.
### Cursor/Keyset Pages ### Cursor/Keyset Pages
Offset pagination remains supported for compatibility and for first page loads, Offset pagination remains supported for compatibility and for first page loads,

View File

@@ -53,6 +53,8 @@ class ReferenceOptionResponse(BaseModel):
class ReferenceOptionListResponse(BaseModel): class ReferenceOptionListResponse(BaseModel):
options: list[ReferenceOptionResponse] = Field(default_factory=list) options: list[ReferenceOptionResponse] = Field(default_factory=list)
provider_available: bool = True provider_available: bool = True
next_cursor: str | None = None
has_more: bool = False
class LoginRequest(BaseModel): class LoginRequest(BaseModel):

View File

@@ -12,6 +12,7 @@ from govoplan_core.core.access import (
) )
CAPABILITY_ACCESS_REFERENCE_OPTIONS = "access.reference_options"
ReferenceAvailability = Literal["available", "inactive", "unavailable"] ReferenceAvailability = Literal["available", "inactive", "unavailable"]
@@ -48,9 +49,17 @@ class ReferenceSearchRequest:
query: str = "" query: str = ""
selected_values: tuple[str, ...] = () selected_values: tuple[str, ...] = ()
limit: int = 50 limit: int = 50
cursor: str | None = None
context: Mapping[str, object] = field(default_factory=dict) context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ReferenceSearchPage:
options: tuple[ReferenceOption, ...] = ()
next_cursor: str | None = None
has_more: bool = False
@runtime_checkable @runtime_checkable
class ReferenceOptionProvider(Protocol): class ReferenceOptionProvider(Protocol):
"""Optional module-neutral provider for typed reference selectors.""" """Optional module-neutral provider for typed reference selectors."""
@@ -61,7 +70,7 @@ class ReferenceOptionProvider(Protocol):
principal: object, principal: object,
*, *,
request: ReferenceSearchRequest, request: ReferenceSearchRequest,
) -> Sequence[ReferenceOption]: ) -> ReferenceSearchPage:
... ...
@@ -89,38 +98,115 @@ def access_scope_reference_options(
selected_values: Sequence[str] = (), selected_values: Sequence[str] = (),
limit: int = 50, limit: int = 50,
administrative: bool = False, administrative: bool = False,
session: object | None = None,
reference_kind: str | None = None,
) -> tuple[ReferenceOption, ...]: ) -> tuple[ReferenceOption, ...]:
"""Return canonical user-account or group references for definition scopes.""" """Return canonical user-account or group references for definition scopes."""
return access_scope_reference_page(
registry,
principal,
scope_type=scope_type,
query=query,
selected_values=selected_values,
limit=limit,
administrative=administrative,
session=session,
reference_kind=reference_kind,
).options
def access_scope_reference_page(
registry: object | None,
principal: object,
*,
scope_type: str,
query: str = "",
selected_values: Sequence[str] = (),
limit: int = 50,
cursor: str | None = None,
administrative: bool = False,
session: object | None = None,
reference_kind: str | None = None,
) -> ReferenceSearchPage:
"""Search bounded Access references while retaining selected values."""
clean_type = str(scope_type or "").strip().casefold() clean_type = str(scope_type or "").strip().casefold()
if clean_type not in {"user", "group"}: if clean_type not in {"user", "group"}:
raise ValueError("Scope reference type must be user or group.") raise ValueError("Scope reference type must be user or group.")
clean_kind = str(reference_kind or clean_type).strip().casefold()
if clean_kind not in (
{"user", "membership"} if clean_type == "user" else {"group"}
):
raise ValueError("Scope reference kind is incompatible with the scope type.")
normalized_limit = max(1, min(int(limit), 200)) normalized_limit = max(1, min(int(limit), 200))
selected = tuple( selected = tuple(
dict.fromkeys( dict.fromkeys(
str(value).strip() for value in selected_values if str(value).strip() str(value).strip() for value in selected_values if str(value).strip()
) )
)[:200]
tenant_id = str(getattr(principal, "tenant_id", "") or "")
provider = reference_option_provider(
registry,
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
) )
if provider is not None:
if not tenant_id:
return ReferenceSearchPage(
options=tuple(
_unavailable_option(value, kind=clean_kind)
for value in selected
)
)
page = provider.search_reference_options(
session,
principal,
request=ReferenceSearchRequest(
kind=clean_kind,
tenant_id=tenant_id,
query=str(query or "").strip().casefold(),
selected_values=selected,
limit=normalized_limit,
cursor=str(cursor).strip() if cursor else None,
context={"administrative": administrative},
),
)
return ReferenceSearchPage(
options=_bounded_page_options(
page.options,
selected_values=selected,
limit=normalized_limit,
kind=clean_kind,
),
next_cursor=page.next_cursor,
has_more=page.has_more,
)
directory = _access_directory(registry) directory = _access_directory(registry)
if directory is None: if directory is None:
return _fallback_scope_options( return ReferenceSearchPage(
options=_fallback_scope_options(
principal, principal,
scope_type=clean_type, scope_type=clean_type,
reference_kind=clean_kind,
query=query, query=query,
selected_values=selected, selected_values=selected,
limit=normalized_limit, limit=normalized_limit,
) )
)
tenant_id = str(getattr(principal, "tenant_id", "") or "")
if not tenant_id: if not tenant_id:
return tuple( return ReferenceSearchPage(
_unavailable_option(value, kind=clean_type) for value in selected options=tuple(
_unavailable_option(value, kind=clean_kind) for value in selected
)
) )
if clean_type == "user": if clean_type == "user":
candidates = _user_options( candidates = _user_options(
directory.users_for_tenant(tenant_id), directory.users_for_tenant(tenant_id),
principal=principal, principal=principal,
administrative=administrative, administrative=administrative,
value_kind=clean_kind,
) )
else: else:
candidates = _group_options( candidates = _group_options(
@@ -128,17 +214,26 @@ def access_scope_reference_options(
principal=principal, principal=principal,
administrative=administrative, administrative=administrative,
) )
return _filter_and_retain_options( return ReferenceSearchPage(
options=_filter_and_retain_options(
candidates, candidates,
query=query, query=query,
selected_values=selected, selected_values=selected,
limit=normalized_limit, limit=normalized_limit,
kind=clean_type, kind=clean_kind,
)
) )
def access_scope_reference_provider_available(registry: object | None) -> bool: def access_scope_reference_provider_available(registry: object | None) -> bool:
return _access_directory(registry) is not None return (
reference_option_provider(
registry,
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
)
is not None
or _access_directory(registry) is not None
)
def validate_access_scope_reference( def validate_access_scope_reference(
@@ -209,6 +304,7 @@ def _user_options(
*, *,
principal: object, principal: object,
administrative: bool, administrative: bool,
value_kind: str = "user",
) -> tuple[ReferenceOption, ...]: ) -> tuple[ReferenceOption, ...]:
principal_account_id = str(getattr(principal, "account_id", "") or "") principal_account_id = str(getattr(principal, "account_id", "") or "")
options: dict[str, ReferenceOption] = {} options: dict[str, ReferenceOption] = {}
@@ -218,11 +314,12 @@ def _user_options(
inactive = user.status != "active" inactive = user.status != "active"
label = user.display_name or user.email or user.account_id label = user.display_name or user.email or user.account_id
detail_parts = [part for part in (user.email, "Inactive" if inactive else None) if part] detail_parts = [part for part in (user.email, "Inactive" if inactive else None) if part]
options[user.account_id] = ReferenceOption( value = user.id if value_kind == "membership" else user.account_id
value=user.account_id, options[value] = ReferenceOption(
value=value,
label=label, label=label,
description=" · ".join(detail_parts) or None, description=" · ".join(detail_parts) or None,
kind="user", kind=value_kind,
availability="inactive" if inactive else "available", availability="inactive" if inactive else "available",
disabled=inactive, disabled=inactive,
source_module="access", source_module="access",
@@ -296,28 +393,58 @@ def _filter_and_retain_options(
return tuple(matches) return tuple(matches)
def _bounded_page_options(
options: Sequence[ReferenceOption],
*,
selected_values: Sequence[str],
limit: int,
kind: str,
) -> tuple[ReferenceOption, ...]:
by_value = {option.value: option for option in options}
selected = set(selected_values)
bounded = [
option for option in options if option.value not in selected
][:limit]
returned = {option.value for option in bounded}
for value in selected_values:
if value in returned:
continue
bounded.append(by_value.get(value) or _unavailable_option(value, kind=kind))
returned.add(value)
return tuple(bounded)
def _fallback_scope_options( def _fallback_scope_options(
principal: object, principal: object,
*, *,
scope_type: str, scope_type: str,
reference_kind: str | None = None,
query: str, query: str,
selected_values: Sequence[str], selected_values: Sequence[str],
limit: int, limit: int,
) -> tuple[ReferenceOption, ...]: ) -> tuple[ReferenceOption, ...]:
candidates: list[ReferenceOption] = [] candidates: list[ReferenceOption] = []
if scope_type == "user": if scope_type == "user":
account_id = str(getattr(principal, "account_id", "") or "") kind = str(reference_kind or "user")
if account_id: value = str(
(
getattr(principal, "membership_id", "")
if kind == "membership"
else getattr(principal, "account_id", "")
)
or ""
)
if value:
candidates.append( candidates.append(
ReferenceOption( ReferenceOption(
value=account_id, value=value,
label=( label=(
str(getattr(principal, "display_name", "") or "") str(getattr(principal, "display_name", "") or "")
or str(getattr(principal, "email", "") or "") or str(getattr(principal, "email", "") or "")
or account_id or value
), ),
description="Current user · Access directory unavailable", description="Current user · Access directory unavailable",
kind="user", kind=kind,
source_module="core", source_module="core",
provenance={"fallback": True}, provenance={"fallback": True},
) )
@@ -358,10 +485,13 @@ def _unavailable_option(value: str, *, kind: str) -> ReferenceOption:
__all__ = [ __all__ = [
"CAPABILITY_ACCESS_REFERENCE_OPTIONS",
"ReferenceAvailability", "ReferenceAvailability",
"ReferenceOption", "ReferenceOption",
"ReferenceOptionProvider", "ReferenceOptionProvider",
"ReferenceSearchPage",
"ReferenceSearchRequest", "ReferenceSearchRequest",
"access_scope_reference_page",
"access_scope_reference_options", "access_scope_reference_options",
"access_scope_reference_provider_available", "access_scope_reference_provider_available",
"reference_option_provider", "reference_option_provider",

View File

@@ -10,6 +10,10 @@ from govoplan_core.core.access import (
UserRef, UserRef,
) )
from govoplan_core.core.references import ( from govoplan_core.core.references import (
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
ReferenceOption,
ReferenceSearchPage,
access_scope_reference_page,
access_scope_reference_options, access_scope_reference_options,
validate_access_scope_reference, validate_access_scope_reference,
) )
@@ -84,6 +88,38 @@ class _Registry:
return self.directory if self.has_capability(name) else None return self.directory if self.has_capability(name) else None
class _ReferenceProvider:
def __init__(self) -> None:
self.requests = []
def search_reference_options(self, session, principal, *, request):
del session, principal
self.requests.append(request)
return ReferenceSearchPage(
options=(
ReferenceOption(
value="account-1",
label="Ada",
kind="user",
source_module="access",
),
),
next_cursor="offset:1",
has_more=True,
)
class _ProviderRegistry:
def __init__(self) -> None:
self.provider = _ReferenceProvider()
def has_capability(self, name):
return name == CAPABILITY_ACCESS_REFERENCE_OPTIONS
def capability(self, name):
return self.provider if self.has_capability(name) else None
class ReferenceOptionTests(unittest.TestCase): class ReferenceOptionTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.principal = SimpleNamespace( self.principal = SimpleNamespace(
@@ -168,6 +204,34 @@ class ReferenceOptionTests(unittest.TestCase):
self.assertEqual("account-1", options[0].value) self.assertEqual("account-1", options[0].value)
self.assertEqual("core", options[0].source_module) self.assertEqual("core", options[0].source_module)
def test_bounded_provider_receives_normalized_search_and_retains_stale_selection(self) -> None:
registry = _ProviderRegistry()
page = access_scope_reference_page(
registry,
self.principal,
scope_type="user",
query=" Ada ",
selected_values=("removed-account",),
limit=500,
cursor="offset:20",
administrative=True,
session=object(),
)
request = registry.provider.requests[0]
self.assertEqual("ada", request.query)
self.assertEqual(200, request.limit)
self.assertEqual("offset:20", request.cursor)
self.assertTrue(request.context["administrative"])
self.assertEqual(
["account-1", "removed-account"],
[option.value for option in page.options],
)
self.assertEqual("unavailable", page.options[1].availability)
self.assertTrue(page.has_more)
self.assertEqual("offset:1", page.next_cursor)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -53,8 +53,26 @@ export function apiReferenceOptionProvider(
return { return {
search: (query, context) => search: (query, context) =>
load(query, context.selectedValues, context.limit, context.signal), load(query, context.selectedValues, context.limit, context.signal),
resolve: (values, context) => async resolve(values, context) {
load("", values, context.limit ?? values.length, context.signal) const uniqueValues = [...new Set(values.map((value) => value.trim()).filter(Boolean))];
const chunks: string[][] = [];
for (let index = 0; index < uniqueValues.length; index += 200) {
chunks.push(uniqueValues.slice(index, index + 200));
}
const pages = await Promise.all(
chunks.map((chunk) =>
load("", chunk, Math.min(200, Math.max(1, chunk.length)), context.signal)
)
);
const byValue = new Map(
pages.flat().map((option) => [option.value, option])
);
return uniqueValues.map(
(value) =>
byValue.get(value)
?? unavailableReferenceOption(value)
);
}
}; };
} }