-
-
Notifications
You must be signed in to change notification settings - Fork 14
fix: ConfigFactory and ProviderConfig config merging
#298
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
albertcht
merged 7 commits into
hypervel:main
from
binaryfire:fix/provider-config-merge-scalar-values
Dec 21, 2025
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
34b0eb4
fix: Use Arr::merge for config merging to handle both lists and scalars
binaryfire ce024ab
Switch to custom merge method
binaryfire de3bd4b
Add dedicated merge test
binaryfire 5843766
Update docblocks
binaryfire 7ad6bc7
Merge remote-tracking branch 'origin/main' into fix/provider-config-m…
binaryfire c71cc77
test: add PriorityDefinition merge coverage
binaryfire 4b8c7e4
refactor: extract shared mergeTwo() to eliminate duplication
binaryfire File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Hypervel\Tests\Config; | ||
|
|
||
| use Hypervel\Config\ProviderConfig; | ||
| use PHPUnit\Framework\TestCase; | ||
| use ReflectionMethod; | ||
|
|
||
| /** | ||
| * Tests for ConfigFactory merge behavior. | ||
| * | ||
| * ConfigFactory merges: ProviderConfig::load() + $rootConfig + ...$autoloadConfig | ||
| * This test verifies the merge logic handles both scalar replacement and list | ||
| * combining correctly. | ||
| * | ||
| * @internal | ||
| * @coversNothing | ||
| */ | ||
| class ConfigFactoryTest extends TestCase | ||
| { | ||
| /** | ||
| * Test that the merge strategy used by ConfigFactory correctly handles | ||
| * numeric arrays (lists) by combining them, not replacing at indices. | ||
| * | ||
| * This is a regression test for the issue where array_replace_recursive | ||
| * was replacing values at matching indices instead of combining lists. | ||
| * | ||
| * Scenario: Provider configs define commands, app config adds more commands. | ||
| * Expected: All commands should be present in the merged result. | ||
| */ | ||
| public function testMergePreservesListsFromProviderConfigs(): void | ||
| { | ||
| // Simulates ProviderConfig::load() result | ||
| $providerConfig = [ | ||
| 'commands' => [ | ||
| 'App\Commands\CommandA', | ||
| 'App\Commands\CommandB', | ||
| 'App\Commands\CommandC', | ||
| ], | ||
| 'listeners' => [ | ||
| 'App\Listeners\ListenerA', | ||
| 'App\Listeners\ListenerB', | ||
| ], | ||
| ]; | ||
|
|
||
| // Simulates app config (e.g., config/commands.php adding custom commands) | ||
| $appConfig = [ | ||
| 'commands' => [ | ||
| 'App\Commands\CustomCommand', | ||
| ], | ||
| ]; | ||
|
|
||
| // This simulates what ConfigFactory currently does | ||
| $result = $this->mergeConfigs($providerConfig, $appConfig); | ||
|
|
||
| // All provider commands should still be present | ||
| $this->assertContains( | ||
| 'App\Commands\CommandA', | ||
| $result['commands'], | ||
| 'CommandA from provider config should be preserved' | ||
| ); | ||
| $this->assertContains( | ||
| 'App\Commands\CommandB', | ||
| $result['commands'], | ||
| 'CommandB from provider config should be preserved' | ||
| ); | ||
| $this->assertContains( | ||
| 'App\Commands\CommandC', | ||
| $result['commands'], | ||
| 'CommandC from provider config should be preserved' | ||
| ); | ||
|
|
||
| // App's custom command should be added | ||
| $this->assertContains( | ||
| 'App\Commands\CustomCommand', | ||
| $result['commands'], | ||
| 'CustomCommand from app config should be added' | ||
| ); | ||
|
|
||
| // Should have 4 commands total (3 from provider + 1 from app) | ||
| $this->assertCount(4, $result['commands'], 'Should have all 4 commands'); | ||
| } | ||
|
|
||
| /** | ||
| * Test that the merge strategy correctly replaces scalar values in | ||
| * associative arrays (app config overrides provider config). | ||
| */ | ||
| public function testMergeReplacesScalarsInAssociativeArrays(): void | ||
| { | ||
| $providerConfig = [ | ||
| 'database' => [ | ||
| 'default' => 'sqlite', | ||
| 'connections' => [ | ||
| 'pgsql' => [ | ||
| 'driver' => 'pgsql', | ||
| 'host' => 'localhost', | ||
| 'port' => 5432, | ||
| ], | ||
| ], | ||
| ], | ||
| ]; | ||
|
|
||
| $appConfig = [ | ||
| 'database' => [ | ||
| 'default' => 'pgsql', | ||
| 'connections' => [ | ||
| 'pgsql' => [ | ||
| 'host' => 'production-db.example.com', | ||
| ], | ||
| ], | ||
| ], | ||
| ]; | ||
|
|
||
| $result = $this->mergeConfigs($providerConfig, $appConfig); | ||
|
|
||
| // App's default should override provider's default | ||
| $this->assertSame('pgsql', $result['database']['default']); | ||
|
|
||
| // App's host should override provider's host | ||
| $this->assertSame( | ||
| 'production-db.example.com', | ||
| $result['database']['connections']['pgsql']['host'] | ||
| ); | ||
|
|
||
| // Driver should remain a string (not become an array) | ||
| $this->assertIsString( | ||
| $result['database']['connections']['pgsql']['driver'], | ||
| 'Driver should remain a string, not become an array' | ||
| ); | ||
| $this->assertSame('pgsql', $result['database']['connections']['pgsql']['driver']); | ||
|
|
||
| // Provider's port should be preserved | ||
| $this->assertSame(5432, $result['database']['connections']['pgsql']['port']); | ||
| } | ||
|
|
||
| /** | ||
| * Test merging multiple config arrays (simulating provider + root + autoload configs). | ||
| */ | ||
| public function testMergeMultipleConfigArrays(): void | ||
| { | ||
| $providerConfig = [ | ||
| 'commands' => ['CommandA', 'CommandB'], | ||
| 'app' => ['name' => 'Provider Default'], | ||
| ]; | ||
|
|
||
| $rootConfig = [ | ||
| 'app' => ['debug' => true], | ||
| ]; | ||
|
|
||
| $autoloadConfig1 = [ | ||
| 'commands' => ['CommandC'], | ||
| 'app' => ['name' => 'My App'], | ||
| ]; | ||
|
|
||
| $autoloadConfig2 = [ | ||
| 'database' => ['default' => 'mysql'], | ||
| ]; | ||
|
|
||
| // Merge all configs (simulating ConfigFactory behavior) | ||
| $result = $this->mergeConfigs($providerConfig, $rootConfig, $autoloadConfig1, $autoloadConfig2); | ||
|
|
||
| // All commands should be combined | ||
| $this->assertContains('CommandA', $result['commands']); | ||
| $this->assertContains('CommandB', $result['commands']); | ||
| $this->assertContains('CommandC', $result['commands']); | ||
|
|
||
| // Later app.name should win | ||
| $this->assertSame('My App', $result['app']['name']); | ||
|
|
||
| // app.debug should be merged in | ||
| $this->assertTrue($result['app']['debug']); | ||
|
|
||
| // database from autoloadConfig2 should be present | ||
| $this->assertSame('mysql', $result['database']['default']); | ||
| } | ||
|
|
||
| /** | ||
| * Simulate ConfigFactory's merge behavior using ProviderConfig::merge(). | ||
| * | ||
| * ConfigFactory uses ProviderConfig::mergeTwo() directly for merging. | ||
| * We test via ProviderConfig::merge() which uses the same mergeTwo() method | ||
| * internally, ensuring identical merge semantics. | ||
| */ | ||
| private function mergeConfigs(array ...$configs): array | ||
| { | ||
| $method = new ReflectionMethod(ProviderConfig::class, 'merge'); | ||
|
|
||
| return $method->invoke(null, ...$configs); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The special handling for dependencies with PriorityDefinition has a logic issue. When iterating through all arrays and processing dependencies, the code resets
$result['dependencies']to an empty array at line 114, which discards any dependencies that were already merged by themergeTwofunction. Then it iterates through all arrays again, but it checks if$dependis an instance of PriorityDefinition when$dependcomes from the now-empty$result['dependencies'], which means it will never be a PriorityDefinition on the first iteration.The logic should either:
Consider refactoring this to preserve the merged dependencies and only apply special PriorityDefinition merging logic when needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@albertcht This is a false positive. The "reset to empty and re-iterate" pattern is intentional and is an exact copy from https://github.com/hyperf/hyperf/blob/master/src/config/src/ProviderConfig.php.
Why it works this way:
The initial merge (
mergeTwoin this PR,array_merge_recursivein Hyperf's original) doesn't understandPriorityDefinitionobjects—it would just keep the last value for each key. The post-merge step rebuilds dependencies from scratch with special handling:$depend = null, so! null instanceof PriorityDefinition→ true → store the valuePriorityDefinition, override it (last one wins). If it is aPriorityDefinitionand the new value is also one, call$depend->merge($value)to combine priorities.This correctly handles the DI priority system where multiple providers can register competing implementations for the same interface, with the highest priority winning at resolution time.