Skip to content

Commit f7e2ecc

Browse files
committed
feat: implement Callback class
1 parent 482bd87 commit f7e2ecc

6 files changed

Lines changed: 294 additions & 9 deletions

File tree

‎config/eclipse-common.php‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
<?php
22

33
return [
4-
/*
4+
/**
55
* Available locales for the application.
6-
* Can be a list (array) of locale codes or a closure that returns such an array.
6+
* Can be a list (array) of locale codes or a Callback instance.
7+
* @see \Eclipse\Common\Support\Callback
78
*/
8-
'available_locales' => function () {
9-
return ['en', 'sl'];
10-
},
9+
'available_locales' => ['en', 'sl'],
1110
];

‎src/Support/Callback.php‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
namespace Eclipse\Common\Support;
4+
5+
use InvalidArgumentException;
6+
use ReflectionClass;
7+
use RuntimeException;
8+
9+
/**
10+
* Represents a callable entity that can encapsulate a class method, an invokable class, or a function.
11+
*
12+
* This class provides a serializable wrapper around callables, allowing them to be
13+
* stored in configuration files or serialized for later use.
14+
*
15+
* Example usage:
16+
* ```
17+
* // Create from a static class method
18+
* $callback = new Callback(ConfigLocaleCallback::class, 'getLocales');
19+
* $result = $callback(); // Invoke the callback
20+
*
21+
* // Create from an invokable class
22+
* $callback = new Callback(InvokableLocaleCallback::class);
23+
* $result = $callback(); // Invoke the callback
24+
*
25+
* // Create from a function
26+
* $callback = new Callback('array_filter');
27+
* $result = $callback($array, fn($item) => $item > 0);
28+
*
29+
* // Convert to a native callable
30+
* $callable = $callback->toCallable();
31+
*
32+
* // Use in configuration
33+
* 'available_locales' => new Callback(MyClass::class, 'getLocales'),
34+
* ```
35+
*/
36+
class Callback
37+
{
38+
private ?string $class_name = null;
39+
40+
private ?string $method_name = null;
41+
42+
private ?string $function_name = null;
43+
44+
public function __construct(string $callable_or_class, ?string $method_name = null)
45+
{
46+
if ($method_name !== null) {
47+
if (! class_exists($callable_or_class) || ! method_exists($callable_or_class, $method_name)) {
48+
throw new InvalidArgumentException('Specified class name or method name does not exist');
49+
}
50+
51+
$this->class_name = $callable_or_class;
52+
$this->method_name = $method_name;
53+
} elseif (class_exists($callable_or_class)) {
54+
if (! method_exists($callable_or_class, '__invoke')) {
55+
throw new InvalidArgumentException('Specified class is not invokable');
56+
}
57+
58+
$this->class_name = $callable_or_class;
59+
} elseif (is_callable($callable_or_class)) {
60+
$this->function_name = $callable_or_class;
61+
} else {
62+
throw new InvalidArgumentException('Specified function name is not callable');
63+
}
64+
}
65+
66+
public function __invoke(...$arguments): mixed
67+
{
68+
if (isset($this->class_name)) {
69+
if (isset($this->method_name)) {
70+
return call_user_func_array([$this->class_name, $this->method_name], $arguments);
71+
}
72+
73+
return (new $this->class_name)(...$arguments);
74+
}
75+
76+
if (isset($this->function_name)) {
77+
return call_user_func_array($this->function_name, $arguments);
78+
}
79+
80+
throw new RuntimeException('Callback is not properly initialized');
81+
}
82+
83+
public function __serialize(): array
84+
{
85+
return [
86+
'class_name' => $this->class_name,
87+
'method_name' => $this->method_name,
88+
'function_name' => $this->function_name,
89+
];
90+
}
91+
92+
public function __unserialize(array $data): void
93+
{
94+
$this->class_name = $data['class_name'] ?? null;
95+
$this->method_name = $data['method_name'] ?? null;
96+
$this->function_name = $data['function_name'] ?? null;
97+
}
98+
99+
public static function __set_state(array $properties): self
100+
{
101+
$callback = (new ReflectionClass(self::class))->newInstanceWithoutConstructor();
102+
103+
$callback->class_name = $properties['class_name'] ?? null;
104+
$callback->method_name = $properties['method_name'] ?? null;
105+
$callback->function_name = $properties['function_name'] ?? null;
106+
107+
return $callback;
108+
}
109+
110+
public function toCallable(): callable
111+
{
112+
if (isset($this->class_name)) {
113+
if (isset($this->method_name)) {
114+
return [$this->class_name, $this->method_name];
115+
}
116+
117+
return new $this->class_name;
118+
}
119+
120+
if (isset($this->function_name)) {
121+
return $this->function_name;
122+
}
123+
124+
throw new RuntimeException('Callback is not properly initialized');
125+
}
126+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace Tests\Support;
4+
5+
class ConfigLocaleCallback
6+
{
7+
public static function getLocales(): array
8+
{
9+
return ['en', 'sl', 'de'];
10+
}
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace Tests\Support;
4+
5+
class InvokableLocaleCallback
6+
{
7+
public function __invoke(): array
8+
{
9+
return ['en', 'sl', 'de'];
10+
}
11+
}

‎tests/Unit/Helpers/L10nHelperTest.php‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
use Eclipse\Common\Exceptions\InvalidConfigurationException;
44
use Eclipse\Common\Helpers\L10nHelper as Helper;
5+
use Eclipse\Common\Support\Callback;
56
use Illuminate\Support\Facades\Config;
7+
use Tests\Support\ConfigLocaleCallback;
68

79
test('it returns empty string for empty language code', function () {
810
expect(Helper::getLanguageName(''))->toBe('');
@@ -67,14 +69,14 @@
6769
->and($locales)->toBe(['en' => 'en', 'sl' => 'sl']);
6870
});
6971

70-
test('it can get available locales from callable config', function () {
71-
Config::set('eclipse-common.available_locales', fn () => ['en', 'de']);
72+
test('it can get available locales from Callback config', function () {
73+
Config::set('eclipse-common.available_locales', new Callback(ConfigLocaleCallback::class, 'getLocales'));
7274

7375
$locales = Helper::getAvailableLocales();
7476

7577
expect($locales)->toBeArray()
76-
->and($locales)->toHaveCount(2)
77-
->and($locales)->toBe(['en' => 'en', 'de' => 'de']);
78+
->and($locales)->toHaveCount(3)
79+
->and($locales)->toBe(['en' => 'en', 'sl' => 'sl', 'de' => 'de']);
7880
});
7981

8082
test('it throws exception for invalid locales configuration', function () {
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<?php
2+
3+
use Eclipse\Common\Helpers\L10nHelper;
4+
use Eclipse\Common\Support\Callback;
5+
use Illuminate\Support\Facades\Config;
6+
use Tests\Support\ConfigLocaleCallback;
7+
use Tests\Support\InvokableLocaleCallback;
8+
9+
test('it can be created from class method', function () {
10+
$callback = new Callback(ConfigLocaleCallback::class, 'getLocales');
11+
12+
expect($callback)->toBeInstanceOf(Callback::class)
13+
->and($callback->toCallable())->toBe([ConfigLocaleCallback::class, 'getLocales'])
14+
->and($callback())->toBe(['en', 'sl', 'de']);
15+
});
16+
17+
test('it throws exception when creating from non-existent class or method', function () {
18+
expect(fn () => new Callback('NonExistentClass', 'getLocales'))
19+
->toThrow(InvalidArgumentException::class, 'Specified class name or method name does not exist');
20+
21+
expect(fn () => new Callback(ConfigLocaleCallback::class, 'nonExistentMethod'))
22+
->toThrow(InvalidArgumentException::class, 'Specified class name or method name does not exist');
23+
});
24+
25+
test('it can be created from invokable class', function () {
26+
$callback = new Callback(InvokableLocaleCallback::class);
27+
28+
expect($callback)->toBeInstanceOf(Callback::class)
29+
->and($callback->toCallable())->toBeInstanceOf(InvokableLocaleCallback::class)
30+
->and($callback())->toBe(['en', 'sl', 'de']);
31+
});
32+
33+
test('it throws exception when creating from non-invokable class without method', function () {
34+
expect(fn () => new Callback(ConfigLocaleCallback::class))
35+
->toThrow(InvalidArgumentException::class, 'Specified class is not invokable');
36+
});
37+
38+
test('it can be created from function', function () {
39+
$callback = new Callback('strtoupper');
40+
41+
expect($callback)->toBeInstanceOf(Callback::class)
42+
->and($callback->toCallable())->toBe('strtoupper')
43+
->and($callback('hello'))->toBe('HELLO');
44+
});
45+
46+
test('it throws exception when creating from non-existent function', function () {
47+
expect(fn () => new Callback('non_existent_function_12345'))
48+
->toThrow(InvalidArgumentException::class, 'Specified function name is not callable');
49+
});
50+
51+
test('it throws exception when invoking uninitialized callback', function () {
52+
$callback = Callback::__set_state([]);
53+
54+
expect(fn () => $callback())
55+
->toThrow(RuntimeException::class, 'Callback is not properly initialized');
56+
57+
expect(fn () => $callback->toCallable())
58+
->toThrow(RuntimeException::class, 'Callback is not properly initialized');
59+
});
60+
61+
test('it can be serialized and unserialized', function () {
62+
$callback = new Callback(ConfigLocaleCallback::class, 'getLocales');
63+
64+
$serialized = serialize($callback);
65+
$unserialized = unserialize($serialized);
66+
67+
expect($unserialized)->toBeInstanceOf(Callback::class)
68+
->and($unserialized())->toBe(['en', 'sl', 'de']);
69+
70+
$functionCallback = new Callback('trim');
71+
$unserializedFunction = unserialize(serialize($functionCallback));
72+
73+
expect($unserializedFunction)->toBeInstanceOf(Callback::class)
74+
->and($unserializedFunction(' test '))->toBe('test');
75+
76+
$invokableCallback = new Callback(InvokableLocaleCallback::class);
77+
$unserializedInvokable = unserialize(serialize($invokableCallback));
78+
79+
expect($unserializedInvokable)->toBeInstanceOf(Callback::class)
80+
->and($unserializedInvokable())->toBe(['en', 'sl', 'de']);
81+
});
82+
83+
test('it supports var_export via set_state for config caching', function () {
84+
$callback = new Callback(ConfigLocaleCallback::class, 'getLocales');
85+
86+
$exported = var_export($callback, true);
87+
$restored = eval("return $exported;");
88+
89+
expect($restored)->toBeInstanceOf(Callback::class)
90+
->and($restored())->toBe(['en', 'sl', 'de']);
91+
92+
$invokableCallback = new Callback(InvokableLocaleCallback::class);
93+
$exportedInvokable = var_export($invokableCallback, true);
94+
$restoredInvokable = eval("return $exportedInvokable;");
95+
96+
expect($restoredInvokable)->toBeInstanceOf(Callback::class)
97+
->and($restoredInvokable())->toBe(['en', 'sl', 'de']);
98+
});
99+
100+
test('it can be used as a callback for available_locales config value', function () {
101+
$callback = new Callback(ConfigLocaleCallback::class, 'getLocales');
102+
103+
Config::set('eclipse-common.available_locales', $callback);
104+
105+
$locales = L10nHelper::getAvailableLocales();
106+
107+
expect($locales)->toBeArray()
108+
->and($locales)->toBe([
109+
'en' => 'en',
110+
'sl' => 'sl',
111+
'de' => 'de',
112+
]);
113+
});
114+
115+
test('it can be used as a callback for available_locales when config is cached with var_export', function () {
116+
$callback = new Callback(ConfigLocaleCallback::class, 'getLocales');
117+
118+
// Simulate config caching (var_export -> eval)
119+
$exported = var_export($callback, true);
120+
$restored = eval("return $exported;");
121+
122+
Config::set('eclipse-common.available_locales', $restored);
123+
124+
$locales = L10nHelper::getAvailableLocales();
125+
$options = L10nHelper::getLocaleOptions();
126+
127+
expect($locales)->toBe([
128+
'en' => 'en',
129+
'sl' => 'sl',
130+
'de' => 'de',
131+
])->and($options)->toBe([
132+
'en' => 'English (en)',
133+
'sl' => 'Slovenian (sl)',
134+
'de' => 'German (de)',
135+
]);
136+
});

0 commit comments

Comments
 (0)