From a500c7a8adcf8494a0ef6b3026352b0b021e92bb Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 27 Jun 2026 23:52:04 +0200 Subject: [PATCH 1/2] fix(search): resolve every schema's real register in cross-schema unified search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the unified-search fix begun in #233. Even with the column/IN-limit crashes gone, OpenRegister objects still never appeared in Nextcloud's top-bar search. Two stacked causes: 1. ROOT CAUSE — ObjectService::searchObjectsPaginated auto-injected `_register = currentRegister` into every query. For a cross-schema search (the unified-search provider passes a `@self.schema` array and no register) this scoped the WHOLE search to one ambient register, so objects in every other register were invisible. Now the auto-inject is skipped whenever the query is multi-schema (`@self.schema` array / `_schemas` / `@self.schemas`). 2. MagicMapper::searchObjectsPaginatedMultiSchema built its schema->register map via registerMapper::findAll, which applies an organisation filter that collapsed the candidate set to a single register. It now builds the map from a DIRECT query over all registers and loads matched register entities lazily via find(_multitenancy:false, _rbac:false), so each searched schema is paired with its REAL owning register (correct magic table). Schema-membership parsing is extracted to extractSchemaIds() with list-vs-id-keyed-map handling. Verified live: searching "Rex" now returns the pet OBJECT (register 2411 / schema 4309) alongside its file and calendar event, over ~1000 searchable schemas, with no 54011 / 1000-IN errors. Magic tables only — no secondary index; Solr/Elasticsearch stay deprecated for unified search (docs/features/search-and-faceting.md updated). Quality: phpcs/phpstan/psalm clean on changed files (baseline counts adjusted for the env-independent find() named-arg pattern); unit tests for extractSchemaIds. Batching (opsx §2) deferred as hardening — the single union now works at scale. --- docs/features/search-and-faceting.md | 12 +- lib/Db/MagicMapper.php | 154 ++++++++++----- lib/Service/ObjectService.php | 16 +- .../unified-search-index/.openspec.yaml | 2 + .../changes/unified-search-index/design.md | 182 ++++++++++++++++++ .../changes/unified-search-index/proposal.md | 91 +++++++++ .../specs/unified-search-provider/spec.md | 110 +++++++++++ .../changes/unified-search-index/tasks.md | 55 ++++++ .../specs/unified-search-provider/spec.md | 9 +- phpstan-baseline.neon | 8 +- psalm-baseline.xml | 4 - ...agicMapperSchemaRegisterResolutionTest.php | 135 +++++++++++++ 12 files changed, 723 insertions(+), 55 deletions(-) create mode 100644 openspec/changes/unified-search-index/.openspec.yaml create mode 100644 openspec/changes/unified-search-index/design.md create mode 100644 openspec/changes/unified-search-index/proposal.md create mode 100644 openspec/changes/unified-search-index/specs/unified-search-provider/spec.md create mode 100644 openspec/changes/unified-search-index/tasks.md create mode 100644 tests/Unit/Db/MagicMapper/MagicMapperSchemaRegisterResolutionTest.php diff --git a/docs/features/search-and-faceting.md b/docs/features/search-and-faceting.md index 2af93b2d77..4bdd9360f4 100644 --- a/docs/features/search-and-faceting.md +++ b/docs/features/search-and-faceting.md @@ -2,10 +2,19 @@ ## Overview -OpenRegister provides a comprehensive, backend-agnostic search and filtering system for register objects. The system supports full-text search with relevance ranking, field-level filtering with comparison operators, faceted drill-down navigation, multi-field sorting, and cursor/offset pagination. A single unified API surface (`ObjectService.searchObjectsPaginated()`) operates transparently against PostgreSQL, Apache Solr, or Elasticsearch. +OpenRegister provides a comprehensive, backend-agnostic search and filtering system for register objects. The system supports full-text search with relevance ranking, field-level filtering with comparison operators, faceted drill-down navigation, multi-field sorting, and cursor/offset pagination. A single unified API surface (`ObjectService.searchObjectsPaginated()`) operates against the object magic tables in the database (PostgreSQL / MariaDB). Apache Solr and Elasticsearch are deprecated as search backends and are not used by unified search. **Tender demand**: 78% of analyzed government tenders require advanced search and filtering capabilities. +## Nextcloud Unified (Top-Bar) Search + +OpenRegister objects participate in Nextcloud's unified (top-bar) search through one fleet-wide provider (`lib/Search/ObjectsProvider.php`, id `openregister_objects`). When a user searches from the magnifier, the provider asks for every **searchable** schema (the `searchable` flag on the schema) with no register filter, and `MagicMapper` resolves each schema to its real owning register and queries the per-(register, schema) magic tables directly. + +- **Magic tables only.** Unified search reads the magic tables; it does **not** use a secondary/denormalised index. Apache **Solr and Elasticsearch are deprecated for unified search** — the cross-schema top-bar path never touches them. (The legacy external `search-index` capability was removed in a separate change.) +- **Cross-schema is not register-scoped.** A cross-schema query (a `@self.schema` array / `_schemas`) is never narrowed to the ambient "current register"; each searched schema is paired with its own register so objects in *every* register surface, not just one. +- **Scale.** The multi-schema union projects only constant metadata columns and scopes each arm's `@self.schema` to its own schema id, so a fleet with 1000+ searchable schemas stays under the database's target-list (1664-column) and `IN`-list (1000-element) limits. +- **Security.** Results still respect RBAC, tenant isolation (active organisation), the `searchable` flag, and the published predicate; a schema with no resolvable register or missing magic table is skipped and logged, never fatal. + ## Full-Text Search Triggered via the `_search` query parameter: @@ -15,7 +24,6 @@ Triggered via the `_search` query parameter: - Case-insensitive matching via `ILIKE` in the database backend - String properties with `format: date`, `format: date-time`, or `format: time` are excluded from text search - PostgreSQL `pg_trgm` extension enables fuzzy matching when installed -- Solr and Elasticsearch backends use their native query parsers ``` GET /api/objects/meldingen-register/meldingen?_search=geluidsoverlast diff --git a/lib/Db/MagicMapper.php b/lib/Db/MagicMapper.php index a55eee5065..9cc4b749ba 100644 --- a/lib/Db/MagicMapper.php +++ b/lib/Db/MagicMapper.php @@ -1398,6 +1398,7 @@ private function searchAcrossMultipleTablesWithUnion(array $query, array $regist * @param Schema $schema Schema entity. * @param Register $register Register entity. * @param array $allPropertyColumns Superset of all property columns across schemas. + * @param bool $metadataOnly Project only metadata columns (no property columns) to keep wide unions under the target-list limit. * * @return string|null SQL SELECT statement or null if table doesn't exist. * @@ -8333,6 +8334,8 @@ public function getMaxAllowedPacketSize(): int * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * + * @spec openspec/changes/unified-search-index/specs/unified-search-provider/spec.md */ public function searchObjectsPaginated( array $searchQuery=[], @@ -8367,7 +8370,6 @@ public function searchObjectsPaginated( // register filter is present, registerIds is left empty and // searchObjectsPaginatedMultiSchema resolves each schema's real owning // register from a schema->register map. - // @spec openspec/changes/unified-search-index/specs/unified-search-provider/spec.md $isMultiSchemaSearch = $schemaId === null && $schemaIds !== null && is_array($schemaIds) === true @@ -8393,7 +8395,7 @@ public function searchObjectsPaginated( ids: $ids, uses: $uses ); - } + }//end if // Single schema search. if ($registerId !== null && $schemaId !== null) { @@ -8512,6 +8514,42 @@ public function searchObjectsPaginated( ]; }//end searchObjectsPaginated() + /** + * Extract integer schema ids from a register's `schemas` membership list. + * + * The list may hold ids by value or by key, as ints or numeric strings; + * this normalises all forms to a flat list of distinct integer ids. + * + * @param array $registerSchemas The register's getSchemas()/decoded schemas array. + * + * @return int[] Distinct integer schema ids. + */ + private function extractSchemaIds(array $registerSchemas): array + { + // A plain list (`[4306, 4307]`) carries ids by VALUE; its integer keys + // are positional, not ids. An id-keyed map (`{4310: "Pet"}`) carries + // ids by KEY. Only consider keys for the map shape so a list of + // non-numeric values can never inject positional indices as schema ids. + $isList = array_is_list($registerSchemas); + $ids = []; + foreach ($registerSchemas as $schemaKey => $schemaValue) { + $candidates = [$schemaKey, $schemaValue]; + if ($isList === true) { + $candidates = [$schemaValue]; + } + + foreach ($candidates as $candidate) { + if (is_int($candidate) === true + || (is_string($candidate) === true && ctype_digit($candidate) === true) + ) { + $ids[(int) $candidate] = true; + } + } + } + + return array_keys($ids); + }//end extractSchemaIds() + /** * Search objects across multiple schemas using UNION queries. * @@ -8533,6 +8571,8 @@ public function searchObjectsPaginated( * @SuppressWarnings(PHPMD.ExcessiveMethodLength) * @psalm-suppress UnusedParam * Parameters reserved for future per-schema security filtering. + * + * @spec openspec/changes/unified-search-index/specs/unified-search-provider/spec.md */ private function searchObjectsPaginatedMultiSchema( array $searchQuery, @@ -8548,13 +8588,37 @@ private function searchObjectsPaginatedMultiSchema( $registersCache = []; $schemasCache = []; - $registers = []; + // Build a schema_id -> owning register_id map so each schema is paired + // with its REAL register (correct magic table). A schema with no owning + // register is SKIPPED (logged) rather than forced onto an unrelated + // register, which produced the "Register+schema table does not exist" + // empties. Register ENTITIES are loaded lazily (find()) only for the + // registers actually matched. `$registers` caches them by id. + // + // IMPORTANT: when no register filter is given (unified search passes a + // searchable-schema set only) we read the register->schema membership + // with a DIRECT query, NOT registerMapper::findAll — findAll applies an + // organisation filter (even with _multitenancy:false the trait's active- + // org resolution can collapse the result to a single register), which + // would hide most schemas' owning registers and make cross-schema + // search return nothing. + $registers = []; + $schemaToRegisterId = []; + if (empty($registerIds) === false) { foreach ($registerIds as $regId) { try { $register = $this->registerMapper->find($regId, _multitenancy: false, _rbac: false); $registers[$register->getId()] = $register; $registersCache[$register->getId()] = $register->jsonSerialize(); + $registerSchemas = ($register->getSchemas() ?? []); + if (is_array($registerSchemas) === true) { + foreach ($this->extractSchemaIds(registerSchemas: $registerSchemas) as $sid) { + if (isset($schemaToRegisterId[$sid]) === false) { + $schemaToRegisterId[$sid] = $register->getId(); + } + } + } } catch (\Exception $e) { $this->logger->warning( message: '[MagicMapper] Failed to find register for multi-schema search', @@ -8563,24 +8627,34 @@ private function searchObjectsPaginatedMultiSchema( } } } else { - // No register filter (e.g. unified search passes only a - // searchable-schema set): load every register so each schema can be - // paired with its REAL owning register via the schema->register map - // below — instead of guessing one and hitting a non-existent table. try { - foreach ($this->registerMapper->findAll(_rbac: false, _multitenancy: false) as $register) { - $registers[$register->getId()] = $register; - $registersCache[$register->getId()] = $register->jsonSerialize(); + $rqb = $this->db->getQueryBuilder(); + $rqb->select('id', 'schemas')->from('openregister_registers'); + $res = $rqb->executeQuery(); + while (($row = $res->fetch()) !== false) { + $regId = (int) $row['id']; + $schemas = json_decode((string) ($row['schemas'] ?? '[]'), true); + if (is_array($schemas) === false) { + continue; + } + + foreach ($this->extractSchemaIds(registerSchemas: $schemas) as $sid) { + if (isset($schemaToRegisterId[$sid]) === false) { + $schemaToRegisterId[$sid] = $regId; + } + } } - } catch (\Exception $e) { + + $res->closeCursor(); + } catch (\Throwable $e) { $this->logger->warning( - message: '[MagicMapper] Failed to load registers for schema-only multi-schema search', + message: '[MagicMapper] Failed to build schema->register map for multi-schema search', context: ['file' => __FILE__, 'line' => __LINE__, 'error' => $e->getMessage()] ); - } + }//end try }//end if - if (empty($registers) === true) { + if (empty($schemaToRegisterId) === true) { return [ 'results' => [], 'total' => 0, @@ -8589,40 +8663,34 @@ private function searchObjectsPaginatedMultiSchema( ]; } - // Build a schema_id -> owning register map once (the register whose - // getSchemas() lists the schema id — by value or key, int or numeric - // string). Each schema is then paired with its REAL register so the - // correct magic table is targeted; a schema with no owning register is - // SKIPPED (logged) rather than forced onto an unrelated register, which - // is what produced the "Register+schema table does not exist" empties. - // @spec openspec/changes/unified-search-index/specs/unified-search-provider/spec.md - $schemaToRegister = []; - foreach ($registers as $register) { - $registerSchemas = $register->getSchemas(); - if (is_array($registerSchemas) === false) { - continue; - } + $registerSchemaPairs = []; + $totalCount = 0; - foreach ($registerSchemas as $schemaKey => $schemaValue) { - foreach ([$schemaValue, $schemaKey] as $candidate) { - if (is_int($candidate) === true - || (is_string($candidate) === true && ctype_digit($candidate) === true) - ) { - $mappedId = (int) $candidate; - if (isset($schemaToRegister[$mappedId]) === false) { - $schemaToRegister[$mappedId] = $register; - } + foreach ($schemaIds as $sId) { + $schemaIdInt = (int) $sId; + $matchedRegisterId = ($schemaToRegisterId[$schemaIdInt] ?? null); + $matchedRegister = null; + if ($matchedRegisterId !== null) { + if (isset($registers[$matchedRegisterId]) === false) { + // Load the owning register ENTITY lazily (only for registers + // actually matched by a searched schema). find() honours + // _multitenancy:false so it resolves regardless of the + // active organisation; on failure the schema is skipped. + try { + $reg = $this->registerMapper->find($matchedRegisterId, _multitenancy: false, _rbac: false); + $registers[$reg->getId()] = $reg; + $registersCache[$reg->getId()] = $reg->jsonSerialize(); + } catch (\Throwable $e) { + $this->logger->warning( + message: '[MagicMapper] Failed to load owning register for multi-schema search', + context: ['file' => __FILE__, 'line' => __LINE__, 'registerId' => $matchedRegisterId, 'error' => $e->getMessage()] + ); } } - } - } - $registerSchemaPairs = []; - $totalCount = 0; + $matchedRegister = ($registers[$matchedRegisterId] ?? null); + }//end if - foreach ($schemaIds as $sId) { - $schemaIdInt = (int) $sId; - $matchedRegister = ($schemaToRegister[$schemaIdInt] ?? null); if ($matchedRegister === null) { // No owning register -> the magic table cannot be resolved; skip // (logged) instead of guessing a wrong register. diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index 7a1561a708..f032128415 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -2415,9 +2415,23 @@ public function searchObjectsPaginated( // response time regardless of which backend (index/database) runs. $searchStartTime = microtime(true); + // Detect a cross-schema search: a `@self.schema` array, `_schemas`, or + // `@self.schemas` means the caller wants to search MANY schemas (e.g. the + // unified-search provider passes every searchable schema). Such a search + // must NOT be scoped to a single register — each schema is resolved to + // its own owning register downstream — so the ambient currentRegister + // (often a default like the first register) must not leak in. + $selfSchema = ($query['@self']['schema'] ?? null); + $isMultiSchemaCtx = (is_array($selfSchema) === true && count($selfSchema) > 0) + || array_key_exists('_schemas', $query) === true + || (isset($query['@self']['schemas']) === true); + // Add register and schema context to query for magic mapper routing. // Use array_key_exists to allow explicit null values to disable auto-setting. - if ($this->currentRegister !== null && array_key_exists('_register', $query) === false) { + if ($this->currentRegister !== null + && array_key_exists('_register', $query) === false + && $isMultiSchemaCtx === false + ) { $query['_register'] = $this->currentRegister->getId(); } diff --git a/openspec/changes/unified-search-index/.openspec.yaml b/openspec/changes/unified-search-index/.openspec.yaml new file mode 100644 index 0000000000..f9be753a1e --- /dev/null +++ b/openspec/changes/unified-search-index/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-27 diff --git a/openspec/changes/unified-search-index/design.md b/openspec/changes/unified-search-index/design.md new file mode 100644 index 0000000000..4d8ddc1ec5 --- /dev/null +++ b/openspec/changes/unified-search-index/design.md @@ -0,0 +1,182 @@ +## Context + +OpenRegister stores every object in a per-(register, schema) "magic" table +(`oc_openregister_table_{reg}_{schema}`). The Nextcloud unified-search +provider `OCA\OpenRegister\Search\ObjectsProvider` must search across ALL +searchable schemas at once. It delegates to +`ObjectService::searchObjectsPaginated`, which for the cross-schema case routes +into `MagicMapper::searchObjectsPaginatedMultiSchema` and unions the magic +tables. + +PR #233 already fixed three crash modes: a metadata-only UNION projection above +a column budget (avoiding Postgres's 1664-column `54011`), and per-schema +scoping of the `@self.schema` `IN`-list in both the count loop and each UNION +arm (avoiding the >1000-expression `IN` cap). What remains: + +- **Failure #4 (correctness):** when no register filter is supplied, each + schema is paired with `reset($registers)` instead of its real owning + register, so the query targets a non-existent table → empty results. +- **Scale:** one UNION over ALL searchable tables is non-viable at very high + searchable-schema counts (statement size, arm count). + +This change finishes cross-schema unified search **over the magic tables +only** — no secondary index, no Solr. It supersedes the earlier denormalised +`oc_openregister_search_index` proposal per the no-secondary-store directive: +there is no index table, no lifecycle listener, and no backfill. + +### Declarative-vs-imperative decision + +This change is **imperative**, not declarative. It is query-execution logic: +resolving each schema's owning register, batching (register, schema) pairs into +DB-limit-safe UNION groups, executing them, and merging/sorting/paginating in +PHP. None of that is expressible as schema-level JSON (the way lifecycle state +machines, aggregations, or `x-openregister-flows` are). It lives in PHP inside +`MagicMapper` and the provider. + +### Org ADR-001 (data layer) + +Org-wide ADR-001 holds that all primary data lives in OpenRegister. This change +introduces **no new store at all** — it reads only the canonical magic tables. +There is nothing to keep in sync and nothing that could drift from the source +of truth. + +## Goals / Non-Goals + +**Goals:** +- OpenRegister objects appear in Nextcloud unified search, correctly linked to + their real owning register/table. +- Cross-schema search runs without tripping any DB limit (column count, `IN` + list, UNION arm count / statement size), at realistic searchable-schema + counts and degrading gracefully beyond them. +- Preserve the exact security contract: RBAC, tenant isolation (active + organisation), the `searchable` flag, and the published predicate — all + enforced inside the existing OR search pipeline the magic query delegates to. +- Magic tables are the sole data source; Solr/Elasticsearch are not used. + +**Non-Goals:** +- No secondary/denormalised index table, no lifecycle listener, no backfill. +- Do NOT modify or remove the existing external `search-index` (Solr) + capability/code — ripping out Solr is a separate cleanup. This change only + stops depending on it for unified search and notes the deprecation. +- No new caching / early-exit / score-precomputation layer (a future optional + optimisation, explicitly out of scope). +- No change to single-schema or register-scoped search paths beyond what the + shared fan-out helper requires. + +## Decisions + +### Decision 1 — Resolve each schema's owning register via a schema→register map + +Build a `schema_id → register` map once per cross-schema search by scanning the +candidate registers and reading each register's `getSchemas()`. For every +searchable schema, pair it with the register whose `getSchemas()` contains that +schema id. This replaces the `reset($registers)` fallback in +`searchObjectsPaginatedMultiSchema` (failure #4). A schema with no resolvable +register, or whose resolved magic table does not exist, is **skipped** (logged, +not fatal). The map must be built from the full set of candidate registers — +not just registers named in a `_register`/`@self.register` filter — so a +schema-only query (the provider's normal call: `@self.schema` = searchable ids, +no register) resolves correctly. + +The multi-schema trigger in `searchObjectsPaginated` must also fire on a +schema-only query: today `$isMultiSchemaSearch` additionally requires a +register id / register-id list. It must fire when a schema-id **array** is +present even with no register filter, deriving the register set from the +schema→register map. + +**Alternative considered:** cartesian product of every register × every schema. +Rejected — quadratic, and most pairs map to non-existent tables. + +### Decision 2 — Bounded, batched fan-out with PHP merge/sort/paginate + +Rather than one UNION over ALL resolved pairs, split the resolved +(register, schema) pairs into **batches** of at most `N` arms (a tuned constant, +conservatively chosen to stay under Postgres statement-size / arm-count limits; +the column-count limit is already handled by PR #233's metadata-only +projection). For each batch: + +1. Run that batch's UNION over its tables (each arm scoped to its own schema's + `IN`/columns per PR #233), applying the same WHERE (term + `@self` filters) + and ordering as a single-batch query. +2. Collect the rows with their relevance/score (and the metadata needed to + sort: score, then a stable tiebreaker such as `updated` then `uuid`). + +After all batches, **merge** the per-batch result sets in PHP, **sort** by +relevance/score then the stable tiebreaker, then apply **offset/limit** +pagination across the merged set. The total count is summed per-schema as PR +#233 already does. Because pagination happens after the merge, each batch is +fetched up to `offset + limit` rows (a bounded over-fetch) so the merged window +is correct; this is documented as the cost of cross-batch ordering. + +Only schemas flagged `searchable = true` whose magic table actually exists are +included as arms. + +**Alternative considered:** keep a single UNION but cap the schema count. +Rejected — silently drops schemas and still mis-orders; batching keeps all +searchable schemas in scope and produces a correctly ordered, paginated result. + +### Decision 3 — Security delegation (unchanged contract) + +RBAC, tenant isolation (active organisation), the `searchable` flag, and the +published predicate are all enforced inside the existing magic-table search +pipeline that each batch UNION delegates to (`_rbac: true`, +`_multitenancy: true`, the published predicate, soft-delete exclusion). The +batching/merge layer does not relax or duplicate any access filter — it only +partitions, then re-merges, rows the pipeline already authorised. The provider +constrains the query to `searchable = true` schemas (it already resolves the +non-searchable opt-out set) and never widens results. + +### Decision 4 — Portability + +Postgres is the primary target; the relevant limits (target-list columns, +`IN`-list size, statement size / UNION arm count) are what the batching is +tuned against. The mapper already detects the platform via +`$this->db->getDatabasePlatform()`. On MariaDB/MySQL the same batching applies; +the batch size constant accounts for that engine's analogous statement/`IN` +limits. No engine-specific feature (trigram, FULLTEXT) is required — matching +uses the existing pipeline's term matching. + +### Seed Data + +This change adds **no new OpenRegister schemas or registers** and **no new +database table of any kind**. There is therefore **no `_registers.json` seed +data** for this change and none should be created by the apply agent. Sample +identifiers in docs/tests should use the nil UUID +`00000000-0000-0000-0000-000000000000` and placeholders like ``. + +## Risks / Trade-offs + +- [Fan-out cost grows with searchable-schema count] → batching keeps each + statement under DB limits; realistic instances have far fewer searchable + schemas (the ~1100 here is test pollution). An optional cache / early-exit is + noted as future work, out of scope. +- [Cross-batch ordering requires per-batch over-fetch of `offset + limit` rows] + → bounded and documented; correct ordering is preferred over a cheaper but + wrong page. +- [A schema's owning register cannot be resolved or its table is missing] → + the schema is skipped and logged; it degrades recall for that schema only, + never errors the whole search. +- [Deep pagination over many batches] → over-fetch grows with offset; unified + search uses small page sizes (cap 25) and shallow paging, so this stays + bounded in practice. +- [Two search code paths remain (batched magic UNION for unified search; other + callers' existing paths)] → accepted; converging them is out of scope. + +## Migration Plan + +1. Land the register-resolution fix + batched fan-out in `MagicMapper` and + confirm the provider reaches the fixed path. +2. No DB migration (no schema change). +3. Rollback: revert the `MagicMapper`/provider changes; behaviour returns to + the prior (empty-result) state. No data is affected. + +## Open Questions + +- The batch size constant `N` (UNION arms per statement) — pick a conservative + default and make it a named constant; tune against the observed + statement-size limit. +- Relevance/score source for cross-batch sorting — reuse the pipeline's + existing scoring if present, else fall back to a deterministic + `updated`/`uuid` order. +- Whether to short-circuit the fan-out once `offset + limit` results are + collected (early-exit) — deferred as a future optimisation. diff --git a/openspec/changes/unified-search-index/proposal.md b/openspec/changes/unified-search-index/proposal.md new file mode 100644 index 0000000000..e9a2171a65 --- /dev/null +++ b/openspec/changes/unified-search-index/proposal.md @@ -0,0 +1,91 @@ +--- +kind: code +--- + +## Why + +OpenRegister objects never appear in Nextcloud unified search (the top-bar +magnifier). The `ObjectsProvider` searches across **all** searchable schemas +by delegating to `ObjectService::searchObjectsPaginated`, which for the +cross-schema case routes into `MagicMapper::searchObjectsPaginatedMultiSchema` +and builds a `UNION` across the per-(register, schema) magic tables +(`oc_openregister_table_{reg}_{schema}`). On a real instance this fails and the +provider fails soft to an empty result, so only files/calendar from other NC +providers show up. Verified failure modes: + +1. Each UNION arm projected the superset of every property column across all + schemas → Postgres `SQLSTATE 54011: target lists can have at most 1664 + entries`. (Fixed by PR #233: metadata-only projection above a column + budget.) +2. The per-table count re-applied the full ~1100-id `@self.schema` as one + `IN(...)` → `More than 1000 expressions in a list`. (Fixed by PR #233: + per-schema scoping of the count.) +3. Each UNION arm's WHERE re-applied the same >1000-id `IN(...)`. (Fixed by + PR #233: per-schema scoping of each arm.) +4. The multi-schema path mis-resolves a schema's owning register — when no + register filter is supplied it falls back to `reset($registers)` and targets + a non-existent table → `Register+schema table does not exist` → empty even + at small scale. **Still broken.** + +PR #233 fixed crashes 1–3. This change finishes the job: it makes cross-schema +unified search work correctly and scalably **over the magic tables +themselves** — no secondary index, no Solr. + +## What Changes + +- **Fix register resolution (failure #4).** Pair each searchable schema with + its OWN owning register by building a `schema_id → register` map across ALL + registers (the register whose `getSchemas()` contains that schema id), rather + than falling back to `reset($registers)`. This targets the correct + `oc_openregister_table_{reg}_{schema}` for every schema. Schemas whose magic + table does not exist are skipped. +- **Bounded, batched fan-out (scale).** Instead of one UNION over ALL + searchable tables, batch the resolved (register, schema) pairs into groups + small enough to stay safely under Postgres limits (UNION-arm count / + statement size; target-list columns already mitigated by PR #233's + metadata-only projection), run each batch's UNION, then merge + sort (by + relevance/score, then a stable tiebreaker) + paginate (offset/limit) in PHP + across batches. +- **No Solr/Elasticsearch.** Solr/Elasticsearch are deprecated; the + unified-search provider relies on the magic tables only. The existing + external `search-index` (Solr) capability/code is NOT modified or removed + here — that cleanup is a separate change, out of scope; unified search simply + stops depending on it. +- **Repoint/confirm `ObjectsProvider`** so the cross-schema search reaches the + fixed, batched magic-table path (passing the searchable-schema set, with no + register filter required to trigger the multi-schema branch). +- **Supersedes** the earlier denormalised-index idea: per the no-secondary-store + directive there is no `oc_openregister_search_index` table, no lifecycle + listener, and no backfill — the magic tables are the sole source. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `unified-search-provider`: the requirement that the provider returns matching + objects across all searchable schemas is unchanged in intent, but the + cross-schema execution is corrected and bounded — register-resolved, + batched UNION over the magic tables with PHP merge/sort/paginate, no + secondary index and no Solr. RBAC / tenant-isolation / published / + `searchable`-flag guarantees are restated against this path. + +## Impact + +- **PHP**: `lib/Db/MagicMapper.php` — `searchObjectsPaginatedMultiSchema` and + its pairing/fan-out helpers (`searchAcrossMultipleTables` / + `searchAcrossMultipleTablesWithUnion`); the multi-schema trigger so it fires + on a schema-only (no-register) query; and `lib/Search/ObjectsProvider.php` + (confirm it reaches the fixed path). +- **DB**: no schema change — no migration, no new table. Postgres-first; a + MariaDB/MySQL note covers the equivalent UNION/statement limits. +- **Security**: RBAC, tenant isolation (active organisation), the `searchable` + flag, and the published predicate stay enforced inside the existing OR search + pipeline the magic query already delegates to. +- **No new OpenRegister schemas/registers** and **no `_registers.json` seed + data** — there is not even an infra table. +- **Scale trade-off**: fan-out cost grows with the searchable-schema count; + acceptable because realistic instances have far fewer searchable schemas + (the ~1100 here is test pollution). An optional cache / early-exit is a + future, out-of-scope optimisation. diff --git a/openspec/changes/unified-search-index/specs/unified-search-provider/spec.md b/openspec/changes/unified-search-index/specs/unified-search-provider/spec.md new file mode 100644 index 0000000000..6c98bb328e --- /dev/null +++ b/openspec/changes/unified-search-index/specs/unified-search-provider/spec.md @@ -0,0 +1,110 @@ +## MODIFIED Requirements + +### Requirement: Search results MUST respect OR RBAC, tenant isolation, and the published predicate + +The provider MUST execute cross-schema unified search over the +OpenRegister magic tables themselves (no secondary/denormalised index, +and no Solr/Elasticsearch), and MUST delegate access control to the +existing OR search pipeline by querying with `_rbac: true` and +`_multitenancy: true`. It MUST NOT apply a weaker (or duplicate) access +filter of its own. The result set MUST contain only objects the searching +user may read: objects granted via RBAC scopes, plus objects readable +through the published predicate (`@self.published` set and in the past, +`@self.depublished` unset or in the future), scoped to the user's active +organisation. Soft-deleted objects MUST never be returned. The +batched-fan-out, merge, sort, and pagination layer MUST only re-merge +rows the pipeline already authorised — it MUST NOT widen the result set. + +#### Scenario: User only sees objects they may read +- GIVEN user `alice` has an RBAC read grant on schema `client` but not on schema `salary` +- AND a `salary` object and a `client` object both match the term `Jansen` +- WHEN `alice` searches for `Jansen` +- THEN the `client` object is in the results +- AND the `salary` object is NOT in the results + +#### Scenario: Published objects are findable without an explicit grant +- GIVEN user `bob` has no RBAC grant on schema `publication` +- AND a `publication` object matching `subsidieregeling` has `@self.published` in the past and no `@self.depublished` +- WHEN `bob` searches for `subsidieregeling` +- THEN the published object IS in the results + +#### Scenario: Unpublished and depublished objects are hidden from ungranted users +- GIVEN user `bob` has no RBAC grant on schema `publication` +- AND one matching object has `@self.published` unset and another has `@self.depublished` in the past +- WHEN `bob` searches for the matching term +- THEN neither object is in the results + +#### Scenario: Tenant isolation in search results +- GIVEN organisations `gemeente-a` and `gemeente-b` each have objects matching `kerkstraat` +- WHEN a user whose active organisation is `gemeente-a` searches for `kerkstraat` +- THEN only `gemeente-a` objects are returned + +#### Scenario: Soft-deleted objects never appear +- GIVEN an object matching the term has been soft-deleted +- WHEN any user searches for the term +- THEN the deleted object is NOT in the results + +## ADDED Requirements + +### Requirement: Cross-schema search MUST resolve each schema's owning register correctly + +For a cross-schema search the provider/mapper MUST pair every searchable +schema with its OWN owning register — the register whose `getSchemas()` +contains that schema id — by building a `schema_id → register` map across +all candidate registers. It MUST NOT fall back to an arbitrary register +(e.g. `reset($registers)`) when no register filter is supplied. The query +for each schema MUST target that schema's real magic table +`oc_openregister_table_{register}_{schema}`. A schema whose owning +register cannot be resolved, or whose magic table does not exist, MUST be +skipped (logged), not fail the whole search. The cross-schema path MUST be +reached even when the query carries only a searchable-schema set and no +register filter. + +#### Scenario: Object links to its real register, not a fallback +- GIVEN schema `case` belongs to register `case-management` (not the first-loaded register) +- AND a `case` object matches the term +- WHEN a user with the right to read it searches for that term +- THEN the result is found in `case-management`'s magic table +- AND the result links to the `case-management` register/route, not a fallback register + +#### Scenario: Schema-only query (no register filter) still searches cross-schema +- GIVEN the provider passes the searchable-schema set with no register filter +- WHEN a user searches for a matching term +- THEN the cross-schema path executes and returns matches across schemas + +#### Scenario: Schema with a missing table is skipped, not fatal +- GIVEN one searchable schema has no corresponding magic table +- WHEN a cross-schema search runs +- THEN that schema is skipped and logged +- AND results from the other searchable schemas are still returned + +### Requirement: Cross-schema search MUST use bounded, batched fan-out that stays under database limits + +The cross-schema search MUST split the resolved (register, schema) pairs +into batches small enough that each batch's `UNION` statement stays under +the database's limits (target-list column count, `IN`-list size, and +statement-size / UNION-arm count). Each batch MUST be executed, then the +per-batch result sets MUST be merged, sorted by relevance/score with a +stable tiebreaker, and paginated (offset/limit) in PHP across all batches. +Only schemas flagged `searchable = true` whose magic table exists MUST be +included. The search MUST NOT issue a single `UNION` over all searchable +tables when that would exceed a database limit. + +#### Scenario: Very high searchable-schema count does not trip DB limits +- GIVEN an instance with more than 1000 searchable schemas +- AND an object matching `bestemmingsplan` exists in one of them +- WHEN a user with the right to read it searches for `bestemmingsplan` +- THEN the object is returned +- AND the query does not raise the 1664-column (`54011`) or >1000-expression `IN` errors + +#### Scenario: Results are ordered and paginated across batches +- GIVEN matching objects exist across schemas spread over multiple batches +- WHEN the first page of results is requested +- THEN the page contains the top-ranked matches across all batches in relevance order +- AND requesting the next page continues without duplicating earlier entries + +#### Scenario: Only searchable schemas with an existing table are queried +- GIVEN schema `internal-note` has `searchable = false` +- AND schema `ghost` is searchable but has no magic table +- WHEN a cross-schema search runs +- THEN neither `internal-note` nor `ghost` contributes a UNION arm diff --git a/openspec/changes/unified-search-index/tasks.md b/openspec/changes/unified-search-index/tasks.md new file mode 100644 index 0000000000..c40a93c7ca --- /dev/null +++ b/openspec/changes/unified-search-index/tasks.md @@ -0,0 +1,55 @@ +## 1. Register resolution (fix failure #4) + +- [x] 1.1 In `MagicMapper::searchObjectsPaginatedMultiSchema`, build a `schema_id → register` map across all candidate registers (the register whose `getSchemas()` contains the schema id) and pair each schema with its real owning register; remove the `reset($registers)` fallback. +- [x] 1.2 Derive the candidate-register set from the schema→register map when the query carries only a searchable-schema set (no register filter), instead of requiring a register id to load registers. +- [x] 1.3 In `searchObjectsPaginated`, make `$isMultiSchemaSearch` fire on a schema-id array even with no register/register-ids filter, so the schema-only provider call reaches the multi-schema path. +- [x] 1.4 Skip (and log) any schema whose owning register cannot be resolved or whose magic table does not exist, without failing the whole search. +- [x] 1.5 Build the schema→register map from a DIRECT query over all registers (not `registerMapper::findAll`, which applies an organisation filter that collapsed the candidate set to a single register); load matched register entities lazily via `find(_multitenancy:false, _rbac:false)`. +- [x] 1.6 ROOT CAUSE: in `ObjectService::searchObjectsPaginated`, stop auto-injecting `_register = currentRegister` for cross-schema searches (a `@self.schema` array / `_schemas` / `@self.schemas`); the ambient default register was scoping the whole unified search to one register, so objects in every other register (e.g. the pet) never surfaced. + +## 2. Bounded, batched fan-out + +> Status (2026-06-27): deferred as hardening. With register resolution fixed (§1) the +> live cross-schema search returns correctly over ~1000 searchable schemas in a SINGLE +> UNION, because PR #233's metadata-only projection keeps it under the 1664-column +> target-list limit and the per-schema `IN` scoping keeps it under the 1000-element +> `IN` limit (verified live: pet object surfaces with no `54011` / 1000-`IN` errors). +> Batching remains valuable hardening for very large fleets but is no longer required +> for correctness on realistic data; it can land as a follow-up without changing the +> provider contract. + +- [ ] 2.1 Add a named batch-size constant (UNION arms per statement) chosen to stay safely under the database statement-size / arm-count limit; document the rationale in the docblock. +- [ ] 2.2 In the fan-out helper (`searchAcrossMultipleTables` / `searchAcrossMultipleTablesWithUnion`), split the resolved (register, schema) pairs into batches, run each batch's UNION (per-schema-scoped arms per PR #233), and collect rows with score + a stable tiebreaker (`updated`, then `uuid`). +- [ ] 2.3 Merge the per-batch result sets in PHP, sort by relevance/score then the stable tiebreaker, and apply offset/limit pagination across the merged set (per-batch over-fetch up to `offset + limit`). +- [ ] 2.4 Include only `searchable = true` schemas whose magic table exists as UNION arms; confirm the per-schema count summation (PR #233) still produces the correct total. + +## 3. Provider + +- [x] 3.1 Confirm/repoint `lib/Search/ObjectsProvider.php` so the cross-schema search reaches the fixed batched path (passes the searchable-schema set, no register filter required), and remove any reliance on the external Solr `search-index` backend from the unified-search path. + +## 4. Tests (PHPUnit, CI-way — php:8.3-cli + OCP stubs, no NC/OR runtime) + +- [ ] 4.1 Add register-resolution unit tests (mocked register/schema mappers) covering: schema paired with its real owning register, schema-only query reaching the multi-schema path, and skip-on-missing-register/table. +- [ ] 4.2 Add batching-boundary unit tests (mocked `IDBConnection`/query builder) covering: pairs split into batches under the limit, cross-batch merge/sort/paginate correctness, and that no single statement exceeds the arm-count/`IN`-list bounds. + +## 5. Spec + docs + +- [x] 5.1 Add this change to the `## OpenSpec changes` list in `openspec/specs/unified-search-provider/spec.md` and confirm the delta validates with `openspec validate`. +- [x] 5.2 Add a docs note that unified search uses the magic tables only and that Solr/Elasticsearch are deprecated for unified search (the external `search-index` capability is untouched and removed in a separate change). — `docs/features/search-and-faceting.md` ("Nextcloud Unified (Top-Bar) Search"). + +## Acceptance criteria + +- Unified search returns OpenRegister objects, each linked to its real owning register/table, on an instance with 1000+ searchable schemas, with no `54011` (1664-column) or >1000-`IN` errors and no single UNION over all tables. +- Results respect RBAC, tenant isolation (active organisation), the `searchable` flag, and the published predicate — verified by the MODIFIED requirement's scenarios. +- Schemas with a missing/unresolvable register or table are skipped and logged, never fatal. +- Cross-batch results are correctly ordered and paginated without duplicates across pages. +- No new database table, migration, listener, or backfill is introduced; the external Solr `search-index` code is not modified. +- No regressions for opencatalogi and softwarecatalog unified search. + +## Quality reminders + +- PHP must pass `composer check:strict` (PHPCS, PHPMD, Psalm, PHPStan); fix any pre-existing issues touched. +- Run the Hydra mechanical gates (spdx-headers, forbidden-patterns, stub-scan, spec-coverage, etc.) before push. +- Add `@spec openspec/changes/unified-search-index/...` traceability tags to changed methods. +- i18n: any new user-facing strings go through `IL10N::t` with English source keys. +- Use only safe placeholder identifiers (nil UUID `00000000-0000-0000-0000-000000000000`, ``) in any docs/tests. diff --git a/openspec/specs/unified-search-provider/spec.md b/openspec/specs/unified-search-provider/spec.md index 9fdba9d1d9..1a7a803840 100644 --- a/openspec/specs/unified-search-provider/spec.md +++ b/openspec/specs/unified-search-provider/spec.md @@ -1,9 +1,16 @@ --- -status: done +status: in-progress --- # unified-search-provider Specification +## OpenSpec changes + +- `unified-search-index` (in-progress) — fixes cross-schema unified search + over the magic tables (correct per-schema register resolution + bounded + batched UNION fan-out with PHP merge/sort/paginate); no secondary index, + no Solr. + ## Purpose OpenRegister provides Nextcloud unified search (top-bar magnifier) over diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 5cfa7b5e92..c3f1bee42f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2707,22 +2707,22 @@ parameters: - message: "#^Unknown parameter \\$_multitenancy in call to method OCA\\\\OpenRegister\\\\Db\\\\RegisterMapper\\:\\:find\\(\\)\\.$#" - count: 13 + count: 15 path: lib/Db/MagicMapper.php - message: "#^Unknown parameter \\$_multitenancy in call to method OCA\\\\OpenRegister\\\\Db\\\\SchemaMapper\\:\\:find\\(\\)\\.$#" - count: 15 + count: 16 path: lib/Db/MagicMapper.php - message: "#^Unknown parameter \\$_rbac in call to method OCA\\\\OpenRegister\\\\Db\\\\RegisterMapper\\:\\:find\\(\\)\\.$#" - count: 11 + count: 13 path: lib/Db/MagicMapper.php - message: "#^Unknown parameter \\$_rbac in call to method OCA\\\\OpenRegister\\\\Db\\\\SchemaMapper\\:\\:find\\(\\)\\.$#" - count: 13 + count: 14 path: lib/Db/MagicMapper.php - diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 8534f49e5c..15230c0ad4 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -136,10 +136,6 @@ - - - - diff --git a/tests/Unit/Db/MagicMapper/MagicMapperSchemaRegisterResolutionTest.php b/tests/Unit/Db/MagicMapper/MagicMapperSchemaRegisterResolutionTest.php new file mode 100644 index 0000000000..e44439634a --- /dev/null +++ b/tests/Unit/Db/MagicMapper/MagicMapperSchemaRegisterResolutionTest.php @@ -0,0 +1,135 @@ + owning-register map so every searched schema is paired with its + * REAL register (correct magic table) instead of being forced onto a default + * one. The DB-integration behaviour of the multi-schema union is validated live + * (see openspec/changes/unified-search-index/tasks.md, §2 status note); these + * tests pin the pure, environment-independent membership-parsing contract. + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Db\MagicMapper + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://www.OpenRegister.app + * + * @spec openspec/changes/unified-search-index/specs/unified-search-provider/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db\MagicMapper; + +use OCA\OpenRegister\Db\MagicMapper; +use PHPUnit\Framework\TestCase; +use ReflectionClass; +use ReflectionMethod; + +/** + * Targets MagicMapper::extractSchemaIds() — the schema-membership normaliser + * behind cross-schema unified search. + */ +class MagicMapperSchemaRegisterResolutionTest extends TestCase +{ + + /** + * Build a MagicMapper instance bypassing the constructor. + * + * extractSchemaIds() is pure (touches no instance state), so no + * dependencies need wiring — newInstanceWithoutConstructor() suffices. + * + * @return MagicMapper Mapper instance. + */ + private function buildMapperWithoutConstructor(): MagicMapper + { + $reflection = new ReflectionClass(MagicMapper::class); + return $reflection->newInstanceWithoutConstructor(); + + }//end buildMapperWithoutConstructor() + + + /** + * Invoke a private MagicMapper method by name. + * + * @param MagicMapper $mapper Mapper instance. + * @param string $method Method name. + * @param array $args Positional arguments. + * + * @return mixed Return value of the invocation. + */ + private function invokePrivate(MagicMapper $mapper, string $method, array $args): mixed + { + $reflectionMethod = new ReflectionMethod(MagicMapper::class, $method); + $reflectionMethod->setAccessible(true); + return $reflectionMethod->invokeArgs($mapper, $args); + + }//end invokePrivate() + + + /** + * A plain list of integer schema ids (the common `getSchemas()` / decoded + * `schemas` column shape) is returned verbatim as integers. + * + * @return void + */ + public function testExtractsIntegerSchemaIdsByValue(): void + { + $mapper = $this->buildMapperWithoutConstructor(); + + $result = $this->invokePrivate($mapper, 'extractSchemaIds', [[4306, 4307, 4309]]); + + sort($result); + $this->assertSame([4306, 4307, 4309], $result); + + }//end testExtractsIntegerSchemaIdsByValue() + + + /** + * Numeric-string ids (as a JSON-decoded column can yield) are coerced to + * integers, and ids carried by KEY (id => label maps) are also collected. + * + * @return void + */ + public function testExtractsNumericStringsAndKeyedIds(): void + { + $mapper = $this->buildMapperWithoutConstructor(); + + $byValue = $this->invokePrivate($mapper, 'extractSchemaIds', [['28', '430']]); + sort($byValue); + $this->assertSame([28, 430], $byValue); + + // id-by-key shape: {"4310": "Pet", "4311": "Visit"}. + $byKey = $this->invokePrivate($mapper, 'extractSchemaIds', [[4310 => 'Pet', 4311 => 'Visit']]); + sort($byKey); + $this->assertSame([4310, 4311], $byKey); + + }//end testExtractsNumericStringsAndKeyedIds() + + + /** + * Non-numeric and empty inputs yield no ids and never throw — a register + * with a malformed/empty `schemas` membership must simply contribute + * nothing to the schema->register map (the schema is skipped downstream). + * + * @return void + */ + public function testIgnoresNonNumericAndEmptyMembership(): void + { + $mapper = $this->buildMapperWithoutConstructor(); + + $this->assertSame([], $this->invokePrivate($mapper, 'extractSchemaIds', [[]])); + $this->assertSame([], $this->invokePrivate($mapper, 'extractSchemaIds', [['not-an-id', 'abc']])); + + // Mixed: only the numeric entry survives, distinct ids only. + $mixed = $this->invokePrivate($mapper, 'extractSchemaIds', [['x', '4309', 4309]]); + $this->assertSame([4309], $mixed); + + }//end testIgnoresNonNumericAndEmptyMembership() + + +}//end class From 4c823fb5a5d7fc751d3cfb1e0a0f702262654adb Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 28 Jun 2026 09:31:52 +0200 Subject: [PATCH 2/2] feat(search): show each schema's icon in unified search results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRegister object results in Nextcloud's top-bar search showed no icon (the deep-link fallback `icon-openregister` is a CSS class the search dropdown does not render). Schemas already carry an `icon` field (an MDI name); this surfaces it. - MdiIconRenderer: renders a curated set of @mdi/js glyphs (the pet-store sample icons + common entity icons) to a standalone SVG / data URI. - IconController + route GET /api/icon/mdi/{name}: serves the glyph as a same-origin SVG image (public, cacheable). Nextcloud search only paints a thumbnail from a real URL, so the icon is passed as the entry thumbnail. - ObjectsProvider: when a schema has an icon, use it (square glyph) ahead of the generic app icon; resolve schema/register display metadata with _multitenancy/_rbac bypassed so a result whose schema lives in another organisation still resolves its real name + icon instead of falling back to the bare numeric id. Verified live: searching "Rex" returns the pet with its Dog glyph and the "· Pet" schema label. phpcs/phpstan/psalm clean on changed files; unit test for MdiIconRenderer (normalisation, svg/dataUri, unknown→null). --- appinfo/routes.php | 3 + lib/Controller/IconController.php | 92 ++++++++++++ lib/Search/ObjectsProvider.php | 80 +++++++++-- lib/Service/MdiIconRenderer.php | 154 +++++++++++++++++++++ phpstan-baseline.neon | 16 +++ tests/Unit/Service/MdiIconRendererTest.php | 100 +++++++++++++ 6 files changed, 434 insertions(+), 11 deletions(-) create mode 100644 lib/Controller/IconController.php create mode 100644 lib/Service/MdiIconRenderer.php create mode 100644 tests/Unit/Service/MdiIconRendererTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index fda2f0c5d8..e2d2db8708 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -40,6 +40,9 @@ ['name' => 'schemas#patch', 'url' => '/api/schemas/{id}', 'verb' => 'PATCH', 'requirements' => ['id' => '[^/]+']], ['name' => 'sources#patch', 'url' => '/api/sources/{id}', 'verb' => 'PATCH', 'requirements' => ['id' => '[^/]+']], + // Curated MDI glyph as an SVG image (used to render a schema's icon in unified search). + ['name' => 'icon#mdi', 'url' => '/api/icon/mdi/{name}', 'verb' => 'GET', 'requirements' => ['name' => '[A-Za-z0-9-]+']], + // Data sync / harvesting — manual trigger + status (data-sync-harvesting spec). ['name' => 'sources#syncNow', 'url' => '/api/sources/{id}/sync', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], ['name' => 'sources#syncStatus', 'url' => '/api/sources/{id}/sync-status', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], diff --git a/lib/Controller/IconController.php b/lib/Controller/IconController.php new file mode 100644 index 0000000000..ec5e01271f --- /dev/null +++ b/lib/Controller/IconController.php @@ -0,0 +1,92 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://www.OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Controller; + +use OCA\OpenRegister\Service\MdiIconRenderer; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\DataDisplayResponse; +use OCP\IRequest; + +/** + * Renders curated MDI glyphs as SVG images. + */ +class IconController extends Controller +{ + /** + * Constructor for the IconController. + * + * @param string $appName The name of the app + * @param IRequest $request The HTTP request object + * + * @return void + */ + public function __construct(string $appName, IRequest $request) + { + parent::__construct(appName: $appName, request: $request); + + }//end __construct() + + /** + * Serve a curated Material Design Icon as an SVG image. + * + * Public, cacheable, and read-only: it returns nothing but static glyph + * geometry from a curated allow-list, so it is safe without authentication. + * Unknown icon names return 404 so the caller falls back to its own icon. + * + * @param string $name The MDI icon reference (e.g. "Dog", "mdi-dog"). + * + * @return DataDisplayResponse The SVG image, or a 404 for an unknown icon. + * + * @spec openspec/changes/unified-search-index/specs/unified-search-provider/spec.md + */ + #[PublicPage] + #[NoCSRFRequired] + public function mdi(string $name): DataDisplayResponse + { + $svg = MdiIconRenderer::svg(icon: $name); + if ($svg === null) { + return new DataDisplayResponse( + data: '', + statusCode: Http::STATUS_NOT_FOUND + ); + } + + $response = new DataDisplayResponse( + data: $svg, + statusCode: Http::STATUS_OK, + headers: ['Content-Type' => 'image/svg+xml'] + ); + // Glyph geometry is immutable for a given name — cache hard. + $response->cacheFor(86400, false, true); + + return $response; + + }//end mdi() +}//end class diff --git a/lib/Search/ObjectsProvider.php b/lib/Search/ObjectsProvider.php index 8e2e187c74..40a55282b0 100644 --- a/lib/Search/ObjectsProvider.php +++ b/lib/Search/ObjectsProvider.php @@ -29,6 +29,7 @@ use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\DeepLinkRegistryService; +use OCA\OpenRegister\Service\MdiIconRenderer; use OCA\OpenRegister\Service\ObjectService; use OCP\IL10N; use OCP\IURLGenerator; @@ -493,20 +494,40 @@ public function search(IUser $user, ISearchQuery $query): SearchResult ); } - // Use registered app icon or fall back to OpenRegister icon. - $icon = $this->deepLinkRegistry->resolveIcon( - registerId: $registerId, - schemaId: $schemaId - ) ?? 'icon-openregister'; - // Resolve the per-app label for this (register, schema) pair. $appLabel = $this->deepLinkRegistry->resolveDisplayName( registerId: $registerId, schemaId: $schemaId ); - // Use the registered (rounded) app icon for claimed pairs. - $rounded = ($appLabel !== null); + // Icon precedence: + // 1. the schema's own MDI icon (an explicit, per-schema choice + // by the app author), rendered as a self-hosted data: SVG so + // it renders in the search dropdown and passes the image CSP; + // 2. the consuming app's registered (rounded) icon; + // 3. the generic OpenRegister icon class. + // The rounded avatar style only applies to the registered app + // icon — a schema glyph is a square monochrome icon. + // The schema glyph is served from the icon endpoint as a real + // same-origin SVG URL and passed as the THUMBNAIL, because + // Nextcloud search only paints a thumbnail from a URL — an + // icon-class name or a data: URI is not rendered as an image. + $schemaIconName = $this->resolveSchemaIcon(schemaId: $schemaId); + $thumbnailUrl = ''; + if (MdiIconRenderer::has(icon: $schemaIconName) === true) { + $thumbnailUrl = $this->urlGenerator->linkToRoute( + 'openregister.icon.mdi', + ['name' => $schemaIconName] + ); + $icon = 'icon-openregister'; + $rounded = false; + } else { + $icon = $this->deepLinkRegistry->resolveIcon( + registerId: $registerId, + schemaId: $schemaId + ) ?? 'icon-openregister'; + $rounded = ($appLabel !== null); + } // Create descriptive title and subline. $name = $selfData['name'] ?? ''; @@ -529,7 +550,7 @@ public function search(IUser $user, ISearchQuery $query): SearchResult ); $searchResultEntries[] = new SearchResultEntry( - $icon, + $thumbnailUrl, $title, $subline, $objectUrl, @@ -756,7 +777,11 @@ private function resolveSchemaName(int $schemaId): string $key = 'schema_'.$schemaId; if (isset($this->nameCache[$key]) === false) { try { - $schema = $this->schemaMapper->find($schemaId); + // Resolve display metadata with tenancy/RBAC bypassed: the + // result object already passed those gates, and a schema owned by + // a different organisation than the active one must still resolve + // its human title (otherwise the result falls back to the bare id). + $schema = $this->schemaMapper->find($schemaId, _multitenancy: false, _rbac: false); $title = $schema->getTitle(); $this->nameCache[$key] = (string) $schemaId; if ($title !== null && $title !== '') { @@ -770,6 +795,38 @@ private function resolveSchemaName(int $schemaId): string return $this->nameCache[$key]; }//end resolveSchemaName() + /** + * Resolve a schema ID to its MDI icon reference (e.g. "Dog"), if set. + * + * @param int $schemaId The schema ID + * + * @return string|null The schema's icon reference, or null when unset/unknown + * + * @spec openspec/changes/unified-search-index/specs/unified-search-provider/spec.md + */ + private function resolveSchemaIcon(int $schemaId): ?string + { + $key = 'schemaicon_'.$schemaId; + if (array_key_exists($key, $this->nameCache) === false) { + $this->nameCache[$key] = ''; + try { + // Tenancy/RBAC bypassed for the same reason as resolveSchemaName(). + $icon = $this->schemaMapper->find($schemaId, _multitenancy: false, _rbac: false)->getIcon(); + if ($icon !== null) { + $this->nameCache[$key] = $icon; + } + } catch (\Exception $e) { + $this->nameCache[$key] = ''; + } + } + + if ($this->nameCache[$key] === '') { + return null; + } + + return $this->nameCache[$key]; + }//end resolveSchemaIcon() + /** * Resolve a register ID to its human-readable title. * @@ -784,7 +841,8 @@ private function resolveRegisterName(int $registerId): string $key = 'register_'.$registerId; if (isset($this->nameCache[$key]) === false) { try { - $register = $this->registerMapper->find($registerId); + // Tenancy/RBAC bypassed for the same reason as resolveSchemaName(). + $register = $this->registerMapper->find($registerId, _multitenancy: false, _rbac: false); $title = $register->getTitle(); $this->nameCache[$key] = (string) $registerId; if ($title !== null && $title !== '') { diff --git a/lib/Service/MdiIconRenderer.php b/lib/Service/MdiIconRenderer.php new file mode 100644 index 0000000000..08daab5723 --- /dev/null +++ b/lib/Service/MdiIconRenderer.php @@ -0,0 +1,154 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://www.OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service; + +/** + * Renders Material Design Icon references to self-hosted SVG data URIs. + */ +final class MdiIconRenderer +{ + + /** + * Fill colour for rendered glyphs — a neutral slate readable on the light + * unified-search dropdown. + * + * @var string + */ + private const FILL = '#5d6770'; + + /** + * Curated MDI path data, keyed by the normalised icon name (lower-case, + * alphanumeric, `mdi` prefix stripped — so "Dog", "mdi-dog" and "mdiDog" + * all resolve to "dog"). Values are the SVG `path` `d` data from Material + * Design Icons v7 (the mdi/js package). Lines hold one full path each and + * therefore exceed the line-length limit by design. + * + * @var array + */ + // phpcs:disable Generic.Files.LineLength.MaxExceeded + private const PATHS = [ + 'dog' => 'M18,4C16.29,4 15.25,4.33 14.65,4.61C13.88,4.23 13,4 12,4C11,4 10.12,4.23 9.35,4.61C8.75,4.33 7.71,4 6,4C3,4 1,12 1,14C1,14.83 2.32,15.59 4.14,15.9C4.78,18.14 7.8,19.85 11.5,20V15.72C10.91,15.35 10,14.68 10,14C10,13 12,13 12,13C12,13 14,13 14,14C14,14.68 13.09,15.35 12.5,15.72V20C16.2,19.85 19.22,18.14 19.86,15.9C21.68,15.59 23,14.83 23,14C23,12 21,4 18,4M4.15,13.87C3.65,13.75 3.26,13.61 3,13.5C3.25,10.73 5.2,6.4 6.05,6C6.59,6 7,6.06 7.37,6.11C5.27,8.42 4.44,12.04 4.15,13.87M9,12A1,1 0 0,1 8,11C8,10.46 8.45,10 9,10A1,1 0 0,1 10,11C10,11.56 9.55,12 9,12M15,12A1,1 0 0,1 14,11C14,10.46 14.45,10 15,10A1,1 0 0,1 16,11C16,11.56 15.55,12 15,12M19.85,13.87C19.56,12.04 18.73,8.42 16.63,6.11C17,6.06 17.41,6 17.95,6C18.8,6.4 20.75,10.73 21,13.5C20.75,13.61 20.36,13.75 19.85,13.87Z', + 'cat' => 'M12,8L10.67,8.09C9.81,7.07 7.4,4.5 5,4.5C5,4.5 3.03,7.46 4.96,11.41C4.41,12.24 4.07,12.67 4,13.66L2.07,13.95L2.28,14.93L4.04,14.67L4.18,15.38L2.61,16.32L3.08,17.21L4.53,16.32C5.68,18.76 8.59,20 12,20C15.41,20 18.32,18.76 19.47,16.32L20.92,17.21L21.39,16.32L19.82,15.38L19.96,14.67L21.72,14.93L21.93,13.95L20,13.66C19.93,12.67 19.59,12.24 19.04,11.41C20.97,7.46 19,4.5 19,4.5C16.6,4.5 14.19,7.07 13.33,8.09L12,8M9,11A1,1 0 0,1 10,12A1,1 0 0,1 9,13A1,1 0 0,1 8,12A1,1 0 0,1 9,11M15,11A1,1 0 0,1 16,12A1,1 0 0,1 15,13A1,1 0 0,1 14,12A1,1 0 0,1 15,11M11,14H13L12.3,15.39C12.5,16.03 13.06,16.5 13.75,16.5A1.5,1.5 0 0,0 15.25,15H15.75A2,2 0 0,1 13.75,17C13,17 12.35,16.59 12,16V16H12C11.65,16.59 11,17 10.25,17A2,2 0 0,1 8.25,15H8.75A1.5,1.5 0 0,0 10.25,16.5C10.94,16.5 11.5,16.03 11.7,15.39L11,14Z', + 'bird' => 'M23 11.5L19.95 10.37C19.69 9.22 19.04 8.56 19.04 8.56C17.4 6.92 14.75 6.92 13.11 8.56L11.63 10.04L5 3C4 7 5 11 7.45 14.22L2 19.5C2 19.5 10.89 21.5 16.07 17.45C18.83 15.29 19.45 14.03 19.84 12.7L23 11.5M17.71 11.72C17.32 12.11 16.68 12.11 16.29 11.72C15.9 11.33 15.9 10.7 16.29 10.31C16.68 9.92 17.32 9.92 17.71 10.31C18.1 10.7 18.1 11.33 17.71 11.72Z', + 'fish' => 'M12,20L12.76,17C9.5,16.79 6.59,15.4 5.75,13.58C5.66,14.06 5.53,14.5 5.33,14.83C4.67,16 3.33,16 2,16C3.1,16 3.5,14.43 3.5,12.5C3.5,10.57 3.1,9 2,9C3.33,9 4.67,9 5.33,10.17C5.53,10.5 5.66,10.94 5.75,11.42C6.4,10 8.32,8.85 10.66,8.32L9,5C11,5 13,5 14.33,5.67C15.46,6.23 16.11,7.27 16.69,8.38C19.61,9.08 22,10.66 22,12.5C22,14.38 19.5,16 16.5,16.66C15.67,17.76 14.86,18.78 14.17,19.33C13.33,20 12.67,20 12,20M17,11A1,1 0 0,0 16,12A1,1 0 0,0 17,13A1,1 0 0,0 18,12A1,1 0 0,0 17,11Z', + 'turtle' => 'M8.47,5.95C8.95,5.67 9.47,5.44 10,5.28V4C10,2.9 10.87,2 11.97,1.97C13.13,2 14,2.9 14,4V5.28C14.53,5.45 15.05,5.67 15.53,5.95L13.93,8.07H10.07L8.47,5.95M19,12C19,12.5 18.95,12.95 18.86,13.4L16.33,12.62L15.14,8.96L16.74,6.85C17.17,7.25 17.55,7.7 17.88,8.2C18.67,8.13 19.43,8.25 20.11,8.59C21.14,9.12 21.84,10.13 22,11.28L19,11.64C19,11.76 19,11.88 19,12M5,12C5,11.88 5,11.76 5,11.65L2,11.28C2.16,10.13 2.86,9.12 3.89,8.59C4.57,8.25 5.34,8.13 6.08,8.26C6.41,7.75 6.79,7.28 7.24,6.87L8.86,8.95L7.67,12.62L5.14,13.4C5.05,12.95 5,12.5 5,12M10.24,9.57H13.76L14.85,12.93L12,15L9.15,12.93L10.24,9.57M8.13,14.05L11.25,16.31V18.96C10.68,18.9 10.13,18.77 9.62,18.58L8.39,21.34C7.33,20.87 6.57,19.9 6.37,18.76C6.23,18 6.35,17.24 6.69,16.56C6.24,16.04 5.87,15.46 5.59,14.82L8.13,14.05M15.87,14.05L18.41,14.82C18.13,15.46 17.76,16.04 17.31,16.56C17.65,17.24 17.77,18 17.64,18.76C17.43,19.9 16.67,20.87 15.61,21.34L14.39,18.58C13.86,18.77 13.33,18.94 12.75,19V16.31L15.87,14.05Z', + 'paw' => 'M8.35,3C9.53,2.83 10.78,4.12 11.14,5.9C11.5,7.67 10.85,9.25 9.67,9.43C8.5,9.61 7.24,8.32 6.87,6.54C6.5,4.77 7.17,3.19 8.35,3M15.5,3C16.69,3.19 17.35,4.77 17,6.54C16.62,8.32 15.37,9.61 14.19,9.43C13,9.25 12.35,7.67 12.72,5.9C13.08,4.12 14.33,2.83 15.5,3M3,7.6C4.14,7.11 5.69,8 6.5,9.55C7.26,11.13 7,12.79 5.87,13.28C4.74,13.77 3.2,12.89 2.41,11.32C1.62,9.75 1.9,8.08 3,7.6M21,7.6C22.1,8.08 22.38,9.75 21.59,11.32C20.8,12.89 19.26,13.77 18.13,13.28C17,12.79 16.74,11.13 17.5,9.55C18.31,8 19.86,7.11 21,7.6M19.33,18.38C19.37,19.32 18.65,20.36 17.79,20.75C16,21.57 13.88,19.87 11.89,19.87C9.9,19.87 7.76,21.64 6,20.75C5,20.26 4.31,18.96 4.44,17.88C4.62,16.39 6.41,15.59 7.47,14.5C8.88,13.09 9.88,10.44 11.89,10.44C13.89,10.44 14.95,13.05 16.3,14.5C17.41,15.72 19.26,16.75 19.33,18.38Z', + 'account' => 'M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z', + 'accountgroup' => 'M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z', + 'tag' => 'M5.5,7A1.5,1.5 0 0,1 4,5.5A1.5,1.5 0 0,1 5.5,4A1.5,1.5 0 0,1 7,5.5A1.5,1.5 0 0,1 5.5,7M21.41,11.58L12.41,2.58C12.05,2.22 11.55,2 11,2H4C2.89,2 2,2.89 2,4V11C2,11.55 2.22,12.05 2.59,12.41L11.58,21.41C11.95,21.77 12.45,22 13,22C13.55,22 14.05,21.77 14.41,21.41L21.41,14.41C21.78,14.05 22,13.55 22,13C22,12.44 21.77,11.94 21.41,11.58Z', + 'shape' => 'M11,13.5V21.5H3V13.5H11M12,2L17.5,11H6.5L12,2M17.5,13C20,13 22,15 22,17.5C22,20 20,22 17.5,22C15,22 13,20 13,17.5C13,15 15,13 17.5,13Z', + 'cartoutline' => 'M17,18A2,2 0 0,1 19,20A2,2 0 0,1 17,22C15.89,22 15,21.1 15,20C15,18.89 15.89,18 17,18M1,2H4.27L5.21,4H20A1,1 0 0,1 21,5C21,5.17 20.95,5.34 20.88,5.5L17.3,11.97C16.96,12.58 16.3,13 15.55,13H8.1L7.2,14.63L7.17,14.75A0.25,0.25 0 0,0 7.42,15H19V17H7C5.89,17 5,16.1 5,15C5,14.65 5.09,14.32 5.24,14.04L6.6,11.59L3,4H1V2M7,18A2,2 0 0,1 9,20A2,2 0 0,1 7,22C5.89,22 5,21.1 5,20C5,18.89 5.89,18 7,18M16,11L18.78,6H6.14L8.5,11H16Z', + 'stethoscope' => 'M19,8C19.56,8 20,8.43 20,9A1,1 0 0,1 19,10C18.43,10 18,9.55 18,9C18,8.43 18.43,8 19,8M2,2V11C2,13.96 4.19,16.5 7.14,16.91C7.76,19.92 10.42,22 13.5,22A6.5,6.5 0 0,0 20,15.5V11.81C21.16,11.39 22,10.29 22,9A3,3 0 0,0 19,6A3,3 0 0,0 16,9C16,10.29 16.84,11.4 18,11.81V15.41C18,17.91 16,19.91 13.5,19.91C11.5,19.91 9.82,18.7 9.22,16.9C12,16.3 14,13.8 14,11V2H10V5H12V11A4,4 0 0,1 8,15A4,4 0 0,1 4,11V5H6V2H2Z', + 'clipboardpulse' => 'M19,3H14.82C14.4,1.84 13.3,1 12,1C10.7,1 9.6,1.84 9.18,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M12,3A1,1 0 0,1 13,4A1,1 0 0,1 12,5A1,1 0 0,1 11,4A1,1 0 0,1 12,3M5,13.46H7.17L10.5,7.08L11.44,14.05L13.93,10.86L16.53,13.46H19V15H15.89L14.07,13.21L10.38,17.92L9.62,12.15L8.11,15H5V13.46Z', + 'medicalbag' => 'M10,3L8,5V7H5C3.85,7 3.12,8 3,9L2,19C1.88,20 2.54,21 4,21H20C21.46,21 22.12,20 22,19L21,9C20.88,8 20.06,7 19,7H16V5L14,3H10M10,5H14V7H10V5M11,10H13V13H16V15H13V18H11V15H8V13H11V10Z', + 'calendar' => 'M19,19H5V8H19M16,1V3H8V1H6V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3H18V1M17,12H12V17H17V12Z', + 'database' => 'M12,3C7.58,3 4,4.79 4,7C4,9.21 7.58,11 12,11C16.42,11 20,9.21 20,7C20,4.79 16.42,3 12,3M4,9V12C4,14.21 7.58,16 12,16C16.42,16 20,14.21 20,12V9C20,11.21 16.42,13 12,13C7.58,13 4,11.21 4,9M4,14V17C4,19.21 7.58,21 12,21C16.42,21 20,19.21 20,17V14C20,16.21 16.42,18 12,18C7.58,18 4,16.21 4,14Z', + 'filedocumentoutline' => 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z', + ]; + // phpcs:enable Generic.Files.LineLength.MaxExceeded + + /** + * Whether the curated set contains a renderable glyph for this icon. + * + * @param string|null $icon The schema icon reference (e.g. "Dog", "mdi-dog"). + * + * @return bool True when {@see self::svg()} would return an SVG. + */ + public static function has(?string $icon): bool + { + return self::resolvePath(icon: $icon) !== null; + + }//end has() + + /** + * Render an MDI icon reference to a standalone 24×24 SVG document. + * + * @param string|null $icon The schema icon reference (e.g. "Dog", "mdi-dog"). + * + * @return string|null The SVG markup, or null when the icon is empty or not + * in the curated set. + */ + public static function svg(?string $icon): ?string + { + $path = self::resolvePath(icon: $icon); + if ($path === null) { + return null; + } + + return '' + .''; + + }//end svg() + + /** + * Render an MDI icon reference to a `data:` SVG URI. + * + * @param string|null $icon The schema icon reference (e.g. "Dog", "mdi-dog"). + * + * @return string|null A `data:image/svg+xml;base64,…` URI, or null when the + * icon is empty or not in the curated set. + */ + public static function dataUri(?string $icon): ?string + { + $svg = self::svg(icon: $icon); + if ($svg === null) { + return null; + } + + return 'data:image/svg+xml;base64,'.base64_encode($svg); + + }//end dataUri() + + /** + * Resolve an icon reference to its SVG path data, or null when unknown. + * + * @param string|null $icon The schema icon reference. + * + * @return string|null The SVG path `d` data, or null. + */ + private static function resolvePath(?string $icon): ?string + { + if ($icon === null || $icon === '') { + return null; + } + + // Normalise: drop a leading "mdi" prefix, strip every non-alphanumeric + // character, lower-case — so "Dog", "mdi-dog" and "mdiDog" all map to + // the "dog" key. + $key = strtolower((string) preg_replace('/[^a-z0-9]/i', '', $icon)); + if (str_starts_with($key, 'mdi') === true) { + $key = substr($key, 3); + } + + return (self::PATHS[$key] ?? null); + + }//end resolvePath() +}//end class diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c3f1bee42f..41072b49c3 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,21 @@ parameters: ignoreErrors: + - + message: "#^Unknown parameter \\$_multitenancy in call to method OCA\\\\OpenRegister\\\\Db\\\\SchemaMapper\\:\\:find\\(\\)\\.$#" + count: 2 + path: lib/Search/ObjectsProvider.php + - + message: "#^Unknown parameter \\$_rbac in call to method OCA\\\\OpenRegister\\\\Db\\\\SchemaMapper\\:\\:find\\(\\)\\.$#" + count: 2 + path: lib/Search/ObjectsProvider.php + - + message: "#^Unknown parameter \\$_multitenancy in call to method OCA\\\\OpenRegister\\\\Db\\\\RegisterMapper\\:\\:find\\(\\)\\.$#" + count: 1 + path: lib/Search/ObjectsProvider.php + - + message: "#^Unknown parameter \\$_rbac in call to method OCA\\\\OpenRegister\\\\Db\\\\RegisterMapper\\:\\:find\\(\\)\\.$#" + count: 1 + path: lib/Search/ObjectsProvider.php - message: "#^Unknown parameter \\$_multitenancy in call to method OCA\\\\OpenRegister\\\\Db\\\\RegisterMapper\\:\\:find\\(\\)\\.$#" count: 1 diff --git a/tests/Unit/Service/MdiIconRendererTest.php b/tests/Unit/Service/MdiIconRendererTest.php new file mode 100644 index 0000000000..13f6feeef9 --- /dev/null +++ b/tests/Unit/Service/MdiIconRendererTest.php @@ -0,0 +1,100 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://www.OpenRegister.app + * + * @spec openspec/changes/unified-search-index/specs/unified-search-provider/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service; + +use OCA\OpenRegister\Service\MdiIconRenderer; +use PHPUnit\Framework\TestCase; + +/** + * Targets MdiIconRenderer::has(), svg() and dataUri(). + */ +class MdiIconRendererTest extends TestCase +{ + + + /** + * A curated icon name resolves regardless of case, prefix or separators. + * + * @return void + */ + public function testNormalisesNameVariants(): void + { + foreach (['Dog', 'dog', 'mdi-dog', 'mdiDog', 'MDI_DOG'] as $variant) { + $this->assertTrue(MdiIconRenderer::has(icon: $variant), $variant.' should resolve'); + $this->assertNotNull(MdiIconRenderer::svg(icon: $variant), $variant.' should render'); + } + + }//end testNormalisesNameVariants() + + + /** + * svg() returns a 24×24 SVG document carrying the glyph path. + * + * @return void + */ + public function testSvgIsWellFormed(): void + { + $svg = MdiIconRenderer::svg(icon: 'Account'); + + $this->assertIsString($svg); + $this->assertStringStartsWith('assertStringContainsString('viewBox="0 0 24 24"', $svg); + $this->assertStringContainsString('assertStringEndsWith('', $svg); + + }//end testSvgIsWellFormed() + + + /** + * dataUri() wraps the SVG as a base64 data URI whose payload is the SVG. + * + * @return void + */ + public function testDataUriEncodesTheSvg(): void + { + $uri = MdiIconRenderer::dataUri(icon: 'Stethoscope'); + + $this->assertIsString($uri); + $this->assertStringStartsWith('data:image/svg+xml;base64,', $uri); + + $decoded = base64_decode(substr($uri, strlen('data:image/svg+xml;base64,')), true); + $this->assertIsString($decoded); + $this->assertSame(MdiIconRenderer::svg(icon: 'Stethoscope'), $decoded); + + }//end testDataUriEncodesTheSvg() + + + /** + * Unknown, empty and null names resolve to nothing so the caller can fall + * back to its own icon, and never throw. + * + * @return void + */ + public function testUnknownAndEmptyReturnNothing(): void + { + foreach (['Nonexistent', '', null, ' ', '!!!'] as $bad) { + $this->assertFalse(MdiIconRenderer::has(icon: $bad)); + $this->assertNull(MdiIconRenderer::svg(icon: $bad)); + $this->assertNull(MdiIconRenderer::dataUri(icon: $bad)); + } + + }//end testUnknownAndEmptyReturnNothing() + + +}//end class