From 6cd8d1be27ca5a845896c3227e50068aaf768115 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 24 Mar 2026 07:28:27 +0100 Subject: [PATCH 1/2] fix: Sidebar sub-resources, deep link dispatch, and search query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dispatch DeepLinkRegistrationEvent in boot() so consuming apps (Procest, Pipelinq) can register their URL patterns - Add setVerb('comment') in NoteService — Nextcloud CommentsManager requires a verb before saving - Return empty array instead of 500 when user has no VTODO calendar in TasksController::index() - Fix search query to only include property columns that exist in each magic table, preventing "column does not exist" errors in UNION queries across tables with different schemas --- lib/AppInfo/Application.php | 12 ++++++++---- lib/Controller/TasksController.php | 5 +++++ lib/Db/MagicMapper.php | 18 ++++++++++++++++-- lib/Db/MagicMapper/MagicSearchHandler.php | 15 +++++++++++---- lib/Service/NoteService.php | 1 + 5 files changed, 41 insertions(+), 10 deletions(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 1d361ecf3..85f7fac4d 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -764,9 +764,13 @@ private function registerEventListeners(IRegistrationContext $context): void */ public function boot(IBootContext $context): void { - // Deep link registration is deferred to avoid circular DI resolution. - // DeepLinkRegistryService depends on RegisterMapper/SchemaMapper which - // trigger circular resolution chains when resolved during boot. - // Consuming apps register their patterns lazily on first use instead. + // Dispatch the deep link registration event so consuming apps + // (Procest, Pipelinq, etc.) can register their URL patterns. + // DeepLinkRegistryService uses ContainerInterface for lazy mapper + // resolution, so no circular DI issues during registration. + $server = $context->getServerContainer(); + $dispatcher = $server->get(IEventDispatcher::class); + $registry = $server->get(DeepLinkRegistryService::class); + $dispatcher->dispatchTyped(new DeepLinkRegistrationEvent($registry)); }//end boot() }//end class diff --git a/lib/Controller/TasksController.php b/lib/Controller/TasksController.php index b5c021550..0cb863ad6 100644 --- a/lib/Controller/TasksController.php +++ b/lib/Controller/TasksController.php @@ -107,6 +107,11 @@ public function index( } catch (DoesNotExistException $e) { return new JSONResponse(data: ['error' => 'Object not found'], statusCode: 404); } catch (Exception $e) { + // No VTODO calendar = no tasks; return empty for listing. + if (str_contains($e->getMessage(), 'No VTODO-supporting calendar')) { + return new JSONResponse(data: ['results' => [], 'total' => 0]); + } + return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 500); } }//end index() diff --git a/lib/Db/MagicMapper.php b/lib/Db/MagicMapper.php index f0982f56b..04cba55f8 100644 --- a/lib/Db/MagicMapper.php +++ b/lib/Db/MagicMapper.php @@ -1272,11 +1272,13 @@ private function buildUnionSelectPart( // Columns that don't exist use NULL::text AS placeholder. // Cast to text ensures type compatibility across schemas in UNION. // (e.g., one schema has 'type' as text, another as jsonb). + $existingColumns = []; foreach (array_keys($allPropertyColumns) as $columnName) { $quotedCol = $this->quoteIdentifier(name: $columnName, isPostgres: $isPostgres); $colExpr = "NULL::text AS {$quotedCol}"; if ($this->columnExistsInTable(tableName: $tableName, columnName: $columnName) === true) { - $colExpr = "{$quotedCol}::text AS {$quotedCol}"; + $colExpr = "{$quotedCol}::text AS {$quotedCol}"; + $existingColumns[] = $columnName; } $selectColumns[] = $colExpr; @@ -1301,6 +1303,13 @@ private function buildUnionSelectPart( $type = $propDef['type'] ?? 'string'; if (in_array($type, ['string', 'text'], true) === true) { $columnName = $this->sanitizeColumnName(name: $propName); + // Only score columns that actually exist in this table. + // Columns from other schemas are aliased as NULL in the SELECT + // and cannot be referenced by similarity()/ILIKE expressions. + if ($this->columnExistsInTable(tableName: $tableName, columnName: $columnName) === false) { + continue; + } + $quotedCol = $this->quoteIdentifier(name: $columnName, isPostgres: $isPostgres); // Fallback: use CASE with ILIKE for basic relevance scoring. $likePattern = "'%".trim($quotedTerm, "'")."%'"; @@ -1329,7 +1338,12 @@ private function buildUnionSelectPart( // Build WHERE conditions using shared method (single source of truth for filters). // This ensures search, count, and facets all use the same filter logic. - $whereClauses = $this->searchHandler->buildWhereConditionsSql(query: $query, schema: $schema); + // Pass existing columns so property-based search only targets columns in this table. + $whereClauses = $this->searchHandler->buildWhereConditionsSql( + query: $query, + schema: $schema, + existingColumns: $existingColumns + ); if (empty($whereClauses) === false) { $selectSql .= ' WHERE '.implode(' AND ', $whereClauses); diff --git a/lib/Db/MagicMapper/MagicSearchHandler.php b/lib/Db/MagicMapper/MagicSearchHandler.php index b4dd0fe42..a37e400dc 100644 --- a/lib/Db/MagicMapper/MagicSearchHandler.php +++ b/lib/Db/MagicMapper/MagicSearchHandler.php @@ -360,7 +360,7 @@ function ($key) { * * @return string[] Array of SQL WHERE conditions (without leading AND/WHERE). */ - public function buildWhereConditionsSql(array $query, Schema $schema): array + public function buildWhereConditionsSql(array $query, Schema $schema, ?array $existingColumns=null): array { $conditions = []; // Get connection for value quoting through QueryBuilder. @@ -391,7 +391,8 @@ public function buildWhereConditionsSql(array $query, Schema $schema): array search: trim($search), schema: $schema, query: $query, - connection: $connection + connection: $connection, + existingColumns: $existingColumns ); if ($searchCondition !== null) { $conditions[] = $searchCondition; @@ -454,7 +455,8 @@ private function buildSearchConditionSql( string $search, Schema $schema, array $query, - object $connection + object $connection, + ?array $existingColumns=null ): ?string { $searchConditions = []; $likePattern = $connection->quote('%'.$search.'%'); @@ -468,7 +470,12 @@ private function buildSearchConditionSql( foreach ($properties as $propName => $propDef) { $type = $propDef['type'] ?? 'string'; if ($type === 'string') { - $columnName = $this->sanitizeColumnName(name: $propName); + $columnName = $this->sanitizeColumnName(name: $propName); + // In UNION contexts, only search columns that actually exist in this table. + if ($existingColumns !== null && in_array($columnName, $existingColumns, true) === false) { + continue; + } + $searchConditions[] = "{$columnName}::text ILIKE {$likePattern}"; } } diff --git a/lib/Service/NoteService.php b/lib/Service/NoteService.php index 39308b14c..3af4fecb6 100644 --- a/lib/Service/NoteService.php +++ b/lib/Service/NoteService.php @@ -147,6 +147,7 @@ public function createNote(string $objectUuid, string $message): array ); $comment->setMessage($message); + $comment->setVerb('comment'); $this->commentsManager->save($comment); return $this->commentToArray(comment: $comment); From bbbc87777f37f153d523ade5557c9f5ecd88cb41 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 25 Mar 2026 16:39:41 +0100 Subject: [PATCH 2/2] fix: resolve PHPCS errors (named params, implicit true, missing doc params) --- lib/AppInfo/Application.php | 2 +- lib/Controller/TasksController.php | 4 ++-- lib/Db/MagicMapper.php | 4 ++-- lib/Db/MagicMapper/MagicSearchHandler.php | 14 ++++++++------ 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 8be7471c8..93df5790b 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -768,6 +768,6 @@ public function boot(IBootContext $context): void $server = $context->getServerContainer(); $dispatcher = $server->get(IEventDispatcher::class); $registry = $server->get(DeepLinkRegistryService::class); - $dispatcher->dispatchTyped(new DeepLinkRegistrationEvent($registry)); + $dispatcher->dispatchTyped(new DeepLinkRegistrationEvent(registry: $registry)); }//end boot() }//end class diff --git a/lib/Controller/TasksController.php b/lib/Controller/TasksController.php index 0cb863ad6..272509084 100644 --- a/lib/Controller/TasksController.php +++ b/lib/Controller/TasksController.php @@ -108,12 +108,12 @@ public function index( return new JSONResponse(data: ['error' => 'Object not found'], statusCode: 404); } catch (Exception $e) { // No VTODO calendar = no tasks; return empty for listing. - if (str_contains($e->getMessage(), 'No VTODO-supporting calendar')) { + if (str_contains($e->getMessage(), 'No VTODO-supporting calendar') === true) { return new JSONResponse(data: ['results' => [], 'total' => 0]); } return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 500); - } + }//end try }//end index() /** diff --git a/lib/Db/MagicMapper.php b/lib/Db/MagicMapper.php index 04cba55f8..93aeab497 100644 --- a/lib/Db/MagicMapper.php +++ b/lib/Db/MagicMapper.php @@ -1310,7 +1310,7 @@ private function buildUnionSelectPart( continue; } - $quotedCol = $this->quoteIdentifier(name: $columnName, isPostgres: $isPostgres); + $quotedCol = $this->quoteIdentifier(name: $columnName, isPostgres: $isPostgres); // Fallback: use CASE with ILIKE for basic relevance scoring. $likePattern = "'%".trim($quotedTerm, "'")."%'"; $scoreExpr = "CASE WHEN {$quotedCol}::text ILIKE {$likePattern} THEN 1 ELSE 0 END"; @@ -1321,7 +1321,7 @@ private function buildUnionSelectPart( $searchColumns[] = $scoreExpr; } - } + }//end foreach $selectColumns[] = '0 AS _search_score'; if (empty($searchColumns) === false) { diff --git a/lib/Db/MagicMapper/MagicSearchHandler.php b/lib/Db/MagicMapper/MagicSearchHandler.php index a37e400dc..ce9f99df0 100644 --- a/lib/Db/MagicMapper/MagicSearchHandler.php +++ b/lib/Db/MagicMapper/MagicSearchHandler.php @@ -355,8 +355,9 @@ function ($key) { * Includes RBAC filtering when enabled (default). Values are quoted inline * (not parameterized) for UNION query compatibility. * - * @param array $query Search parameters including filters. - * @param Schema $schema The schema for property filtering. + * @param array $query Search parameters including filters. + * @param Schema $schema The schema for property filtering. + * @param array|null $existingColumns Optional list of existing column names. * * @return string[] Array of SQL WHERE conditions (without leading AND/WHERE). */ @@ -444,10 +445,11 @@ private function buildRbacConditionSql(Schema $schema): ?string * Without _fuzzy=true: ~140ms (ILIKE only) * With _fuzzy=true: ~160ms (ILIKE + similarity on _name) * - * @param string $search Trimmed search term - * @param Schema $schema Schema for determining searchable columns - * @param array $query Full query array for extracting _fuzzy param - * @param object $connection Database connection for value quoting + * @param string $search Trimmed search term + * @param Schema $schema Schema for determining searchable columns + * @param array $query Full query array for extracting _fuzzy param + * @param object $connection Database connection for value quoting + * @param array|null $existingColumns Optional list of existing column names. * * @return string|null SQL condition or null if no search conditions generated */