perf(core): define bounded reference search
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
directory = _access_directory(registry)
|
if provider is not None:
|
||||||
if directory is None:
|
if not tenant_id:
|
||||||
return _fallback_scope_options(
|
return ReferenceSearchPage(
|
||||||
|
options=tuple(
|
||||||
|
_unavailable_option(value, kind=clean_kind)
|
||||||
|
for value in selected
|
||||||
|
)
|
||||||
|
)
|
||||||
|
page = provider.search_reference_options(
|
||||||
|
session,
|
||||||
principal,
|
principal,
|
||||||
scope_type=clean_type,
|
request=ReferenceSearchRequest(
|
||||||
query=query,
|
kind=clean_kind,
|
||||||
selected_values=selected,
|
tenant_id=tenant_id,
|
||||||
limit=normalized_limit,
|
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)
|
||||||
|
if directory is None:
|
||||||
|
return ReferenceSearchPage(
|
||||||
|
options=_fallback_scope_options(
|
||||||
|
principal,
|
||||||
|
scope_type=clean_type,
|
||||||
|
reference_kind=clean_kind,
|
||||||
|
query=query,
|
||||||
|
selected_values=selected,
|
||||||
|
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(
|
||||||
candidates,
|
options=_filter_and_retain_options(
|
||||||
query=query,
|
candidates,
|
||||||
selected_values=selected,
|
query=query,
|
||||||
limit=normalized_limit,
|
selected_values=selected,
|
||||||
kind=clean_type,
|
limit=normalized_limit,
|
||||||
|
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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user