From ee24ce2719cb21bcdfea81bca51822e891341c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20Vaculi=CC=81k?= Date: Thu, 26 Feb 2026 09:42:01 +0100 Subject: [PATCH 1/2] Collection: support filtering by non-main-side relationship without ->id When filtering by a relationship property that does not hold the FK column (ONE_HAS_ONE non-main side, ONE_HAS_MANY, MANY_HAS_MANY), the ORM now automatically joins the target table and compares against its primary key. Previously, only the main side worked (e.g. m:1 or 1:1 isMain) because the FK column lives in the current table. The non-main side would produce invalid SQL referencing a non-existent column. Example: findBy(['departure!=' => null]) on Alarm, where departure is a non-main 1:1 relationship, now correctly emits a LEFT JOIN instead of trying to use a non-existent column. Closes #574 --- .../Functions/FetchPropertyFunction.php | 30 ++++++++++++++++ .../Collection/collection.where.phpt | 34 +++++++++++++++++++ ...st_testFilterByNonMainSideRelationship.sql | 21 ++++++++++++ ...estFilterByNonMainSideRelationshipNull.sql | 19 +++++++++++ 4 files changed, 104 insertions(+) create mode 100644 tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionWhereTest_testFilterByNonMainSideRelationship.sql create mode 100644 tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionWhereTest_testFilterByNonMainSideRelationshipNull.sql diff --git a/src/Collection/Functions/FetchPropertyFunction.php b/src/Collection/Functions/FetchPropertyFunction.php index 78d532820..afe171128 100644 --- a/src/Collection/Functions/FetchPropertyFunction.php +++ b/src/Collection/Functions/FetchPropertyFunction.php @@ -241,6 +241,36 @@ private function processTokens( throw new InvalidArgumentException("Property expression '$propertyExpression' does not fetch specific property."); } + if ($propertyMetadata->relationship !== null) { + $relType = $propertyMetadata->relationship->type; + if ( + ($relType === Relationship::ONE_HAS_ONE && !$propertyMetadata->relationship->isMain) || + $relType === Relationship::ONE_HAS_MANY || + $relType === Relationship::MANY_HAS_MANY + ) { + $allTokens = [...$tokens, $lastToken]; + [ + $currentAlias, + $currentConventions, + $currentEntityMetadata, + ] = $this->processRelationship( + $allTokens, + $joins, + $propertyMetadata, + $aggregator, + $currentConventions, + $currentMapper, + $currentAlias, + $lastToken, + count($allTokens) - 1, + $makeDistinct, + ); + $primaryKey = $currentEntityMetadata->getPrimaryKey(); + $lastToken = $primaryKey[0]; + $propertyMetadata = $currentEntityMetadata->getProperty($lastToken); + } + } + $column = $this->toColumnExpr( $currentEntityMetadata, $propertyMetadata, diff --git a/tests/cases/integration/Collection/collection.where.phpt b/tests/cases/integration/Collection/collection.where.phpt index c6789d07e..103b102d2 100644 --- a/tests/cases/integration/Collection/collection.where.phpt +++ b/tests/cases/integration/Collection/collection.where.phpt @@ -175,6 +175,40 @@ class CollectionWhereTest extends DataTestCase } + public function testFilterByNonMainSideRelationship(): void + { + // Non-main side of 1:1 relationship (Ean.book is non-main, FK ean_id is in books table) + $ean = new Ean(); + $ean->code = '1234'; + $ean->book = $this->orm->books->getByIdChecked(1); + $this->orm->eans->persistAndFlush($ean); + $this->orm->clear(); + + $fetched = $this->orm->eans->findBy(['book' => 1])->fetch(); + Assert::notNull($fetched); + Assert::equal('1234', $fetched->code); + + Assert::null($this->orm->eans->findBy(['book' => 2])->fetch()); + } + + + public function testFilterByNonMainSideRelationshipNull(): void + { + // Non-main side of 1:1 self-referential relationship (Book.previousPart is non-main side) + // Book 4 has next_part = 3, so Book 3 has a previousPart (= Book 4) + // Books 1, 2, 4 have no previousPart + + $booksWithNoPrevious = $this->orm->books->findBy(['previousPart' => null]); + Assert::count(3, $booksWithNoPrevious); + + $booksWithPrevious = $this->orm->books->findBy(['previousPart!=' => null]); + Assert::count(1, $booksWithPrevious); + $book = $booksWithPrevious->fetch(); + Assert::notNull($book); + Assert::equal(3, $book->id); + } + + private function moveToDifferentZone(DateTimeImmutable $dateTime): DateTimeImmutable { return $dateTime->setTimezone(new DateTimeZone("UTC")); diff --git a/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionWhereTest_testFilterByNonMainSideRelationship.sql b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionWhereTest_testFilterByNonMainSideRelationship.sql new file mode 100644 index 000000000..aa945ce6e --- /dev/null +++ b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionWhereTest_testFilterByNonMainSideRelationship.sql @@ -0,0 +1,21 @@ +SELECT "books".* FROM "books" AS "books" WHERE "books"."id" = 1; +START TRANSACTION; +INSERT INTO "eans" ("code", "type") VALUES ('1234', 2); +SELECT CURRVAL('public.eans_id_seq'); +UPDATE "books" SET "ean_id" = 1 WHERE "id" = 1; +COMMIT; +SELECT + "eans".* +FROM + "eans" AS "eans" + LEFT JOIN "books" AS "book" ON ("eans"."id" = "book"."ean_id") +WHERE + "book"."id" = 1; + +SELECT + "eans".* +FROM + "eans" AS "eans" + LEFT JOIN "books" AS "book" ON ("eans"."id" = "book"."ean_id") +WHERE + "book"."id" = 2; diff --git a/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionWhereTest_testFilterByNonMainSideRelationshipNull.sql b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionWhereTest_testFilterByNonMainSideRelationshipNull.sql new file mode 100644 index 000000000..c26d7954d --- /dev/null +++ b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionWhereTest_testFilterByNonMainSideRelationshipNull.sql @@ -0,0 +1,19 @@ +SELECT + "books".* +FROM + "books" AS "books" + LEFT JOIN "books" AS "previousPart" ON ( + "books"."id" = "previousPart"."next_part" + ) +WHERE + "previousPart"."id" IS NULL; + +SELECT + "books".* +FROM + "books" AS "books" + LEFT JOIN "books" AS "previousPart" ON ( + "books"."id" = "previousPart"."next_part" + ) +WHERE + "previousPart"."id" IS NOT NULL; From 6b2ff21ad0f05b66ab918453f7e12de06f38cbf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20Vaculi=CC=81k?= Date: Fri, 27 Feb 2026 08:12:42 +0100 Subject: [PATCH 2/2] Collection: skip JOIN for main-side relationship->id When the last token is the primary key of a main-side relationship (MANY_HAS_ONE / ONE_HAS_ONE isMain), use the FK column directly instead of joining the related table. E.g. `author->id` becomes `author_id` without an unnecessary JOIN to the authors table. --- .../Functions/FetchPropertyFunction.php | 12 ++++++ ...onAggregationJoinTest_testAnyDependent.sql | 40 +++++++------------ ...Test_testCountStoredAndFutureFiltering.sql | 28 +------------ .../CollectionTest_testOrdering.sql | 23 +---------- .../CollectionTest_testOrderingMultiple.sql | 23 +---------- ...oredOnManyHasManyRelationshipCondition.sql | 20 ++-------- 6 files changed, 36 insertions(+), 110 deletions(-) diff --git a/src/Collection/Functions/FetchPropertyFunction.php b/src/Collection/Functions/FetchPropertyFunction.php index afe171128..1972d0a85 100644 --- a/src/Collection/Functions/FetchPropertyFunction.php +++ b/src/Collection/Functions/FetchPropertyFunction.php @@ -207,6 +207,18 @@ private function processTokens( foreach ($tokens as $tokenIndex => $token) { $property = $currentEntityMetadata->getProperty($token); if ($property->relationship !== null) { + $relType = $property->relationship->type; + $isMainSide = $relType === Relationship::MANY_HAS_ONE + || ($relType === Relationship::ONE_HAS_ONE && $property->relationship->isMain); + if ( + $isMainSide + && $tokenIndex === count($tokens) - 1 + && in_array($lastToken, $property->relationship->entityMetadata->getPrimaryKey(), strict: true) + ) { + $lastToken = $token; + break; + } + [ $currentAlias, $currentConventions, diff --git a/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionAggregationJoinTest_testAnyDependent.sql b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionAggregationJoinTest_testAnyDependent.sql index 942b99a79..6cc87b1d6 100644 --- a/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionAggregationJoinTest_testAnyDependent.sql +++ b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionAggregationJoinTest_testAnyDependent.sql @@ -5,13 +5,10 @@ FROM LEFT JOIN "books" AS "books_any" ON ( "authors"."id" = "books_any"."author_id" ) - LEFT JOIN "public"."authors" AS "books_translator_any" ON ( - "books_any"."translator_id" = "books_translator_any"."id" - ) WHERE ("books_any"."title" = 'Book 1') AND ( - "books_translator_any"."id" IS NULL + "books_any"."translator_id" IS NULL ) GROUP BY "authors"."id"; @@ -27,13 +24,10 @@ FROM LEFT JOIN "books" AS "books_any" ON ( "authors"."id" = "books_any"."author_id" ) - LEFT JOIN "public"."authors" AS "books_translator_any" ON ( - "books_any"."translator_id" = "books_translator_any"."id" - ) WHERE ("books_any"."title" = 'Book 1') AND ( - "books_translator_any"."id" IS NULL + "books_any"."translator_id" IS NULL ) GROUP BY "authors"."id" @@ -45,7 +39,10 @@ FROM "public"."authors" AS "authors" LEFT JOIN "books" AS "books_count" ON ( ( - "authors"."id" = "books_count"."author_id" + ( + "authors"."id" = "books_count"."author_id" + ) + AND "books_count"."translator_id" IS NOT NULL ) OR ( ( @@ -54,18 +51,12 @@ FROM AND "books_count"."price" < 100 ) ) - LEFT JOIN "public"."authors" AS "books_translator_count" ON ( - ( - "books_count"."translator_id" = "books_translator_count"."id" - ) - AND "books_translator_count"."id" IS NOT NULL - ) GROUP BY "authors"."id" HAVING ( - COUNT("books_translator_count"."id") >= 1 - AND COUNT("books_translator_count"."id") <= 1 + COUNT("books_count"."id") >= 1 + AND COUNT("books_count"."id") <= 1 ) OR ( COUNT("books_count"."id") >= 1 @@ -82,7 +73,10 @@ FROM "public"."authors" AS "authors" LEFT JOIN "books" AS "books_count" ON ( ( - "authors"."id" = "books_count"."author_id" + ( + "authors"."id" = "books_count"."author_id" + ) + AND "books_count"."translator_id" IS NOT NULL ) OR ( ( @@ -91,18 +85,12 @@ FROM AND "books_count"."price" < 100 ) ) - LEFT JOIN "public"."authors" AS "books_translator_count" ON ( - ( - "books_count"."translator_id" = "books_translator_count"."id" - ) - AND "books_translator_count"."id" IS NOT NULL - ) GROUP BY "authors"."id" HAVING ( - COUNT("books_translator_count"."id") >= 1 - AND COUNT("books_translator_count"."id") <= 1 + COUNT("books_count"."id") >= 1 + AND COUNT("books_count"."id") <= 1 ) OR ( COUNT("books_count"."id") >= 1 diff --git a/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testCountStoredAndFutureFiltering.sql b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testCountStoredAndFutureFiltering.sql index 59fa3124b..2e909231f 100644 --- a/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testCountStoredAndFutureFiltering.sql +++ b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testCountStoredAndFutureFiltering.sql @@ -1,26 +1,2 @@ -SELECT - COUNT(*) AS count -FROM - ( - SELECT - "books"."id" - FROM - "books" AS "books" - LEFT JOIN "public"."authors" AS "author" ON ( - "books"."author_id" = "author"."id" - ) - WHERE - "author"."id" > 0 - ) temp; - -SELECT - "books".* -FROM - "books" AS "books" - LEFT JOIN "public"."authors" AS "author" ON ( - "books"."author_id" = "author"."id" - ) -WHERE - "author"."id" > 0 -ORDER BY - "author"."id" ASC; +SELECT COUNT(*) AS count FROM (SELECT "books"."id" FROM "books" AS "books" WHERE "books"."author_id" > 0) temp; +SELECT "books".* FROM "books" AS "books" WHERE "books"."author_id" > 0 ORDER BY "books"."author_id" ASC; diff --git a/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testOrdering.sql b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testOrdering.sql index 44252c50e..980a2ec92 100644 --- a/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testOrdering.sql +++ b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testOrdering.sql @@ -1,21 +1,2 @@ -SELECT - "books".* -FROM - "books" AS "books" - LEFT JOIN "public"."authors" AS "author" ON ( - "books"."author_id" = "author"."id" - ) -ORDER BY - "author"."id" DESC, - "books"."title" ASC; - -SELECT - "books".* -FROM - "books" AS "books" - LEFT JOIN "public"."authors" AS "author" ON ( - "books"."author_id" = "author"."id" - ) -ORDER BY - "author"."id" DESC, - "books"."title" DESC; +SELECT "books".* FROM "books" AS "books" ORDER BY "books"."author_id" DESC, "books"."title" ASC; +SELECT "books".* FROM "books" AS "books" ORDER BY "books"."author_id" DESC, "books"."title" DESC; diff --git a/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testOrderingMultiple.sql b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testOrderingMultiple.sql index 44252c50e..980a2ec92 100644 --- a/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testOrderingMultiple.sql +++ b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testOrderingMultiple.sql @@ -1,21 +1,2 @@ -SELECT - "books".* -FROM - "books" AS "books" - LEFT JOIN "public"."authors" AS "author" ON ( - "books"."author_id" = "author"."id" - ) -ORDER BY - "author"."id" DESC, - "books"."title" ASC; - -SELECT - "books".* -FROM - "books" AS "books" - LEFT JOIN "public"."authors" AS "author" ON ( - "books"."author_id" = "author"."id" - ) -ORDER BY - "author"."id" DESC, - "books"."title" DESC; +SELECT "books".* FROM "books" AS "books" ORDER BY "books"."author_id" DESC, "books"."title" ASC; +SELECT "books".* FROM "books" AS "books" ORDER BY "books"."author_id" DESC, "books"."title" DESC; diff --git a/tests/sqls/NextrasTests/Orm/Integration/Relationships/RelationshipManyHasManyTest_testCountStoredOnManyHasManyRelationshipCondition.sql b/tests/sqls/NextrasTests/Orm/Integration/Relationships/RelationshipManyHasManyTest_testCountStoredOnManyHasManyRelationshipCondition.sql index 1f7c3de63..a55c611a9 100644 --- a/tests/sqls/NextrasTests/Orm/Integration/Relationships/RelationshipManyHasManyTest_testCountStoredOnManyHasManyRelationshipCondition.sql +++ b/tests/sqls/NextrasTests/Orm/Integration/Relationships/RelationshipManyHasManyTest_testCountStoredOnManyHasManyRelationshipCondition.sql @@ -4,14 +4,11 @@ SELECT "books_x_tags"."tag_id" FROM "books" AS "books" - LEFT JOIN "public"."authors" AS "author" ON ( - "books"."author_id" = "author"."id" - ) LEFT JOIN "books_x_tags" AS "books_x_tags" ON ( "books_x_tags"."book_id" = "books"."id" ) WHERE - ("author"."id" = 1) + ("books"."author_id" = 1) AND ( "books_x_tags"."tag_id" IN (1) ); @@ -24,14 +21,11 @@ SELECT ) AS "count" FROM "books" AS "books" - LEFT JOIN "public"."authors" AS "author" ON ( - "books"."author_id" = "author"."id" - ) LEFT JOIN "books_x_tags" AS "books_x_tags" ON ( "books_x_tags"."book_id" = "books"."id" ) WHERE - ("author"."id" = 1) + ("books"."author_id" = 1) AND ( "books_x_tags"."tag_id" IN (1) ) @@ -53,15 +47,12 @@ FROM LEFT JOIN "tag_followers" AS "author_tagFollowers_any" ON ( "author"."id" = "author_tagFollowers_any"."author_id" ) - LEFT JOIN "public"."authors" AS "author_tagFollowers_author_any" ON ( - "author_tagFollowers_any"."author_id" = "author_tagFollowers_author_any"."id" - ) LEFT JOIN "books_x_tags" AS "books_x_tags" ON ( "books_x_tags"."book_id" = "books"."id" ) WHERE ( - "author_tagFollowers_author_any"."id" = 1 + "author_tagFollowers_any"."author_id" = 1 ) AND ( "books_x_tags"."tag_id" IN (1) @@ -89,15 +80,12 @@ FROM LEFT JOIN "tag_followers" AS "author_tagFollowers_any" ON ( "author"."id" = "author_tagFollowers_any"."author_id" ) - LEFT JOIN "public"."authors" AS "author_tagFollowers_author_any" ON ( - "author_tagFollowers_any"."author_id" = "author_tagFollowers_author_any"."id" - ) LEFT JOIN "books_x_tags" AS "books_x_tags" ON ( "books_x_tags"."book_id" = "books"."id" ) WHERE ( - "author_tagFollowers_author_any"."id" = 1 + "author_tagFollowers_any"."author_id" = 1 ) AND ( "books_x_tags"."tag_id" IN (1)