diff --git a/lib/Service/Query/RelatedRowExistsClause.php b/lib/Service/Query/RelatedRowExistsClause.php index baf5ebbef3..0d1d1be5aa 100644 --- a/lib/Service/Query/RelatedRowExistsClause.php +++ b/lib/Service/Query/RelatedRowExistsClause.php @@ -120,7 +120,7 @@ final class RelatedRowExistsClause { * * @param RelatedRowFilter $filter The parsed filter. * @param string $engine The database engine. - * @param string $table The objects table. + * @param string $table The related table as SQL names it, `*PREFIX*`-qualified. * @param string $outerAlias The alias of the outer object row. * @param string $innerAlias The alias to give the related row. * @param string $accessPredicate SQL restricting the related rows to ones the caller may read. diff --git a/lib/Service/Query/RelatedRowQueryApplier.php b/lib/Service/Query/RelatedRowQueryApplier.php index 66f9cbe36a..5c91d0cef9 100644 --- a/lib/Service/Query/RelatedRowQueryApplier.php +++ b/lib/Service/Query/RelatedRowQueryApplier.php @@ -123,7 +123,9 @@ public function apply(IQueryBuilder $qb, array $query, Register $register, strin $rendered = $clause->render( filter: $filter, engine: $engine, - table: $this->tableHandler->getTableNameForRegisterSchema(register: $register, schema: $schema), + // Raw SQL gets no automatic table prefix; Nextcloud's connection + // replaces `*PREFIX*` with the instance's (`oc_` by default). + table: '*PREFIX*'.$this->tableHandler->getTableNameForRegisterSchema(register: $register, schema: $schema), outerAlias: $outerAlias, innerAlias: $alias, // 🔴 THE ACCESS PREDICATE FOR THE RELATED SCHEMA, NOT THE OUTER diff --git a/tests/Unit/Service/Query/ReferencedRowFilterTest.php b/tests/Unit/Service/Query/ReferencedRowFilterTest.php index f5e71a361b..360722bcab 100644 --- a/tests/Unit/Service/Query/ReferencedRowFilterTest.php +++ b/tests/Unit/Service/Query/ReferencedRowFilterTest.php @@ -130,7 +130,7 @@ public function testTheApplierAddsForwardBlocksWithTheReferencedSchemasOwnAccess $schemaMapper->expects($this->once())->method('findBySlug')->with('subsidieAanvraag', 2)->willReturn([$schema]); $tableHandler = $this->createMock(MagicTableHandler::class); - $tableHandler->method('getTableNameForRegisterSchema')->willReturn('oc_openregister_table_1_7'); + $tableHandler->method('getTableNameForRegisterSchema')->willReturn('openregister_table_1_7'); $tableHandler->method('existsTableForRegisterSchema')->willReturn(true); $rbac = $this->createMock(MagicRbacHandler::class); diff --git a/tests/Unit/Service/Query/RelatedRowPrefixedDatabaseTest.php b/tests/Unit/Service/Query/RelatedRowPrefixedDatabaseTest.php new file mode 100644 index 0000000000..b6ee652591 --- /dev/null +++ b/tests/Unit/Service/Query/RelatedRowPrefixedDatabaseTest.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 + * + * @link https://www.OpenRegister.app + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +declare(strict_types=1); + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- arrange/act/assert PHPUnit conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit and DBAL helpers use positional args. + +namespace OCA\OpenRegister\Tests\Unit\Service\Query; + +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; +use OCA\OpenRegister\Db\MagicMapper\MagicRbacHandler; +use OCA\OpenRegister\Db\MagicMapper\MagicTableHandler; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Query\RelatedRowQueryApplier; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * @covers \OCA\OpenRegister\Service\Query\RelatedRowQueryApplier + * @covers \OCA\OpenRegister\Service\Query\RelatedRowExistsClause + */ +class RelatedRowPrefixedDatabaseTest extends TestCase { + + /** + * The instance's table prefix. + * + * @var string + */ + private const PREFIX = 'oc_'; + + /** @var Connection An in-memory SQLite database with prefixed magic tables. */ + private Connection $db; + + protected function setUp(): void { + $this->db = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + + // The beschikking table (outer, schema 3) and the aanvraag table (referenced, schema 2). + $this->db->executeStatement('CREATE TABLE oc_openregister_table_1_3 (_uuid TEXT, _deleted TEXT, "subsidieaanvraag" TEXT)'); + $this->db->executeStatement('CREATE TABLE oc_openregister_table_1_2 (_uuid TEXT, _deleted TEXT, _owner TEXT, "case" TEXT)'); + + $this->db->executeStatement( + 'INSERT INTO oc_openregister_table_1_2 (_uuid, _owner, "case") VALUES (\'a-1\', \'alice\', \'case-1\'), (\'a-2\', \'alice\', \'case-2\')' + ); + $this->db->executeStatement( + 'INSERT INTO oc_openregister_table_1_3 (_uuid, "subsidieaanvraag") VALUES (\'b-1\', \'a-1\'), (\'b-2\', \'a-2\')' + ); + } + + /** + * The applier's clauses for one query, with their parameters. + * + * @param array $query The request query. + * + * @return array{0: array, 1: array} + */ + private function clausesFor(array $query): array { + $schema = new Schema(); + $schema->setId(2); + $schemaMapper = $this->createMock(SchemaMapper::class); + $schemaMapper->method('findBySlug')->willReturn([$schema]); + + // The real handler answers the bare name; the prefix is added in SQL. + $tableHandler = $this->createMock(MagicTableHandler::class); + $tableHandler->method('getTableNameForRegisterSchema')->willReturn('openregister_table_1_2'); + $tableHandler->method('existsTableForRegisterSchema')->willReturn(true); + + $rbac = $this->createMock(MagicRbacHandler::class); + $rbac->method('buildRbacPredicateForAlias')->willReturn("rel0._owner = 'alice'"); + + $where = []; + $parameters = []; + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('createFunction')->willReturnArgument(0); + $qb->method('andWhere')->willReturnCallback(function (string $sql) use (&$where, $qb) { + $where[] = $sql; + return $qb; + }); + $qb->method('setParameter')->willReturnCallback(function (string $name, mixed $value) use (&$parameters, $qb) { + $parameters[$name] = $value; + return $qb; + }); + + $register = new Register(); + $register->setId(1); + + (new RelatedRowQueryApplier( + schemaMapper: $schemaMapper, + tableHandler: $tableHandler, + rbacHandler: $rbac, + db: $this->createMock(IDBConnection::class), + logger: new NullLogger() + ))->apply(qb: $qb, query: $query, register: $register); + + return [$where, $parameters]; + } + + /** + * Run the outer query as Nextcloud's connection would: `*PREFIX*` replaced first. + * + * @param array $where The clauses. + * @param array $parameters The bindings. + * + * @return array The matching outer uuids. + */ + private function outerUuidsMatching(array $where, array $parameters): array { + $sql = 'SELECT t._uuid FROM *PREFIX*openregister_table_1_3 t WHERE '.implode(' AND ', $where).' ORDER BY t._uuid'; + + return $this->db->fetchFirstColumn(str_replace('*PREFIX*', self::PREFIX, $sql), $parameters); + } + + public function testAReferencedFilterRunsOnAPrefixedDatabase(): void { + [$where, $parameters] = $this->clausesFor( + ['_referenced' => ['subsidieAanvraag' => ['subsidieaanvraag' => ['case' => 'case-1']]]] + ); + + $this->assertSame(['b-1'], $this->outerUuidsMatching($where, $parameters)); + } + + public function testItMatchesNothingWhenNoReferencedRowQualifies(): void { + [$where, $parameters] = $this->clausesFor( + ['_referenced' => ['subsidieAanvraag' => ['subsidieaanvraag' => ['case' => 'case-9']]]] + ); + + $this->assertSame([], $this->outerUuidsMatching($where, $parameters)); + } +}//end class diff --git a/tests/Unit/Service/Query/RelatedRowQueryApplierTest.php b/tests/Unit/Service/Query/RelatedRowQueryApplierTest.php index 2e14f713fa..caeb5d3be6 100644 --- a/tests/Unit/Service/Query/RelatedRowQueryApplierTest.php +++ b/tests/Unit/Service/Query/RelatedRowQueryApplierTest.php @@ -63,7 +63,7 @@ private function applierFinding(array $found, bool $tableExists=true): RelatedRo $schemaMapper->method('findBySlug')->willReturn($found); $tableHandler = $this->createMock(MagicTableHandler::class); - $tableHandler->method('getTableNameForRegisterSchema')->willReturn('oc_openregister_table_1_2'); + $tableHandler->method('getTableNameForRegisterSchema')->willReturn('openregister_table_1_2'); $tableHandler->method('existsTableForRegisterSchema')->willReturn($tableExists); $rbac = $this->createMock(MagicRbacHandler::class);