diff --git a/src/DeepMergeStrategy.php b/src/DeepMergeStrategy.php new file mode 100644 index 00000000..d5e54963 --- /dev/null +++ b/src/DeepMergeStrategy.php @@ -0,0 +1,79 @@ + [$key => $value], + is_array($key) => $key, + $key instanceof Arrayable => $value->toArray(), + }; + + foreach ($newProps as $key => $prop) { + $propArray = $this->attemptArrayCast($prop); + $mergedPropArray = $this->attemptArrayCast(Arr::get($mergedProps, $key)); + + $shouldFlattenPropArray = is_int($key) && is_array($propArray); + if ($shouldFlattenPropArray) { + $mergedProps = $this->merge($propArray, $mergedProps); + + continue; + } + + $shouldOverride = ! is_array($propArray) || ! is_array($mergedPropArray); + if ($shouldOverride) { + Arr::set($mergedProps, $key, $propArray); + + continue; + } + + $shouldConcatenate = $this->isIndexedArray($propArray) && $this->isIndexedArray($mergedPropArray); + if ($shouldConcatenate) { + Arr::set($mergedProps, $key, array_merge($mergedPropArray, $propArray)); + + continue; + } + + Arr::set($mergedProps, $key, $this->merge($propArray, $mergedPropArray)); + } + + return $mergedProps; + } + + protected function isIndexedArray(array $array): bool + { + return array_keys($array) === range(0, count($array) - 1); + } + + protected function attemptArrayCast(mixed $value): mixed + { + if ($value instanceof Closure) { + $reflection = new ReflectionFunction($value); + + if (! $reflection->getNumberOfRequiredParameters()) { + $value = call_user_func($value); + } + } + + if ($value instanceof Arrayable) { + return $value->toArray(); + } + + return $value; + } +} diff --git a/src/Inertia.php b/src/Inertia.php index 104f03ac..3c567a8b 100644 --- a/src/Inertia.php +++ b/src/Inertia.php @@ -8,6 +8,7 @@ * @method static void setRootView(string $name) * @method static void share(string|array|\Illuminate\Contracts\Support\Arrayable $key, mixed $value = null) * @method static mixed getShared(string|null $key = null, mixed $default = null) + * @method static self setSharedPropMerger(MergeStrategy $mergeStrategy) * @method static void clearHistory() * @method static void encryptHistory($encrypt = true) * @method static void flushShared() diff --git a/src/MergeStrategy.php b/src/MergeStrategy.php new file mode 100644 index 00000000..946fbae0 --- /dev/null +++ b/src/MergeStrategy.php @@ -0,0 +1,10 @@ +sharedProps = array_merge($this->sharedProps, $key); - } elseif ($key instanceof Arrayable) { - $this->sharedProps = array_merge($this->sharedProps, $key->toArray()); - } else { - Arr::set($this->sharedProps, $key, $value); + if ($value instanceof MergeStrategy) { + $this->sharedProps = $value->merge($this->sharedProps, $key); + + return; } + + if (is_null($this->sharedPropMerger)) { + $this->sharedPropMerger = App::make(MergeStrategy::class); + } + + $this->sharedProps = $this->sharedPropMerger->merge($this->sharedProps, $key, $value); } /** @@ -68,6 +74,13 @@ public function getShared(?string $key = null, $default = null) return $this->sharedProps; } + public function setSharedPropMerger(MergeStrategy $mergeStrategy): self + { + $this->sharedPropMerger = $mergeStrategy; + + return $this; + } + /** * @return void */ @@ -157,9 +170,11 @@ public function render(string $component, $props = []): Response $props = $props->toArray(); } + $this->share($props); + return new Response( $component, - array_merge($this->sharedProps, $props), + $this->sharedProps, $this->rootView, $this->getVersion(), $this->encryptHistory ?? config('inertia.history.encrypt', false), diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index b0e0e0c8..906df6b6 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -20,6 +20,7 @@ public function register(): void { $this->app->singleton(ResponseFactory::class); $this->app->bind(Gateway::class, HttpGateway::class); + $this->app->bind(MergeStrategy::class, ShallowMergeStrategy::class); $this->mergeConfigFrom( __DIR__.'/../config/inertia.php', diff --git a/src/ShallowMergeStrategy.php b/src/ShallowMergeStrategy.php new file mode 100644 index 00000000..59e15a57 --- /dev/null +++ b/src/ShallowMergeStrategy.php @@ -0,0 +1,22 @@ +toArray()); + } else { + Arr::set($original, $key, $value); + } + + return $original; + } +} diff --git a/tests/DeepMergeStrategyTest.php b/tests/DeepMergeStrategyTest.php new file mode 100644 index 00000000..33d8d18d --- /dev/null +++ b/tests/DeepMergeStrategyTest.php @@ -0,0 +1,222 @@ + ['view'], + ]; + $input = [ + 'can' => ['edit'], + ]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'can' => [ + 'view', + 'edit', + ], + ], $result); + } + + public function test_it_merges_props_by_key(): void + { + $original = [ + 'can' => ['view'], + ]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, 'can', ['edit']); + + $this->assertEquals([ + 'can' => [ + 'view', + 'edit', + ], + ], $result); + } + + public function test_it_adds_props_with_different_keys_without_merging(): void + { + $original = ['pages' => ['user.show']]; + $input = ['users' => ['John Doe']]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'users' => ['John Doe'], + 'pages' => ['user.show'], + ], $result); + } + + public function test_it_overrides_existing_values(): void + { + $original = ['page' => 'user.show']; + $input = ['page' => 'user.index']; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'page' => 'user.index', + ], $result); + } + + public function test_it_flattens_nested_props_when_the_root_key_is_not_associative(): void + { + $original = [ + 'user' => [ + 'id' => 1, + ], + ]; + $input = [ + 'user' => [ + 'name' => 'John Doe', + 'hobbies' => ['tennis', 'chess'], + ], + [ + 'user' => [ + 'gender' => 'male', + 'age' => 40, + ], + ], + ]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'user' => [ + 'id' => 1, + 'name' => 'John Doe', + 'gender' => 'male', + 'age' => 40, + 'hobbies' => ['tennis', 'chess'], + ], + ], $result); + } + + public function test_it_can_merge_arrayables(): void + { + $original = ['user' => new Collection(['id' => 1])]; + $input = ['user' => new Collection(['name' => 'John Doe'])]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'user' => [ + 'id' => 1, + 'name' => 'John Doe', + ], + ], $result); + } + + public function test_it_can_merge_callables(): void + { + $original = [ + 'user' => fn (): array => [ + 'id' => 1, + ], + ]; + $input = [ + 'user' => fn (): array => [ + 'name' => 'John Doe', + ], + ]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'user' => [ + 'id' => 1, + 'name' => 'John Doe', + ], + ], $result); + } + + public function test_it_overrides_values_with_unmergeable_types(): void + { + $original = [ + 'resource' => new FakeResource(['Original']), + 'scalar' => 'Original', + 'uncallable' => fn (string $value): string => 'Original', + ]; + $input = [ + 'resource' => new FakeResource(['Replacement']), + 'scalar' => 'Replacement', + 'uncallable' => fn (string $value): string => 'Replacement', + ]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals($input, $result); + } + + public function test_it_deep_merges_arrayables_and_arrays(): void + { + $original = [ + 'auth' => [ + 'user' => new Collection([ + 'id' => 1, + 'can' => new Collection(['delete_profile']), + ]), + ], + ]; + $input = [ + 'auth.user' => new Collection([ + 'name' => 'John Doe', + ]), + 'auth.user.can' => [ + 'edit_profile', + ], + ]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'auth' => [ + 'user' => [ + 'name' => 'John Doe', + 'id' => 1, + 'can' => [ + 'delete_profile', + 'edit_profile', + ], + ], + ], + ], $result); + } + + public function test_it_flattens_and_merges_nested_props(): void + { + $original = [ + 'can' => [ + 'edit_profile' => false, + 'delete_profile' => false, + ], + ]; + $input = [ + [ + 'can' => [ + 'manage_profiles' => true, + ], + ], + ]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'can' => [ + 'edit_profile' => false, + 'delete_profile' => false, + 'manage_profiles' => true, + ], + ], $result); + } +} diff --git a/tests/ResponseFactoryTest.php b/tests/ResponseFactoryTest.php index 65e7df46..1d57abee 100644 --- a/tests/ResponseFactoryTest.php +++ b/tests/ResponseFactoryTest.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Route; use Inertia\AlwaysProp; +use Inertia\DeepMergeStrategy; use Inertia\DeferProp; use Inertia\Inertia; use Inertia\LazyProp; @@ -227,6 +228,110 @@ public function test_dot_props_with_callbacks_are_merged_from_shared(): void ]); } + public function test_shared_props_use_shallow_merge_by_default(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('auth.user.can', [ + 'delete_user' => false, + ]); + + return Inertia::render('User/Show', [ + 'auth.user.can' => [ + 'edit_user' => false, + ], + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Show', + 'props' => [ + 'auth' => [ + 'user' => [ + 'can' => [ + 'edit_user' => false, + ], + ], + ], + ], + ]); + } + + public function test_can_override_shared_prop_merger_globally(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::setSharedPropMerger(new DeepMergeStrategy); + + Inertia::share('auth.user.can', [ + 'delete_user' => false, + ]); + + return Inertia::render('User/Show', [ + 'auth.user.can' => [ + 'edit_user' => false, + ], + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Show', + 'props' => [ + 'auth' => [ + 'user' => [ + 'can' => [ + 'delete_user' => false, + 'edit_user' => false, + ], + ], + ], + ], + ]); + } + + public function test_merge_strategy_can_be_applied_per_share_call(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share([ + 'auth.user.id' => 1, + 'auth.user.can' => [ + 'delete_user' => false, + ], + ]); + Inertia::share([ + 'auth.user.can' => [ + 'edit_user' => false, + ], + ], new DeepMergeStrategy); + + return Inertia::render('User/Show', [ + 'auth.user.name' => 'John Doe', + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Show', + 'props' => [ + 'auth' => [ + 'user' => [ + 'name' => 'John Doe', + 'can' => [ + 'delete_user' => false, + 'edit_user' => false, + ], + ], + ], + ], + ]); + } + public function test_can_flush_shared_data(): void { Inertia::share('foo', 'bar'); diff --git a/tests/ShallowMergeStrategyTest.php b/tests/ShallowMergeStrategyTest.php new file mode 100644 index 00000000..abce82f4 --- /dev/null +++ b/tests/ShallowMergeStrategyTest.php @@ -0,0 +1,116 @@ + [ + 'view', + ], + ]; + $input = [ + 'user' => [ + 'name' => 'John Doe', + ], + ]; + + $result = App::make(ShallowMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'can' => [ + 'view', + ], + 'user' => [ + 'name' => 'John Doe', + ], + ], $result); + } + + public function test_it_performs_a_shallow_merge(): void + { + $original = [ + 'can' => [ + 'view', + ], + ]; + $input = [ + 'can' => [ + 'edit', + ], + ]; + + $result = App::make(ShallowMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'can' => [ + 'edit', + ], + ], $result); + } + + public function test_it_can_handle_arrayables(): void + { + $original = [ + 'can' => [ + 'edit', + ], + ]; + $input = new Collection([ + 'user' => [ + 'name' => 'John Doe', + ], + ]); + + $result = App::make(ShallowMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'can' => [ + 'edit', + ], + 'user' => [ + 'name' => 'John Doe', + ], + ], $result); + } + + public function test_it_can_append_values_when_using_a_specific_key(): void + { + $original = [ + 'user' => [ + 'id' => 1, + ], + ]; + + $result = App::make(ShallowMergeStrategy::class)->merge($original, 'user.name', 'John Doe'); + + $this->assertEquals([ + 'user' => [ + 'id' => 1, + 'name' => 'John Doe', + ], + ], $result); + } + + public function test_it_replaces_the_original_value_for_known_keys(): void + { + $original = [ + 'can' => [ + 'view', + ], + ]; + + $result = App::make(ShallowMergeStrategy::class)->merge($original, 'can', ['edit']); + + $this->assertEquals([ + 'can' => [ + 'edit', + ], + ], $result); + } +}