Skip to content
Open
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
25 changes: 20 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,11 +331,26 @@ final class Data
* @param array{
* 'search': array{
* 'nodes': null|list<null|array{
* '__typename': string,
* 'merged'?: bool,
* 'number'?: int,
* 'title'?: string,
* ...,
* '__typename': 'App',
* }|array{
* '__typename': 'Discussion',
* }|array{
* '__typename': 'Issue',
* 'number': int,
* 'title': string,
* }|array{
* '__typename': 'MarketplaceListing',
* }|array{
* '__typename': 'Organization',
* }|array{
* '__typename': 'PullRequest',
* 'merged': bool,
* 'number': int,
* 'title': string,
* }|array{
* '__typename': 'Repository',
* }|array{
* '__typename': 'User',
* }>,
* ...,
* },
Expand Down
25 changes: 20 additions & 5 deletions examples/Generated/Query/Search/Data.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,26 @@ final class Data
* @param array{
* 'search': array{
* 'nodes': null|list<null|array{
* '__typename': string,
* 'merged'?: bool,
* 'number'?: int,
* 'title'?: string,
* ...,
* '__typename': 'App',
* }|array{
* '__typename': 'Discussion',
* }|array{
* '__typename': 'Issue',
* 'number': int,
* 'title': string,
* }|array{
* '__typename': 'MarketplaceListing',
* }|array{
* '__typename': 'Organization',
* }|array{
* '__typename': 'PullRequest',
* 'merged': bool,
* 'number': int,
* 'title': string,
* }|array{
* '__typename': 'Repository',
* }|array{
* '__typename': 'User',
* }>,
* ...,
* },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,26 @@ final class SearchResultItemConnection
/**
* @param array{
* 'nodes': null|list<null|array{
* '__typename': string,
* 'merged'?: bool,
* 'number'?: int,
* 'title'?: string,
* ...,
* '__typename': 'App',
* }|array{
* '__typename': 'Discussion',
* }|array{
* '__typename': 'Issue',
* 'number': int,
* 'title': string,
* }|array{
* '__typename': 'MarketplaceListing',
* }|array{
* '__typename': 'Organization',
* }|array{
* '__typename': 'PullRequest',
* 'merged': bool,
* 'number': int,
* 'title': string,
* }|array{
* '__typename': 'Repository',
* }|array{
* '__typename': 'User',
* }>,
* ...,
* } $data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,7 @@ final class Node
}

public ?AsIssue $asIssue {
get {
if (isset($this->asIssue)) {
return $this->asIssue;
}

if ($this->data['__typename'] !== 'Issue') {
return $this->asIssue = null;
}

if (! array_key_exists('number', $this->data)) {
return $this->asIssue = null;
}

if (! array_key_exists('title', $this->data)) {
return $this->asIssue = null;
}

return $this->asIssue = new AsIssue($this->data);
}
get => $this->asIssue ??= $this->data['__typename'] === 'Issue' ? new AsIssue($this->data) : null;
}

/**
Expand All @@ -56,29 +38,7 @@ final class Node
}

public ?PullRequestInfo $pullRequestInfo {
get {
if (isset($this->pullRequestInfo)) {
return $this->pullRequestInfo;
}

if ($this->data['__typename'] !== 'PullRequest') {
return $this->pullRequestInfo = null;
}

if (! array_key_exists('number', $this->data)) {
return $this->pullRequestInfo = null;
}

if (! array_key_exists('title', $this->data)) {
return $this->pullRequestInfo = null;
}

if (! array_key_exists('merged', $this->data)) {
return $this->pullRequestInfo = null;
}

return $this->pullRequestInfo = new PullRequestInfo($this->data);
}
get => $this->pullRequestInfo ??= $this->data['__typename'] === 'PullRequest' ? new PullRequestInfo($this->data) : null;
}

/**
Expand All @@ -91,11 +51,26 @@ final class Node

/**
* @param array{
* '__typename': string,
* 'merged'?: bool,
* 'number'?: int,
* 'title'?: string,
* ...,
* '__typename': 'App',
* }|array{
* '__typename': 'Discussion',
* }|array{
* '__typename': 'Issue',
* 'number': int,
* 'title': string,
* }|array{
* '__typename': 'MarketplaceListing',
* }|array{
* '__typename': 'Organization',
* }|array{
* '__typename': 'PullRequest',
* 'merged': bool,
* 'number': int,
* 'title': string,
* }|array{
* '__typename': 'Repository',
* }|array{
* '__typename': 'User',
* } $data
*/
public function __construct(
Expand Down
54 changes: 9 additions & 45 deletions src/Generator/DataClassGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -800,51 +800,15 @@ function () use ($plan, $parentType, $nodesType, $fields, $isData, $isMutationDa
return;
}

// For fragments on concrete types when parent is interface/union
// We need to check both typename and required fields
if ($requiredFields === []) {
// No required fields to check, only typename
yield sprintf(
'get => $this->%s ??= $this->data[\'__typename\'] === %s ? %s : null;',
$fieldName,
var_export($nakedFieldType->fragmentType->name(), true),
$construct,
);
} else {
// Generate verbose getter with field checks for PHPStan type safety
yield 'get {';
yield $generator->indent(function () use ($fieldName, $nakedFieldType, $requiredFields, $construct) {
yield sprintf('if (isset($this->%s)) {', $fieldName);
yield ' return $this->' . $fieldName . ';';
yield '}';
yield '';
yield sprintf(
'if ($this->data[\'__typename\'] !== %s) {',
var_export($nakedFieldType->fragmentType->name(), true),
);
yield ' return $this->' . $fieldName . ' = null;';
yield '}';

// Check all required fields exist
foreach ($requiredFields as $requiredField) {
yield '';
yield sprintf(
'if (! array_key_exists(%s, $this->data)) {',
var_export($requiredField, true),
);
yield ' return $this->' . $fieldName . ' = null;';
yield '}';
}

yield '';
yield sprintf(
'return $this->%s = %s;',
$fieldName,
$construct,
);
});
yield '}';
}
// For fragments on concrete types when parent is interface/union,
// the union-of-sealed-arms payload shape lets PHPStan narrow
// by `__typename` alone — no `array_key_exists` guards needed.
yield sprintf(
'get => $this->%s ??= $this->data[\'__typename\'] === %s ? %s : null;',
$fieldName,
var_export($nakedFieldType->fragmentType->name(), true),
$construct,
);

return;
}
Expand Down
96 changes: 95 additions & 1 deletion src/Planner/PayloadShape.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Ruudk\GraphQLCodeGenerator\Planner;

use Ruudk\GraphQLCodeGenerator\Type\StringLiteralType;
use Symfony\Component\TypeInfo\Type as SymfonyType;

/**
Expand All @@ -16,6 +17,16 @@ final class PayloadShape
*/
private array $shape = [];

/**
* Per-variant shapes for polymorphic selections (inline fragments on
* interface/union parents). Keyed by concrete type name; the stored
* `PayloadShape` only holds fields selected inside that variant's
* fragment, not the common fields from the parent selection set.
*
* @var array<string, PayloadShape>
*/
private array $variants = [];

public function addRequired(string $key, SymfonyType $type) : self
{
$this->shape[$key] = $type;
Expand All @@ -33,11 +44,47 @@ public function addOptional(string $key, SymfonyType $type) : self
return $this;
}

public function addVariant(string $typeName, PayloadShape $variantShape) : self
{
// Always store an independent copy. The same source shape is reused
// when distributing an abstract fragment (e.g. `... on Person`) to
// every concrete implementor, and a shared reference would let one
// implementor's later additions leak into the others.
if ( ! isset($this->variants[$typeName])) {
$this->variants[$typeName] = new PayloadShape();
}

$this->variants[$typeName]->merge($variantShape);

// If the source shape itself carried a nested variant whose name
// matches the destination — e.g. distributing an `... on Person` shape
// (which has a nested `... on Employee` variant for `Developer`) to
// the `Developer` implementor — collapse those nested fields into
// this destination directly. Without this, fields from nested inline
// fragments would only live inside an unreachable second-level
// variant and never surface in the emitted arm.
if (isset($variantShape->variants[$typeName])) {
$this->variants[$typeName]->merge($variantShape->variants[$typeName]);
}

return $this;
}

public function has(string $key) : bool
{
return isset($this->shape[$key]);
}

public function hasVariants() : bool
{
return $this->variants !== [];
}

public function hasVariant(string $typeName) : bool
{
return isset($this->variants[$typeName]);
}

/**
* Get the type for a specific key
*/
Expand Down Expand Up @@ -123,11 +170,58 @@ public function merge(PayloadShape $other, bool $asOptional = false) : self
}
}

foreach ($other->variants as $typeName => $variant) {
$this->addVariant($typeName, $asOptional ? $variant->withFieldsOptional() : $variant);
}

return $this;
}

/**
* Return a copy with every direct field demoted to optional. Used when a
* conditional fragment (`@include`/`@skip`) contributes variant shapes —
* the variant arm exists, but each of its fields may or may not show up.
*/
private function withFieldsOptional() : self
{
$clone = new self();

foreach ($this->shape as $key => $value) {
$type = is_array($value) ? $value['type'] : $value;
$clone->addOptional($key, $type);
}

foreach ($this->variants as $typeName => $variant) {
$clone->addVariant($typeName, $variant->withFieldsOptional());
}

return $clone;
}

public function toArrayShape() : SymfonyType
{
return SymfonyType::arrayShape($this->shape, sealed: false);
if ($this->variants === []) {
return SymfonyType::arrayShape($this->shape, sealed: false);
}

$arms = [];

foreach ($this->variants as $typeName => $variant) {
$combined = $this->shape;

foreach ($variant->shape as $key => $value) {
$combined[$key] = $value;
}

$combined['__typename'] = new StringLiteralType($typeName);

// Arms must be sealed: PHPStan's narrowing on `__typename` literal
// only preserves required fields when each arm is sealed. Unsealed
// arms collapse to common-only after narrowing, dropping the
// variant-specific fields the variant subclass constructor needs.
$arms[] = SymfonyType::arrayShape($combined, sealed: true);
}

return count($arms) === 1 ? $arms[0] : SymfonyType::union(...$arms);
}
}
Loading
Loading