Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 21 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,26 @@ Documentation
Full documentation is available at https://doctrine-orm-graphql.apiskeletons.dev or in the [docs](https://github.com/api-skeletons/doctrine-orm-graphql/blob/master/docs) directory.


Versions
Features
--------

* 12.x - Supports [league/event](https://github.com/thephpleague/event) version 3.0 and is PSR-14 compliant
* 11.x - Supports [league/event](https://github.com/thephpleague/event) version 2.2
* Supports all [Doctrine Types](https://doctrine-orm-graphql.apiskeletons.dev/en/latest/types.html#data-type-mappings) and allows custom types
* Pagination with the [GraphQL Complete Connection Model](https://graphql.org/learn/pagination/#complete-connection-model)
* [Filtering of sub-collections](https://doctrine-orm-graphql.apiskeletons.dev/en/latest/queries.html)
* [Events](https://github.com/API-Skeletons/doctrine-orm-graphql#events) for modifying queries, entity types and more
* [Multiple configuration group support](https://doctrine-orm-graphql.apiskeletons.dev/en/latest/driver.html#group)


More information [in the documentation](https://doctrine-orm-graphql.apiskeletons.dev/en/latest/versions.html).
Technical Features
------------------

* Attribute-based metadata
* PHP 8.4 Lazy Ghost Objects for deferred type initialization
* PSR-14 Event-Driven Architecture for query and type customization
* Custom PSR-11 Container with lazy initialization and buildable types
* Advanced hydration system with Doctrine Laminas Hydrator and extraction strategies
* Dynamic QueryBuilder generation with filter translation and event-driven query modification
to solve N+1 query problems


Examples
Expand All @@ -63,16 +76,6 @@ The **LDOG Stack**: Laravel, Doctrine ORM, and GraphQL uses this library: https
For an working implementation see https://graphql.lcdb.org


Features
--------

* Supports all [Doctrine Types](https://doctrine-orm-graphql.apiskeletons.dev/en/latest/types.html#data-type-mappings) and allows custom types
* Pagination with the [GraphQL Complete Connection Model](https://graphql.org/learn/pagination/#complete-connection-model)
* [Filtering of sub-collections](https://doctrine-orm-graphql.apiskeletons.dev/en/latest/queries.html)
* [Events](https://github.com/API-Skeletons/doctrine-orm-graphql#events) for modifying queries, entity types and more
* [Multiple configuration group support](https://doctrine-orm-graphql.apiskeletons.dev/en/latest/driver.html#group)


Quick Start
-----------

Expand Down Expand Up @@ -277,7 +280,10 @@ You may [exclude any filter](https://doctrine-orm-graphql.apiskeletons.dev/en/la
History
-------

The roots of this project go back to May 2018 with https://github.com/API-Skeletons/zf-doctrine-graphql; written for Zend Framework 2. It was migrated to the framework agnostic https://packagist.org/packages/api-skeletons/doctrine-graphql but the name of that repository was incorrect because it did not specify ORM only. So this repository was created and the others were abandoned.
The roots of this project go back to May 2018 with https://github.com/API-Skeletons/zf-doctrine-graphql; written for
Zend Framework 2. It was migrated to the framework agnostic
https://packagist.org/packages/api-skeletons/doctrine-graphql but the name of that repository was incorrect
because it did not specify ORM only. So this repository was created and the others were abandoned.

This was written for the [Live Concert Database](https://lcdb.org)

Expand Down
4 changes: 0 additions & 4 deletions docs/events.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@
Events
======

There are two versions, 11 and 12, of this library which support different event
manager versions. See `Versions and Event Manager Support <versions.html>`_ for
more information.

Query Builder Event
===================

Expand Down
6 changes: 0 additions & 6 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,6 @@ you'll see, there's a lot of customizable power built in too.
technical/internals
technical/migration-guide

.. toctree::
:maxdepth: 1
:caption: Reference

technical/api-index
technical/changelog

.. role:: raw-html(raw)
:format: html
Expand Down
6 changes: 0 additions & 6 deletions docs/technical/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,6 @@ Documentation Structure
internals
migration-guide

.. toctree::
:maxdepth: 1
:caption: Reference

api-index
changelog

Conventions Used
================
Expand Down
24 changes: 7 additions & 17 deletions src/Resolve/ResolveCollectionFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
use League\Event\EventDispatcher;

use function array_flip;
use function array_key_first;
use function count;
use function in_array;

Expand Down Expand Up @@ -102,31 +101,22 @@ protected function buildPagination(

// Handle different association types
if (isset($association['joinTable'])) {
// Many-to-many relationship
$identifierValues = $sourceMetadata->getIdentifierValues($source);
$sourceId = $identifierValues[array_key_first($identifierValues)];

$joinTable = $association['joinTable']['name'];
$joinColumns = $association['joinTable']['joinColumns'];
$inverseJoinColumns = $association['joinTable']['inverseJoinColumns'];

$queryBuilder->innerJoin(
$joinTable,
'jt',
'WITH',
'jt.' . $inverseJoinColumns[0]['name'] . ' = entity.id',
);
$queryBuilder->where('jt.' . $joinColumns[0]['name'] . ' = :sourceId');
$queryBuilder->setParameter('sourceId', $sourceId);
// Many-to-many relationship (owning side with join table)
// Use Doctrine's association mapping instead of manual join table handling
$queryBuilder->innerJoin($entityClassName, 'source', 'WITH', ':source MEMBER OF source.' . $associationName);
$queryBuilder->setParameter('source', $source);
} elseif (isset($association['mappedBy'])) {
// One-to-many: target entity has the foreign key
$queryBuilder->where('entity.' . $association['mappedBy'] . ' = :source');
$queryBuilder->setParameter('source', $source);
// @codeCoverageIgnoreStart
} elseif (isset($association['inversedBy'])) {
// Many-to-one from the owning side (less common for collections)
// This is defensively handled here for completeness
$queryBuilder->innerJoin($entityClassName, 'source', 'WITH', 'source.' . $associationName . ' = entity');
$queryBuilder->where('source = :source');
$queryBuilder->setParameter('source', $source);
// @codeCoverageIgnoreEnd
}

// Apply filters using QueryBuilder
Expand Down
108 changes: 108 additions & 0 deletions test/Feature/Association/InversedByCollectionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

declare(strict_types=1);

namespace ApiSkeletonsTest\Doctrine\ORM\GraphQL\Feature\Association;

use ApiSkeletons\Doctrine\ORM\GraphQL\Config;
use ApiSkeletons\Doctrine\ORM\GraphQL\Driver;
use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\Recording;
use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\User;
use ApiSkeletonsTest\Doctrine\ORM\GraphQL\TestCase;
use GraphQL\GraphQL;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Schema;

class InversedByCollectionTest extends TestCase
{
/**
* Test querying a ManyToMany collection from the owning side (inversedBy)
*/
public function testUserRecordingsCollection(): void
{
$config = new Config(['group' => 'CustomFieldStrategyTest']);

$driver = new Driver($this->getEntityManager(), $config);

$schema = new Schema([
'query' => new ObjectType([
'name' => 'query',
'fields' => [
'user' => $driver->completeConnection(User::class),
'recording' => $driver->completeConnection(Recording::class),
],
]),
]);

// Query users and their recordings
$query = '{
user(pagination: { first: 5 }) {
edges {
node {
name
recordings(pagination: { first: 10 }) {
edges {
node {
source
}
}
totalCount
}
}
}
totalCount
}
}';

$result = GraphQL::executeQuery($schema, $query);
$output = $result->toArray();

$this->assertArrayNotHasKey('errors', $output);
$this->assertIsArray($output['data']['user']['edges']);
}

/**
* Test querying with filters on the inversedBy collection
*/
public function testUserRecordingsWithFilters(): void
{
$config = new Config(['group' => 'CustomFieldStrategyTest']);

$driver = new Driver($this->getEntityManager(), $config);

$schema = new Schema([
'query' => new ObjectType([
'name' => 'query',
'fields' => [
'user' => $driver->completeConnection(User::class),
],
]),
]);

// Query users with filtered recordings
$query = '{
user(pagination: { first: 1 }) {
edges {
node {
name
recordings(
filter: { source: { contains: "tape" } }
pagination: { first: 5 }
) {
edges {
node {
source
}
}
}
}
}
}
}';

$result = GraphQL::executeQuery($schema, $query);
$output = $result->toArray();

$this->assertArrayNotHasKey('errors', $output);
}
}
16 changes: 15 additions & 1 deletion test/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Doctrine\ORM\Tools\SchemaTool;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;

use function count;
use function date;
use function file_get_contents;

Expand Down Expand Up @@ -50,7 +51,8 @@ protected function getEntityManager(): EntityManager

protected function populateData(): void
{
$users = [
$userEntities = [];
$users = [
[
'name' => 'User one',
'email' => 'userOne@gmail.com',
Expand Down Expand Up @@ -139,8 +141,10 @@ protected function populateData(): void
$user->setName($userData['name']);
$user->setEmail($userData['email']);
$user->setPassword($userData['password']);
$userEntities[] = $user;
}

$recordingIndex = 0;
foreach ($artists as $name => $performances) {
$artist = (new Entity\Artist())
->setName($name);
Expand All @@ -164,6 +168,16 @@ protected function populateData(): void
->setSource($source)
->setPerformance($performance);
self::$entityManager->persist($recording);

// Link recordings to users for testing ManyToMany relationships
if (empty($userEntities)) {
continue;
}

$userIndex = $recordingIndex % count($userEntities);
$userEntities[$userIndex]->addRecording($recording);
$recording->addUser($userEntities[$userIndex]);
$recordingIndex++;
}
}
}
Expand Down
Loading
Loading