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
130 changes: 130 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ enum SearchType: string
- **Custom `@indexBy` directive** for O(1) lookups instead of O(n) searching
- **Custom `@throwWhenNull` directive** - drop nullability per-field and throw a typed exception when the server returns null
- **Custom `@hook` directive** - enrich responses with local data resolved lazily at access time
- **Operation arguments** via `withOperationArgument()` - inject extra typed parameters (e.g. an actor) into `execute()` and forward them to your client, optionally gated by a custom directive
- **Fragment dependency injection** - automatically includes required fragments
- **Automatic query optimization** - merges fragments, simplifies inline fragments

Expand Down Expand Up @@ -1023,6 +1024,135 @@ Notes:
- Omitting `batched` (or `batched: false`) keeps the existing per-instance behaviour — this is
not a breaking change. Legacy and batched hooks can be mixed in one query.

### 🎭 Inject Extra Arguments with `withOperationArgument()`

Sometimes a generated operation needs to forward something to your client that is *not* a GraphQL
variable — an actor, a tenant, a correlation id, an idempotency key. `withOperationArgument()`
prepends an extra typed parameter to the generated `execute()` / `executeOrThrow()` methods and
forwards it positionally to your client's `graphql()` call (right after the operation name):

```php
use Symfony\Component\TypeInfo\Type;

Config::create(/* ... */)
->withOperationArgument(
name: 'actor',
type: Type::object(Actor::class),
directive: 'requiresActor', // opt-in: only operations tagged @requiresActor
);
```

- **Opt-in via a directive** — pass `directive: 'requiresActor'` and only operations carrying
`@requiresActor` receive the extra parameter. The directive is local to your operation: it is
registered automatically (so validation passes) and stripped from the query sent to the server.
- **Apply to every operation** — omit `directive` and the parameter is injected into *all*
operations.
- **Restrict by operation type** — pass `operations: ['mutation']` (or `['query']`) to limit which
operation types the argument may target. The default is an empty array, which allows any type.
- Call `withOperationArgument()` multiple times to inject more than one argument; they are emitted
in registration order, before the GraphQL variables.

<!-- source: tests/OperationArgument/CreateThing.graphql -->
```graphql
mutation CreateThing($name: String!) @requiresActor {
createThing(name: $name) {
id
name
}
}
```

**Generated code** — `Actor $actor` leads the signature and is forwarded to `graphql()`, while the
`@requiresActor` directive is gone from `OPERATION_DEFINITION`:

<!-- source: tests/OperationArgument/Generated/Mutation/CreateThing/CreateThingMutation.php -->
```php
<?php

declare(strict_types=1);

namespace Ruudk\GraphQLCodeGenerator\OperationArgument\Generated\Mutation\CreateThing;

use Ruudk\GraphQLCodeGenerator\OperationArgument\Actor;
use Ruudk\GraphQLCodeGenerator\OperationArgument\ActorTestClient;
use Stringable;

// This file was automatically generated and should not be edited.

final readonly class CreateThingMutation {
public const string OPERATION_NAME = 'CreateThing';
public const string OPERATION_DEFINITION = <<<'GRAPHQL'
mutation CreateThing($name: String!) {
createThing(name: $name) {
id
name
}
}

GRAPHQL;

public function __construct(
private ActorTestClient $client,
) {}

/**
* @api
*/
public function execute(
Actor $actor,
Stringable|string $name,
) : Data {
$data = $this->client->graphql(
self::OPERATION_DEFINITION,
[
'name' => (string) $name,
],
self::OPERATION_NAME,
actor: $actor,
);

return new Data(
$data['data'] ?? [], // @phpstan-ignore argument.type
$data['errors'] ?? [] // @phpstan-ignore argument.type
);
}

/**
* @api
* @throws CreateThingMutationFailedException
*/
public function executeOrThrow(
Actor $actor,
Stringable|string $name,
) : Data {
$data = $this->execute(
$actor,
$name,
);

if ($data->errors !== []) {
throw new CreateThingMutationFailedException($data);
}

return $data;
}
}
```

Your client's `graphql()` method receives the extra argument(s) positionally, so add them to its
signature (make them optional if only some operations forward them):

```php
public function graphql(
string $query,
array $variables = [],
?string $operationName = null,
?Actor $actor = null,
) : array {
// forward $actor to the transport — e.g. as a header
}
```

## Requirements

- **PHP 8.4+** (uses property hooks, readonly classes, and other modern features)
Expand Down
44 changes: 44 additions & 0 deletions src/Config/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
* @param list<string> $inlineProcessingDirectories
* @param list<string> $twigProcessingDirectories
* @param array<string, HookDefinition> $hooks
* @param list<OperationArgument> $operationArguments
*/
private function __construct(
public Schema | string $schema,
Expand Down Expand Up @@ -65,6 +66,7 @@ private function __construct(
public bool $formatOperationFiles = false,
public array $hooks = [],
public bool $symfonyAutowireHooks = false,
public array $operationArguments = [],
) {}

public static function create(
Expand Down Expand Up @@ -372,6 +374,48 @@ public function withHook(string $class) : self
]);
}

/**
* Register an extra parameter to inject into generated operation methods.
*
* The parameter is prepended to `execute()`/`executeOrThrow()` and forwarded
* positionally to the client's `graphql()` call (after the operation name).
*
* When `$directive` is given, the parameter only applies to operations carrying
* that directive (e.g. `mutation Foo @requiresActor`). When `$directive` is null,
* it applies to every operation whose type is listed in `$operations`.
*
* `$operations` restricts which operation types the argument may target. Leave it
* empty (the default) to allow any operation type.
*
* @param list<'query'|'mutation'> $operations
* @throws \Webmozart\Assert\InvalidArgumentException
*/
public function withOperationArgument(
string $name,
Type $type,
?string $directive = null,
array $operations = [],
) : self {
Assert::regex($name, '/^[a-zA-Z_][a-zA-Z0-9_]*$/', sprintf(
'Operation argument name "%s" must be a valid PHP identifier.',
$name,
));

if ($directive !== null) {
Assert::regex($directive, '/^[a-zA-Z_][a-zA-Z0-9_]*$/', sprintf(
'Operation argument directive "%s" must be a valid GraphQL directive name.',
$directive,
));
}

$operationArguments = $this->operationArguments;
$operationArguments[] = new OperationArgument($name, $type, $directive, $operations);

return clone ($this, [
'operationArguments' => $operationArguments,
]);
}

/**
* A batched hook's single parameter must be `array` (or `iterable`/untyped) — it
* receives the whole batch of input tuples.
Expand Down
27 changes: 27 additions & 0 deletions src/Config/OperationArgument.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Ruudk\GraphQLCodeGenerator\Config;

use Symfony\Component\TypeInfo\Type;

/**
* An extra, non-GraphQL parameter injected into a generated operation's
* execute()/executeOrThrow() methods and forwarded positionally to the
* client's graphql() call.
*/
final readonly class OperationArgument
{
/**
* @param null|string $directive When set, the argument only applies to operations carrying this directive.
* When null, it applies to every operation whose type is in $operations.
* @param list<'query'|'mutation'> $operations The operation types this argument may target.
*/
public function __construct(
public string $name,
public Type $type,
public ?string $directive,
public array $operations,
) {}
}
5 changes: 5 additions & 0 deletions src/Console/GenerateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Ruudk\GraphQLCodeGenerator\Executor\PlanExecutor;
use Ruudk\GraphQLCodeGenerator\GraphQL\HookDirectiveSchemaExtender;
use Ruudk\GraphQLCodeGenerator\GraphQL\IndexByDirectiveSchemaExtender;
use Ruudk\GraphQLCodeGenerator\GraphQL\OperationArgumentDirectiveSchemaExtender;
use Ruudk\GraphQLCodeGenerator\GraphQL\ThrowWhenNullDirectiveSchemaExtender;
use Ruudk\GraphQLCodeGenerator\Planner;
use SebastianBergmann\Diff\Differ;
Expand Down Expand Up @@ -127,6 +128,10 @@ public function __invoke(
$schema = ThrowWhenNullDirectiveSchemaExtender::extend($schema);
}

if ($configItem->operationArguments !== []) {
$schema = OperationArgumentDirectiveSchemaExtender::extend($schema, $configItem->operationArguments);
}

$this->filesystem->dumpFile(
$configItem->schema,
SchemaPrinter::doPrint(
Expand Down
14 changes: 14 additions & 0 deletions src/DirectiveProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ public function hasThrowWhenNullDirective(NodeList $directives) : bool
return false;
}

/**
* @param NodeList<DirectiveNode> $directives
*/
public function hasDirective(NodeList $directives, string $name) : bool
{
foreach ($directives as $directive) {
if ($directive->name->value === $name) {
return true;
}
}

return false;
}

/**
* Extract the @hook directive's `name` and `input` arguments.
*
Expand Down
20 changes: 18 additions & 2 deletions src/Generator/OperationClassGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ function () use ($plan, $failedException, $namespace, $generator, $usedHooks) {
yield ') {}';

$parameters = $generator->indent(function () use ($plan, $generator) {
foreach ($plan->extraParameters as $extraParameter) {
yield sprintf(
'%s $%s,',
$this->dumpPHPType($extraParameter->type, $generator->import(...)),
$extraParameter->name,
);
}

foreach ($plan->variables as $name => $phpType) {
yield sprintf(
'%s $%s%s,',
Expand Down Expand Up @@ -138,7 +146,7 @@ function () use ($plan, $failedException, $namespace, $generator, $usedHooks) {
}
});

if ($plan->variables !== []) {
if ($plan->variables !== [] || $plan->extraParameters !== []) {
yield 'public function execute(';
yield $parameters;
yield sprintf(
Expand Down Expand Up @@ -186,6 +194,10 @@ function () use ($plan, $failedException, $namespace, $generator, $usedHooks) {
});
yield '],';
yield 'self::OPERATION_NAME,';

foreach ($plan->extraParameters as $extraParameter) {
yield sprintf('%s: $%s,', $extraParameter->name, $extraParameter->name);
}
});
yield ');';
yield '';
Expand Down Expand Up @@ -226,7 +238,7 @@ function () use ($plan, $failedException, $namespace, $generator, $usedHooks) {
yield sprintf('@throws %s', $generator->import($failedException));
});

if ($plan->variables !== []) {
if ($plan->variables !== [] || $plan->extraParameters !== []) {
yield 'public function executeOrThrow(';
yield $parameters;
yield sprintf(
Expand All @@ -244,6 +256,10 @@ function () use ($plan, $failedException, $namespace, $generator, $usedHooks) {
yield $generator->indent(function () use ($plan, $failedException, $generator) {
yield '$data = $this->execute(';
yield $generator->indent(function () use ($plan) {
foreach ($plan->extraParameters as $extraParameter) {
yield sprintf('$%s,', $extraParameter->name);
}

foreach ($plan->variables as $name => $phpType) {
yield sprintf('$%s,', $name);
}
Expand Down
Loading
Loading