From 1e2978bff74872776ea69127c4954da28150a6c9 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Wed, 19 Aug 2026 16:09:37 -0400 Subject: [PATCH 1/2] feat: configurable field-accessor prefixes (SchemaFactory::stripFieldPrefixes) Generalizes the hasser support proposed in #766 into a configurable set of accessor prefixes. SchemaFactory::stripFieldPrefixes(getters:, setters:) sets the method-name prefixes stripped to derive field names and matched when resolving property accessors such as #[SourceField]. Adding "has" to the getters exposes hassers like hasStock(). Prefixes are stripped only on a camelCase boundary (the character after the prefix must be uppercase), so genuine accessors like isEnabled() become enabled while ordinary words such as issue() or hashKey() are left untouched. Defaults (getters get/is, setters set) preserve the historical behavior, so the change is backward compatible. --- src/FieldsBuilder.php | 7 +- src/NamingStrategy.php | 25 ++--- src/SchemaFactory.php | 24 ++++- src/Utils/FieldAccessorPrefixes.php | 79 +++++++++++++++ src/Utils/PropertyAccessor.php | 14 +-- tests/Fixtures/StripFieldPrefixes/Product.php | 24 +++++ .../StripFieldPrefixes/ProductController.php | 16 +++ .../StripFieldPrefixes/ProductType.php | 15 +++ .../StockAdjustment.php | 25 +++++ .../StockController.php | 16 +++ tests/Fixtures/Types/GetterSetterType.php | 12 ++- tests/Integration/StripFieldPrefixesTest.php | 86 ++++++++++++++++ tests/NamingStrategyTest.php | 37 +++++++ tests/Utils/FieldAccessorPrefixesTest.php | 97 +++++++++++++++++++ tests/Utils/PropertyAccessorTest.php | 36 +++++++ website/docs/CHANGELOG.md | 12 +++ website/docs/external-type-declaration.mdx | 4 + website/docs/other-frameworks.mdx | 5 + 18 files changed, 506 insertions(+), 28 deletions(-) create mode 100644 src/Utils/FieldAccessorPrefixes.php create mode 100644 tests/Fixtures/StripFieldPrefixes/Product.php create mode 100644 tests/Fixtures/StripFieldPrefixes/ProductController.php create mode 100644 tests/Fixtures/StripFieldPrefixes/ProductType.php create mode 100644 tests/Fixtures/StripFieldPrefixesInput/StockAdjustment.php create mode 100644 tests/Fixtures/StripFieldPrefixesInput/StockController.php create mode 100644 tests/Integration/StripFieldPrefixesTest.php create mode 100644 tests/Utils/FieldAccessorPrefixesTest.php 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. From 9d941af1109c044e16b95805e19a330fcd7a9a07 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Wed, 19 Aug 2026 23:27:21 -0400 Subject: [PATCH 2/2] ci: trigger checks