From ad1c24a25b5c493d683a3b66c9bbfd09389e468c Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Fri, 16 Oct 2026 10:59:05 -0400 Subject: [PATCH] Keep surname particles after mapped given-name initials --- CHANGELOG.md | 4 ++ src/Mapper/LastnameMapper.php | 18 ++++++++- tests/InitialSurnamePrefixTest.php | 60 +++++++++++++++++++++++++++++ tests/Mapper/LastnameMapperTest.php | 38 ++++++++++++++++++ 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 tests/InitialSurnamePrefixTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f0188fa..01a566a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- Surname particles remain attached after given-name initials: `J. van Gogh` now keeps first name `J.` and surname `van Gogh`, rather than promoting `van` to the first name. This also applies with a salutation, nickname, or trailing credential. See the [parser regression cases](tests/InitialSurnamePrefixTest.php). + ## [1.6.0] - 2026-10-16 ### Security diff --git a/src/Mapper/LastnameMapper.php b/src/Mapper/LastnameMapper.php index e48cae4..156ed10 100644 --- a/src/Mapper/LastnameMapper.php +++ b/src/Mapper/LastnameMapper.php @@ -3,6 +3,7 @@ namespace Iliaal\NameParser\Mapper; use Iliaal\NameParser\Part\AbstractPart; +use Iliaal\NameParser\Part\GivenNamePart; use Iliaal\NameParser\Part\Ignored; use Iliaal\NameParser\Part\Lastname; use Iliaal\NameParser\Part\LastnamePrefix; @@ -262,7 +263,22 @@ protected function isApplicablePrefix(array $parts, int $index): bool return true; } - return $this->hasUnmappedPartsBefore($parts, $index); + if ($this->hasUnmappedPartsBefore($parts, $index)) { + return true; + } + + // InitialMapper may already have mapped the only given-name evidence. + foreach ($parts as $k => $previous) { + if ($k === $index) { + break; + } + + if ($previous instanceof GivenNamePart && $previous->normalize() !== '') { + return true; + } + } + + return false; } protected function isPrefix(string $word): bool diff --git a/tests/InitialSurnamePrefixTest.php b/tests/InitialSurnamePrefixTest.php new file mode 100644 index 0000000..7b80307 --- /dev/null +++ b/tests/InitialSurnamePrefixTest.php @@ -0,0 +1,60 @@ +, bool}> + */ + public static function nameRows(): iterable + { + $dutch = ['firstname' => 'J.', 'initials' => '', 'lastname' => 'van Gogh', 'lastname_prefix' => 'van', 'middlename' => '']; + + yield 'dotted initial' => ['J. van Gogh', $dutch, false]; + yield 'bare initial' => ['J van Gogh', array_replace($dutch, ['firstname' => 'J']), false]; + yield 'two initials' => ['J. R. van Gogh', array_replace($dutch, ['initials' => 'R.']), false]; + yield 'combined initials' => ['JR van Gogh', array_replace($dutch, ['firstname' => 'J', 'initials' => 'R']), false]; + yield 'salutation' => ['Dr. J. van Gogh', $dutch + ['salutation' => 'Dr.'], false]; + yield 'nickname before particle' => ['J. (Jack) van Gogh', $dutch + ['nickname' => 'Jack'], false]; + yield 'nickname after particle' => ['J. van (Jack) Gogh', $dutch + ['nickname' => 'Jack'], false]; + yield 'credential' => ['J. van Gogh MD', $dutch + ['suffix' => 'MD'], false]; + yield 'empty comma tail' => ['J. van Gogh,', $dutch, false]; + yield 'credential comma tail' => ['J. van Gogh, MD', $dutch + ['suffix' => 'MD'], false]; + yield 'explicit surname segment' => ['van Gogh, J.', array_replace($dutch, ['firstname' => '', 'initials' => 'J.']), false]; + yield 'surname-first explicit comma' => ['van Gogh, J.', array_replace($dutch, ['firstname' => '', 'initials' => 'J.']), true]; + yield 'surname-first token' => ['Gogh J.', array_replace($dutch, ['firstname' => '', 'initials' => 'J.', 'lastname' => 'Gogh', 'lastname_prefix' => '']), true]; + yield 'uniform uppercase' => ['J. VAN GOGH', $dutch, false]; + yield 'two-letter particle' => ['J. de Vries', array_replace($dutch, ['lastname' => 'de Vries', 'lastname_prefix' => 'de']), false]; + yield 'Irish particle' => ['É. Ó Cuív', array_replace($dutch, ['firstname' => 'É.', 'lastname' => 'Ó Cuív', 'lastname_prefix' => 'Ó']), false]; + yield 'multiple particles' => ['J. van der Berg', array_replace($dutch, ['lastname' => 'van der Berg', 'lastname_prefix' => 'van der']), false]; + yield 'unmapped given name' => ['John van Gogh', array_replace($dutch, ['firstname' => 'John']), false]; + yield 'particle-like given name' => ['Della Smith', ['firstname' => 'Della', 'lastname' => 'Smith', 'lastname_prefix' => '', 'initials' => ''], false]; + yield 'nickname alone is not a given name' => ['(Jack) Della Smith', ['firstname' => 'Della', 'lastname' => 'Smith', 'lastname_prefix' => '', 'nickname' => 'Jack'], false]; + } + + /** + * @param array $expected + */ + #[DataProvider('nameRows')] + public function testInitialKeepsFollowingSurnameParticle(string $input, array $expected, bool $surnameFirst): void + { + $fresh = (new Parser())->setSurnameFirst($surnameFirst)->parse($input); + $parser = (new Parser())->setSurnameFirst($surnameFirst); + $parser->parse('Smith, JO, MD'); + $parser->parse('John (Bob) Jones'); + $reused = $parser->parse($input); + + foreach ($expected as $field => $value) { + $this->assertSame($value, $fresh->toArray()[$field], $field . ' for ' . $input); + } + + $this->assertSame($fresh->toArray(), $reused->toArray()); + $this->assertSame($fresh->getConfidence(), $reused->getConfidence()); + $this->assertSame($input, $fresh->getSource()); + } +} diff --git a/tests/Mapper/LastnameMapperTest.php b/tests/Mapper/LastnameMapperTest.php index 55dfcba..850bb52 100644 --- a/tests/Mapper/LastnameMapperTest.php +++ b/tests/Mapper/LastnameMapperTest.php @@ -4,10 +4,18 @@ use Iliaal\NameParser\Language\English; use Iliaal\NameParser\Mapper\LastnameMapper; +use Iliaal\NameParser\Part\AbstractPart; use Iliaal\NameParser\Part\Firstname; +use Iliaal\NameParser\Part\GivenNamePart; +use Iliaal\NameParser\Part\Ignored; +use Iliaal\NameParser\Part\Initial; use Iliaal\NameParser\Part\Lastname; use Iliaal\NameParser\Part\LastnamePrefix; +use Iliaal\NameParser\Part\Middlename; +use Iliaal\NameParser\Part\Nickname; use Iliaal\NameParser\Part\Salutation; +use Iliaal\NameParser\Part\Suffix; +use PHPUnit\Framework\Attributes\DataProvider; class LastnameMapperTest extends AbstractMapperTestCase { @@ -119,6 +127,36 @@ public static function provider(): array ]; } + /** + * @return iterable + */ + public static function precedingPartProvider(): iterable + { + yield 'initial' => [new Initial('J.'), true]; + yield 'firstname' => [new Firstname('John'), true]; + yield 'middlename' => [new Middlename('Robert'), true]; + yield 'custom given name' => [new class ('Jean') extends GivenNamePart {}, true]; + yield 'empty initial' => [new Initial(''), false]; + yield 'empty firstname' => [new Firstname(''), false]; + yield 'salutation' => [new Salutation('Dr.'), false]; + yield 'nickname' => [new Nickname('Jack'), false]; + yield 'suffix' => [new Suffix('MD'), false]; + yield 'ignored' => [new Ignored('and'), false]; + } + + #[DataProvider('precedingPartProvider')] + public function testMappedGivenNamesAllowFollowingSurnameParticle(AbstractPart $previous, bool $binds): void + { + $parts = $this->getMapper()->map([$previous, 'van', 'Gogh']); + + $this->assertSame($previous, $parts[0]); + $this->assertSame($binds, $parts[1] instanceof LastnamePrefix); + $this->assertSame( + self::canonicalParts([$previous, $binds ? new LastnamePrefix('van', 'van') : 'van', new Lastname('Gogh')]), + self::canonicalParts($parts), + ); + } + protected function getMapper(bool $matchSingle = false): LastnameMapper { $english = new English();