diff --git a/src/FieldsBuilder.php b/src/FieldsBuilder.php index 7e5291a419..09898cc96b 100644 --- a/src/FieldsBuilder.php +++ b/src/FieldsBuilder.php @@ -51,6 +51,7 @@ use TheCodingMachine\GraphQLite\Types\MutableObjectType; use TheCodingMachine\GraphQLite\Types\TypeResolver; use TheCodingMachine\GraphQLite\Utils\DescriptionResolver; +use TheCodingMachine\GraphQLite\Utils\FieldAccessorPrefixes; use TheCodingMachine\GraphQLite\Utils\PropertyAccessor; use function array_diff_key; @@ -68,7 +69,6 @@ use function key; use function reset; use function rtrim; -use function str_starts_with; use function trim; use const PHP_EOL; @@ -92,6 +92,7 @@ public function __construct( private readonly FieldMiddlewareInterface $fieldMiddleware, private readonly InputFieldMiddlewareInterface $inputFieldMiddleware, private readonly DescriptionResolver $descriptionResolver = new DescriptionResolver(true), + private readonly FieldAccessorPrefixes $fieldAccessorPrefixes = new FieldAccessorPrefixes(), ) { $this->typeMapper = new TypeHandler( @@ -846,7 +847,7 @@ private function getMethodFromPropertyName( if ($reflectionClass->hasMethod($propertyName)) { $methodName = $propertyName; } else { - $methodName = PropertyAccessor::findGetter($reflectionClass->getName(), $propertyName); + $methodName = PropertyAccessor::findGetter($reflectionClass->getName(), $propertyName, $this->fieldAccessorPrefixes); if (! $methodName) { throw FieldNotFoundException::missingField($reflectionClass->getName(), $propertyName); } @@ -1032,7 +1033,7 @@ private function getInputFieldsByMethodAnnotations( $docBlockObj = $this->docBlockFactory->create($refMethod); $methodName = $refMethod->getName(); - if (! str_starts_with($methodName, 'set')) { + if (! $this->fieldAccessorPrefixes->hasSetterPrefix($methodName)) { continue; } diff --git a/src/NamingStrategy.php b/src/NamingStrategy.php index bb308dbb73..ee9bc68e48 100644 --- a/src/NamingStrategy.php +++ b/src/NamingStrategy.php @@ -7,18 +7,21 @@ use TheCodingMachine\GraphQLite\Annotations\Factory; use TheCodingMachine\GraphQLite\Annotations\Input; use TheCodingMachine\GraphQLite\Annotations\TypeInterface; +use TheCodingMachine\GraphQLite\Utils\FieldAccessorPrefixes; use function implode; -use function lcfirst; use function str_ends_with; use function str_replace; -use function str_starts_with; -use function strlen; use function strrpos; use function substr; class NamingStrategy implements NamingStrategyInterface { + public function __construct( + private readonly FieldAccessorPrefixes $fieldAccessorPrefixes = new FieldAccessorPrefixes(), + ) { + } + /** * Returns the name of the GraphQL interface from a name of a concrete class (when the interface is created * automatically to manage inheritance) @@ -88,15 +91,7 @@ public function getInputTypeName(string $className, Input|Factory $input): strin */ public function getFieldNameFromMethodName(string $methodName): string { - // Let's remove any "get" or "is". - if (str_starts_with($methodName, 'get') && strlen($methodName) > 3) { - return lcfirst(substr($methodName, 3)); - } - if (str_starts_with($methodName, 'is') && strlen($methodName) > 2) { - return lcfirst(substr($methodName, 2)); - } - - return $methodName; + return $this->fieldAccessorPrefixes->stripGetterPrefix($methodName); } /** @@ -104,11 +99,7 @@ public function getFieldNameFromMethodName(string $methodName): string */ public function getInputFieldNameFromMethodName(string $methodName): string { - if (str_starts_with($methodName, 'set') && strlen($methodName) > 3) { - return lcfirst(substr($methodName, 3)); - } - - return $methodName; + return $this->fieldAccessorPrefixes->stripSetterPrefix($methodName); } /** diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 87056d7298..0b2b721c33 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -75,6 +75,7 @@ use TheCodingMachine\GraphQLite\Types\InputTypeValidatorInterface; use TheCodingMachine\GraphQLite\Types\TypeResolver; use TheCodingMachine\GraphQLite\Utils\DescriptionResolver; +use TheCodingMachine\GraphQLite\Utils\FieldAccessorPrefixes; use TheCodingMachine\GraphQLite\Utils\NamespacedCache; use function array_reverse; @@ -120,6 +121,8 @@ class SchemaFactory private NamingStrategyInterface|null $namingStrategy = null; + private FieldAccessorPrefixes|null $fieldAccessorPrefixes = null; + private ClassFinder|FinderInterface|null $finder = null; private SchemaConfig|null $schemaConfig = null; @@ -280,6 +283,23 @@ public function setNamingStrategy(NamingStrategyInterface $namingStrategy): self return $this; } + /** + * Configures the method-name prefixes stripped to derive field names and matched when resolving + * property accessors. Getter prefixes map read methods to output fields; setter prefixes map + * write methods to input fields. A prefix is only stripped on a camelCase boundary, so + * "isEnabled" becomes "enabled" while "issue" is left untouched. The defaults preserve + * GraphQLite's historical behavior. + * + * @param list $getters + * @param list $setters + */ + public function stripFieldPrefixes(array $getters = ['get', 'is'], array $setters = ['set']): self + { + $this->fieldAccessorPrefixes = new FieldAccessorPrefixes($getters, $setters); + + return $this; + } + public function setSchemaConfig(SchemaConfig $schemaConfig): self { $this->schemaConfig = $schemaConfig; @@ -398,7 +418,8 @@ public function createSchema(): Schema PhpDocumentorDocBlockFactory::default(), ); $descriptionResolver = new DescriptionResolver($this->useDocblockDescriptions); - $namingStrategy = $this->namingStrategy ?: new NamingStrategy(); + $fieldAccessorPrefixes = $this->fieldAccessorPrefixes ?? new FieldAccessorPrefixes(); + $namingStrategy = $this->namingStrategy ?: new NamingStrategy($fieldAccessorPrefixes); $typeRegistry = new TypeRegistry(); $classFinder = $this->createClassFinder(); $classFinderComputedCache = $this->devMode ? @@ -493,6 +514,7 @@ classBoundCache: $classBoundCache, $fieldMiddlewarePipe, $inputFieldMiddlewarePipe, $descriptionResolver, + $fieldAccessorPrefixes, ); $parameterizedCallableResolver = new ParameterizedCallableResolver($fieldsBuilder, $callableResolver); diff --git a/src/Utils/FieldAccessorPrefixes.php b/src/Utils/FieldAccessorPrefixes.php new file mode 100644 index 0000000000..c6b5fddfb1 --- /dev/null +++ b/src/Utils/FieldAccessorPrefixes.php @@ -0,0 +1,79 @@ + "enabled" while leaving ordinary words such as "issue" or "hashKey" untouched. + */ +final class FieldAccessorPrefixes +{ + /** + * @param list $getters + * @param list $setters + */ + public function __construct( + public readonly array $getters = ['get', 'is'], + public readonly array $setters = ['set'], + ) { + } + + /** + * Strips the matching getter prefix from a read method name to derive an output field name. + */ + public function stripGetterPrefix(string $methodName): string + { + return $this->strip($methodName, $this->getters); + } + + /** + * Strips the matching setter prefix from a write method name to derive an input field name. + */ + public function stripSetterPrefix(string $methodName): string + { + return $this->strip($methodName, $this->setters); + } + + /** + * Whether the method name is a setter, i.e. it carries one of the configured setter prefixes on a + * camelCase boundary. Used to decide which methods define input fields. + */ + public function hasSetterPrefix(string $methodName): bool + { + return $this->strip($methodName, $this->setters) !== $methodName; + } + + /** @param list $prefixes */ + private function strip(string $methodName, array $prefixes): string + { + foreach ($prefixes as $prefix) { + $length = strlen($prefix); + + // The prefix must be present and followed by at least one more character... + if (strlen($methodName) <= $length || ! str_starts_with($methodName, $prefix)) { + continue; + } + + // ...and that character must be uppercase, marking a real camelCase accessor boundary. + if (! ctype_upper($methodName[$length])) { + continue; + } + + return lcfirst(substr($methodName, $length)); + } + + return $methodName; + } +} diff --git a/src/Utils/PropertyAccessor.php b/src/Utils/PropertyAccessor.php index 2d0b4bd76d..866a6675f2 100644 --- a/src/Utils/PropertyAccessor.php +++ b/src/Utils/PropertyAccessor.php @@ -20,9 +20,9 @@ class PropertyAccessor /** * Finds a getter for a property. */ - public static function findGetter(string $class, string $propertyName): string|null + public static function findGetter(string $class, string $propertyName, FieldAccessorPrefixes $prefixes = new FieldAccessorPrefixes()): string|null { - foreach (['get', 'is'] as $prefix) { + foreach ($prefixes->getters as $prefix) { $methodName = self::propertyToMethodName($prefix, $propertyName); if (self::isPublicMethod($class, $methodName)) { @@ -36,12 +36,14 @@ public static function findGetter(string $class, string $propertyName): string|n /** * Finds a setter for a property. */ - public static function findSetter(string $class, string $propertyName): string|null + public static function findSetter(string $class, string $propertyName, FieldAccessorPrefixes $prefixes = new FieldAccessorPrefixes()): string|null { - $methodName = self::propertyToMethodName('set', $propertyName); + foreach ($prefixes->setters as $prefix) { + $methodName = self::propertyToMethodName($prefix, $propertyName); - if (self::isPublicMethod($class, $methodName)) { - return $methodName; + if (self::isPublicMethod($class, $methodName)) { + return $methodName; + } } return null; diff --git a/tests/Fixtures/StripFieldPrefixes/Product.php b/tests/Fixtures/StripFieldPrefixes/Product.php new file mode 100644 index 0000000000..6e01f4ad67 --- /dev/null +++ b/tests/Fixtures/StripFieldPrefixes/Product.php @@ -0,0 +1,24 @@ +name; + } + + public function hasStock(): bool + { + return $this->inStock; + } +} diff --git a/tests/Fixtures/StripFieldPrefixes/ProductController.php b/tests/Fixtures/StripFieldPrefixes/ProductController.php new file mode 100644 index 0000000000..1018ec1af1 --- /dev/null +++ b/tests/Fixtures/StripFieldPrefixes/ProductController.php @@ -0,0 +1,16 @@ +delta = $delta; + } + + public function getDelta(): int + { + return $this->delta; + } +} diff --git a/tests/Fixtures/StripFieldPrefixesInput/StockController.php b/tests/Fixtures/StripFieldPrefixesInput/StockController.php new file mode 100644 index 0000000000..1798da35e5 --- /dev/null +++ b/tests/Fixtures/StripFieldPrefixesInput/StockController.php @@ -0,0 +1,16 @@ +getDelta(); + } +} diff --git a/tests/Fixtures/Types/GetterSetterType.php b/tests/Fixtures/Types/GetterSetterType.php index cb11a7e3cf..dc98906665 100644 --- a/tests/Fixtures/Types/GetterSetterType.php +++ b/tests/Fixtures/Types/GetterSetterType.php @@ -43,4 +43,14 @@ private function setFour(string $value, string $arg): void { throw new \RuntimeException('Should not be called'); } -} \ No newline at end of file + + public function hasFive(string $arg = ''): bool + { + return $arg === 'foo'; + } + + public function assignTwo(string $value): void + { + $this->two = $value . ' assigned'; + } +} diff --git a/tests/Integration/StripFieldPrefixesTest.php b/tests/Integration/StripFieldPrefixesTest.php new file mode 100644 index 0000000000..c92390273f --- /dev/null +++ b/tests/Integration/StripFieldPrefixesTest.php @@ -0,0 +1,86 @@ + $getters + * @param list $setters + */ + private function buildSchema(string $namespace, array $getters = ['get', 'is'], array $setters = ['set']): Schema + { + $factory = new SchemaFactory( + new Psr16Cache(new ArrayAdapter()), + new BasicAutoWiringContainer(new EmptyContainer()), + ); + $factory->addNamespace($namespace); + $factory->stripFieldPrefixes(getters: $getters, setters: $setters); + + return $factory->createSchema(); + } + + public function testHasserSourceFieldResolvesWhenGetterPrefixConfigured(): void + { + $schema = $this->buildSchema( + 'TheCodingMachine\\GraphQLite\\Fixtures\\StripFieldPrefixes', + getters: ['get', 'is', 'has'], + ); + + $result = GraphQL::executeQuery( + $schema, + ' + query { + product { + name + stock + } + } + ', + )->toArray(DebugFlag::RETHROW_INTERNAL_EXCEPTIONS); + + $this->assertArrayNotHasKey('errors', $result); + $this->assertSame(['name' => 'Widget', 'stock' => true], $result['data']['product']); + } + + public function testCustomSetterPrefixDefinesAndHydratesInputField(): void + { + $schema = $this->buildSchema( + 'TheCodingMachine\\GraphQLite\\Fixtures\\StripFieldPrefixesInput', + setters: ['set', 'assign'], + ); + + $result = GraphQL::executeQuery( + $schema, + ' + mutation { + adjustStock(adjustment: { delta: 5 }) + } + ', + )->toArray(DebugFlag::RETHROW_INTERNAL_EXCEPTIONS); + + // The input field is named "delta" (assign prefix stripped) and hydration calls assignDelta(). + $this->assertArrayNotHasKey('errors', $result); + $this->assertSame(5, $result['data']['adjustStock']); + } +} diff --git a/tests/NamingStrategyTest.php b/tests/NamingStrategyTest.php index 157bb5f9d1..a92c44e3cf 100644 --- a/tests/NamingStrategyTest.php +++ b/tests/NamingStrategyTest.php @@ -6,6 +6,7 @@ use TheCodingMachine\GraphQLite\Annotations\Factory; use TheCodingMachine\GraphQLite\Annotations\Type; use TheCodingMachine\GraphQLite\Fixtures\TestObject; +use TheCodingMachine\GraphQLite\Utils\FieldAccessorPrefixes; class NamingStrategyTest extends TestCase { @@ -33,6 +34,42 @@ public function testGetFieldNameFromMethodName(): void $this->assertSame('set', $namingStrategy->getInputFieldNameFromMethodName('set')); } + public function testGetFieldNameFromMethodNameOnlyStripsOnCamelCaseBoundary(): void + { + $namingStrategy = new NamingStrategy(); + + // A prefix is only stripped when the next character is uppercase (a real accessor boundary). + $this->assertSame('enabled', $namingStrategy->getFieldNameFromMethodName('isEnabled')); + // Ordinary words that merely start with the prefix letters are left untouched. + $this->assertSame('issue', $namingStrategy->getFieldNameFromMethodName('issue')); + $this->assertSame('getaway', $namingStrategy->getFieldNameFromMethodName('getaway')); + $this->assertSame('settings', $namingStrategy->getInputFieldNameFromMethodName('settings')); + // "has" is not a getter prefix by default, so hassers pass through unchanged. + $this->assertSame('hasAccess', $namingStrategy->getFieldNameFromMethodName('hasAccess')); + } + + public function testGetFieldNameFromMethodNameWithCustomPrefixes(): void + { + $namingStrategy = new NamingStrategy(new FieldAccessorPrefixes(getters: ['get', 'is', 'has'])); + + // "has" is now a recognised getter prefix on a camelCase boundary. + $this->assertSame('access', $namingStrategy->getFieldNameFromMethodName('hasAccess')); + $this->assertSame('hKey', $namingStrategy->getFieldNameFromMethodName('hasHKey')); + // ...but words that only start with the letters "has" are still left untouched. + $this->assertSame('hashKey', $namingStrategy->getFieldNameFromMethodName('hashKey')); + } + + public function testGetInputFieldNameFromMethodNameWithCustomSetterPrefixes(): void + { + $namingStrategy = new NamingStrategy(new FieldAccessorPrefixes(setters: ['assign'])); + + $this->assertSame('name', $namingStrategy->getInputFieldNameFromMethodName('assignName')); + // A word that only starts with the letters "assign" is left untouched... + $this->assertSame('assignup', $namingStrategy->getInputFieldNameFromMethodName('assignup')); + // ...and "set" is no longer a configured setter prefix here, so it passes through. + $this->assertSame('setName', $namingStrategy->getInputFieldNameFromMethodName('setName')); + } + public function testGetFieldNameFromTypeAnnotation(): void { $namingStrategy = new NamingStrategy(); diff --git a/tests/Utils/FieldAccessorPrefixesTest.php b/tests/Utils/FieldAccessorPrefixesTest.php new file mode 100644 index 0000000000..df7b6d364d --- /dev/null +++ b/tests/Utils/FieldAccessorPrefixesTest.php @@ -0,0 +1,97 @@ +stripGetterPrefix($methodName)); + } + + public static function stripGetterProvider(): iterable + { + $default = new FieldAccessorPrefixes(); + $withHas = new FieldAccessorPrefixes(getters: ['get', 'is', 'has']); + + // Default getters, genuine accessors on a camelCase boundary. + yield 'getName' => ['name', 'getName', $default]; + yield 'isEnabled' => ['enabled', 'isEnabled', $default]; + // Bare prefix (nothing after it) is left alone. + yield 'get' => ['get', 'get', $default]; + yield 'is' => ['is', 'is', $default]; + // No prefix. + yield 'foo' => ['foo', 'foo', $default]; + // Words that merely start with the prefix letters (next char lowercase) are NOT stripped. + yield 'issue' => ['issue', 'issue', $default]; + yield 'getaway' => ['getaway', 'getaway', $default]; + yield 'gettext' => ['gettext', 'gettext', $default]; + // Non-letter right after the prefix is not a boundary either. + yield 'get2FA' => ['get2FA', 'get2FA', $default]; + yield 'get_foo' => ['get_foo', 'get_foo', $default]; + // "has" only strips once configured. + yield 'hasAccess default' => ['hasAccess', 'hasAccess', $default]; + yield 'hasAccess with has' => ['access', 'hasAccess', $withHas]; + yield 'hasHKey with has' => ['hKey', 'hasHKey', $withHas]; + yield 'hashKey with has' => ['hashKey', 'hashKey', $withHas]; + } + + #[DataProvider('stripSetterProvider')] + public function testStripSetterPrefix(string $expected, string $methodName, FieldAccessorPrefixes $prefixes): void + { + self::assertSame($expected, $prefixes->stripSetterPrefix($methodName)); + } + + public static function stripSetterProvider(): iterable + { + $default = new FieldAccessorPrefixes(); + $withAssign = new FieldAccessorPrefixes(setters: ['assign']); + + yield 'setName' => ['name', 'setName', $default]; + yield 'set' => ['set', 'set', $default]; + // "settings"/"setup" merely start with "set"; they are not setters. + yield 'settings' => ['settings', 'settings', $default]; + yield 'setup' => ['setup', 'setup', $default]; + // Custom setter prefix. + yield 'assignName with assign' => ['name', 'assignName', $withAssign]; + yield 'assignup with assign' => ['assignup', 'assignup', $withAssign]; + // A default "set" method is not a setter once the prefix list no longer contains "set". + yield 'setName with assign only' => ['setName', 'setName', $withAssign]; + } + + #[DataProvider('hasSetterPrefixProvider')] + public function testHasSetterPrefix(bool $expected, string $methodName, FieldAccessorPrefixes $prefixes): void + { + self::assertSame($expected, $prefixes->hasSetterPrefix($methodName)); + } + + public static function hasSetterPrefixProvider(): iterable + { + $default = new FieldAccessorPrefixes(); + $withAssign = new FieldAccessorPrefixes(setters: ['assign']); + + yield 'setName is a setter' => [true, 'setName', $default]; + yield 'settings is not a setter' => [false, 'settings', $default]; + yield 'bare set is not a setter' => [false, 'set', $default]; + yield 'assignName under assign' => [true, 'assignName', $withAssign]; + // The footgun guard: with "set" removed from the setter list, a set* method is not a setter, + // so input-field discovery skips it instead of deriving a broken field name. + yield 'setName under assign only' => [false, 'setName', $withAssign]; + } + + public function testEmptyMethodNameAndPrefixEqualToMethodAreSafe(): void + { + $prefixes = new FieldAccessorPrefixes(); + + // Prefix equal to (or longer than) the whole method name never strips and never over-reads. + self::assertSame('get', $prefixes->stripGetterPrefix('get')); + self::assertSame('', $prefixes->stripGetterPrefix('')); + self::assertFalse($prefixes->hasSetterPrefix('set')); + } +} diff --git a/tests/Utils/PropertyAccessorTest.php b/tests/Utils/PropertyAccessorTest.php index 6d1ed84723..a0922fee8e 100644 --- a/tests/Utils/PropertyAccessorTest.php +++ b/tests/Utils/PropertyAccessorTest.php @@ -26,10 +26,46 @@ public static function findGetterProvider(): iterable yield 'regular property' => [null, MagicGetterSetterType::class, 'one']; yield 'getter' => ['getTwo', MagicGetterSetterType::class, 'two']; yield 'isser' => ['isThree', MagicGetterSetterType::class, 'three']; + yield 'hasser without has prefix' => [null, MagicGetterSetterType::class, 'five']; yield 'private getter' => [null, MagicGetterSetterType::class, 'four']; yield 'undefined property' => [null, MagicGetterSetterType::class, 'twenty']; } + public function testFindGetterResolvesHasserWhenConfigured(): void + { + self::assertSame( + 'hasFive', + PropertyAccessor::findGetter( + MagicGetterSetterType::class, + 'five', + new FieldAccessorPrefixes(getters: ['get', 'is', 'has']), + ), + ); + } + + public function testFindSetterUsesConfiguredPrefixes(): void + { + // A custom setter prefix resolves against a matching method... + self::assertSame( + 'assignTwo', + PropertyAccessor::findSetter( + MagicGetterSetterType::class, + 'two', + new FieldAccessorPrefixes(setters: ['assign']), + ), + ); + // ...but a configured prefix with no matching method resolves to null... + self::assertNull( + PropertyAccessor::findSetter( + MagicGetterSetterType::class, + 'three', + new FieldAccessorPrefixes(setters: ['assign']), + ), + ); + // ...while the default "set" prefix still finds the setter. + self::assertSame('setTwo', PropertyAccessor::findSetter(MagicGetterSetterType::class, 'two')); + } + #[DataProvider('findSetterProvider')] public function testFindSetter(mixed $expected, string $class, string $propertyName): void { diff --git a/website/docs/CHANGELOG.md b/website/docs/CHANGELOG.md index c44cb4584d..621818b057 100644 --- a/website/docs/CHANGELOG.md +++ b/website/docs/CHANGELOG.md @@ -14,12 +14,24 @@ sidebar_label: Changelog mapped enum still exposes every case and emits a deprecation notice. **Migration**: any enum that intentionally left some cases unannotated (for example, for docblock-only descriptions) must now add `#[EnumValue]` to every case it wants exposed. +- Accessor prefixes are now stripped only on a camelCase boundary (the character after the prefix + must be uppercase). Method names such as `getaway()`, `issue()`, `settings()` or `setup()` (a + prefix followed by a lowercase letter) are no longer mis-stripped and keep their full name as the + field name. **Migration**: if a `#[Field]`/`#[SourceField]` method relied on the old behavior (for + example `issue()` exposing a field named `sue`), set an explicit field name with `#[Field(name:)]` + or the source field's `name`. ### New Features - `#[EnumValue]` is now the per-case schema-exposure toggle, so internal enum cases can be kept out of the public schema by omitting the attribute. ([#826](https://github.com/thecodingmachine/graphqlite/pull/826)) +- `SchemaFactory::stripFieldPrefixes(getters:, setters:)` configures the method-name prefixes + stripped to derive field names and matched when resolving property accessors. Adding `has` to the + getters exposes hassers such as `hasStock()` via `#[SourceField]`; the defaults (`get`/`is`, `set`) + preserve the previous behavior. + ([#827](https://github.com/thecodingmachine/graphqlite/pull/827), generalizing + [#766](https://github.com/thecodingmachine/graphqlite/pull/766)) @oojacoboo @adynemo - [#822 Accept PHP callables as `#[Security]` rules](https://github.com/thecodingmachine/graphqlite/pull/822) @oojacoboo, alongside the existing expression form, with access to the field context and custom refusal messages. diff --git a/website/docs/external-type-declaration.mdx b/website/docs/external-type-declaration.mdx index 428fad7f8e..010140e442 100644 --- a/website/docs/external-type-declaration.mdx +++ b/website/docs/external-type-declaration.mdx @@ -67,6 +67,10 @@ By doing so, you let GraphQLite know that the type exposes the `getName` method Internally, GraphQLite will look for methods named `name()`, `getName()` and `isName()`). You can set different name to look for with `sourceName` attribute. +The getter prefixes that are tried (`get`, `is` by default) are configurable through +[`SchemaFactory::stripFieldPrefixes()`](other-frameworks.mdx). For instance, adding `has` lets a +`#[SourceField(name: 'stock')]` resolve against a `hasStock()` hasser. + ## `#[MagicField]` attribute If your object has no getters, but instead uses magic properties (using the magic `__get` method), you should use the `#[MagicField]` attribute: diff --git a/website/docs/other-frameworks.mdx b/website/docs/other-frameworks.mdx index 73e27861a9..ace517fb37 100644 --- a/website/docs/other-frameworks.mdx +++ b/website/docs/other-frameworks.mdx @@ -52,6 +52,11 @@ $factory->setAuthenticationService(new VoidAuthenticationService()); $factory->setAuthorizationService(new VoidAuthorizationService()); // Change the naming convention of GraphQL types globally. $factory->setNamingStrategy(new NamingStrategy()); +// Configure the method-name prefixes stripped to derive field names and matched when resolving +// property accessors (e.g. `#[SourceField]`). Add "has" to expose hassers such as `hasStock()`. +// A prefix is only stripped on a camelCase boundary, so `isEnabled()` becomes `enabled` while +// `issue()` is left untouched. Defaults preserve the historical behavior (`get`/`is`, `set`). +$factory->stripFieldPrefixes(getters: ['get', 'is', 'has'], setters: ['set']); // Add a custom type mapper. $factory->addTypeMapper($typeMapper); // Add a custom type mapper using a factory to create it.