Skip to content

Commit 9fe8593

Browse files
committed
feat: add explicitly registered bind value filters
1 parent 92991ab commit 9fe8593

14 files changed

Lines changed: 260 additions & 39 deletions

‎src/BindFilter.php‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
namespace GT\DomTemplate;
3+
4+
use Closure;
5+
use ReflectionMethod;
6+
7+
abstract class BindFilter {
8+
/** @var array<class-string, object> */
9+
private array $dependencies = [];
10+
11+
public function setDependency(object $dependency):void {
12+
unset($this->dependencies[$dependency::class]);
13+
$this->dependencies[$dependency::class] = $dependency;
14+
}
15+
16+
/**
17+
* @template T of object
18+
* @param class-string<T> $class
19+
* @return T|null
20+
*/
21+
public function getDependency(string $class):?object {
22+
foreach(array_reverse($this->dependencies) as $dependency) {
23+
if($dependency instanceof $class) {
24+
return $dependency;
25+
}
26+
}
27+
28+
return null;
29+
}
30+
31+
public function getFilter(string $name):?Closure {
32+
$methodName = "filter" . ucfirst($name);
33+
if(!method_exists($this, $methodName)) {
34+
return null;
35+
}
36+
37+
$method = new ReflectionMethod($this, $methodName);
38+
if(!$method->isPublic() || $method->isStatic()) {
39+
return null;
40+
}
41+
42+
return $method->getClosure($this);
43+
}
44+
}

‎src/BindFilterException.php‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
namespace GT\DomTemplate;
3+
4+
class BindFilterException extends DomTemplateException {
5+
public function __construct(
6+
public readonly string $filterName,
7+
public readonly string $expression,
8+
string $message,
9+
) {
10+
parent::__construct($message);
11+
}
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
namespace GT\DomTemplate;
3+
4+
class BindFilterNotFoundException extends BindFilterException {
5+
public function __construct(string $filterName, string $expression) {
6+
$methodName = "filter" . ucfirst($filterName);
7+
parent::__construct($filterName, $expression,
8+
"Bind filter \"$filterName\" could not be found for expression \"$expression\". "
9+
. "Define a public instance method named $methodName on the registered BindFilter.",
10+
);
11+
}
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
namespace GT\DomTemplate;
3+
4+
class BindFilterNotRegisteredException extends BindFilterException {
5+
public function __construct(string $filterName, string $expression) {
6+
parent::__construct($filterName, $expression,
7+
"No BindFilter instance is registered for expression \"$expression\". "
8+
. "Call Binder::registerBindFilter() before binding filtered values.",
9+
);
10+
}
11+
}

‎src/BindFilterPipeline.php‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
namespace GT\DomTemplate;
3+
4+
class BindFilterPipeline {
5+
private ?BindFilter $filter = null;
6+
7+
public function register(BindFilter $filter):void {
8+
$this->filter = $filter;
9+
}
10+
11+
/** @return array{0:string, 1:mixed} */
12+
public function apply(string $expression, mixed $value):array {
13+
$parts = explode(" ", $expression, 2);
14+
if(isset($parts[1]) && str_starts_with(ltrim($parts[1]), "|")) {
15+
throw new InvalidBindFilterExpressionException("", $expression);
16+
}
17+
$binding = explode("=", $parts[0], 2);
18+
$chain = explode("|", $binding[0]);
19+
$bindKey = array_shift($chain);
20+
if($value instanceof BindableValue) {
21+
$value = $chain ? $value->rawValue : $value->getBindingValue();
22+
}
23+
$callbacks = [];
24+
foreach($chain as $filterName) {
25+
if(!preg_match('/^[a-z][a-z0-9_]*$/iD', $filterName)) {
26+
throw new InvalidBindFilterExpressionException($filterName, $expression);
27+
}
28+
if(!$this->filter) {
29+
throw new BindFilterNotRegisteredException($filterName, $expression);
30+
}
31+
$callback = $this->filter->getFilter($filterName);
32+
if(!$callback) {
33+
throw new BindFilterNotFoundException($filterName, $expression);
34+
}
35+
$callbacks[] = $callback;
36+
}
37+
38+
foreach($callbacks as $callback) {
39+
$value = $callback($value);
40+
}
41+
42+
$unfilteredExpression = $bindKey;
43+
if(isset($binding[1])) {
44+
$unfilteredExpression .= "=" . $binding[1];
45+
}
46+
if(isset($parts[1])) {
47+
$unfilteredExpression .= " " . $parts[1];
48+
}
49+
50+
return [$unfilteredExpression, $value];
51+
}
52+
}

‎src/BindableCache.php‎

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ private function addMethodBindings(
119119
$methodName = $reflectionMethod->getName();
120120
foreach($this->getBindAttributes($reflectionMethod) as $reflectionAttribute) {
121121
$bindKey = $this->getBindKey($reflectionAttribute, $reflectionMethod);
122-
$attributeCache[$bindKey] = fn(object $object):null|iterable|string
123-
=> $this->nullableStringOrIterable($object, $methodName);
122+
$attributeCache[$bindKey] = fn(object $object, bool $preserveTypes = false):mixed
123+
=> $this->nullableStringOrIterable($object, $methodName, $preserveTypes);
124124
$this->cacheNestedClassName(
125125
$objectKeys,
126126
$bindKey,
@@ -144,8 +144,8 @@ private function addPropertyBindings(
144144
if(!empty($attributeList)) {
145145
foreach($attributeList as $reflectionAttribute) {
146146
$bindKey = $this->getBindKey($reflectionAttribute);
147-
$attributeCache[$bindKey] = fn(object $object):null|iterable|string
148-
=> $this->nullableStringOrIterable($object, $propertyName);
147+
$attributeCache[$bindKey] = fn(object $object, bool $preserveTypes = false):mixed
148+
=> $this->nullableStringOrIterable($object, $propertyName, $preserveTypes);
149149
$this->cacheNestedClassName(
150150
$objectKeys,
151151
$bindKey,
@@ -160,9 +160,9 @@ private function addPropertyBindings(
160160
return;
161161
}
162162

163-
$attributeCache[$propertyName] = fn(object $object):null|iterable|string
163+
$attributeCache[$propertyName] = fn(object $object, bool $preserveTypes = false):mixed
164164
=> isset($object->$propertyName)
165-
? $this->nullableStringOrIterable($object, $propertyName)
165+
? $this->nullableStringOrIterable($object, $propertyName, $preserveTypes)
166166
: null;
167167
$this->cacheNestedClassName(
168168
$objectKeys,
@@ -249,39 +249,47 @@ private function appendNestedBindableKeys(
249249
}
250250

251251
/**
252-
* @param object|array<string, string> $object
253-
* @return array<string, string>
252+
* @param object|array<string, mixed> $object
253+
* @return array<string, mixed>
254+
* @param bool $preserveTypes Retain original values alongside ordinary object binding values.
254255
*/
255-
public function convertToKvp(object|array $object):array {
256+
public function convertToKvp(object|array $object, bool $preserveTypes = false):array {
256257
if(is_array($object)) {
257258
return $object;
258259
}
259260

260261
if($object instanceof stdClass) {
261-
return $this->stringifyMap(get_object_vars($object));
262+
$values = get_object_vars($object);
263+
return $preserveTypes
264+
? array_map(fn(mixed $value):mixed => $this->preserveValue($value), $values)
265+
: $this->stringifyMap($values);
262266
}
263267

264268
if(!$this->isBindable($object)) {
265269
return [];
266270
}
267271

268-
return $this->convertBindableObjectToKvp($object);
272+
return $this->convertBindableObjectToKvp($object, $preserveTypes);
269273
}
270274

271-
/** @return array<string, null|string|array<int|string, mixed>> */
272-
private function convertBindableObjectToKvp(object $object):array {
275+
/** @return array<string, mixed> */
276+
private function convertBindableObjectToKvp(object $object, bool $preserveTypes):array {
273277
$kvp = [];
274278
foreach($this->bindableClassMap[$object::class] as $key => $valueGetter) {
275279
$targetObject = $this->resolveTargetObject($object, $key);
276280
$value = $targetObject
277-
? $valueGetter($targetObject)
281+
? $valueGetter($targetObject, $preserveTypes)
278282
: null;
279-
$kvp[$key] = $this->normalizeValue($value);
283+
$kvp[$key] = $preserveTypes ? $this->preserveValue($value) : $this->normalizeValue($value);
280284
}
281285

282286
return $kvp;
283287
}
284288

289+
private function preserveValue(mixed $value):mixed {
290+
return is_iterable($value) ? $value : new BindableValue($value);
291+
}
292+
285293
private function resolveTargetObject(object $object, string $key):?object {
286294
$targetObject = $object;
287295
$segments = explode(".", $key);
@@ -392,11 +400,11 @@ private function isAttribute(
392400
return strcasecmp($reflectionAttribute->getName(), $className) === 0;
393401
}
394402

395-
/** @return null|string|array<int|string, mixed> */
396403
private function nullableStringOrIterable(
397404
object $object,
398405
string $keyOrMethod,
399-
):null|iterable|string {
406+
bool $preserveTypes = false,
407+
):mixed {
400408
if(method_exists($object, $keyOrMethod)) {
401409
$value = $object->$keyOrMethod();
402410
}
@@ -407,7 +415,7 @@ private function nullableStringOrIterable(
407415
return null;
408416
}
409417

410-
if(is_scalar($value)) {
418+
if($preserveTypes || is_scalar($value)) {
411419
return $value;
412420
}
413421
elseif(is_iterable($value)) {

‎src/BindableValue.php‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
namespace GT\DomTemplate;
3+
4+
use Stringable;
5+
6+
/** @internal Retain the original value without changing ordinary object bindings. */
7+
class BindableValue {
8+
private bool $resolved = false;
9+
private mixed $bindingValue = null;
10+
11+
public function __construct(public readonly mixed $rawValue) {}
12+
13+
public function getBindingValue():mixed {
14+
if(!$this->resolved) {
15+
$this->bindingValue = match(true) {
16+
is_iterable($this->rawValue), is_null($this->rawValue) => $this->rawValue,
17+
is_scalar($this->rawValue), $this->rawValue instanceof Stringable => (string)$this->rawValue,
18+
default => null,
19+
};
20+
$this->resolved = true;
21+
}
22+
23+
return $this->bindingValue;
24+
}
25+
}

‎src/Binder.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
use GT\Dom\Element;
55

66
abstract class Binder {
7+
abstract public function registerBindFilter(BindFilter $filter):void;
8+
79
abstract public function bindValue(
810
mixed $value,
911
null|string|Element $context = null

‎src/DocumentBinder.php‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ public function setDependencies(
3535
$this->bindableCache = $bindableCache;
3636
}
3737

38+
public function registerBindFilter(BindFilter $filter):void {
39+
$this->elementBinder->registerBindFilter($filter);
40+
}
41+
3842
/**
3943
* Applies the string value of $value anywhere within $context that
4044
* has a data-bind attribute with no specified key.
@@ -101,7 +105,7 @@ public function bindData(
101105
if(is_object($kvp)) {
102106
if($this->bindableCache->isBindable($kvp)) {
103107
$object = $kvp;
104-
$kvp = $this->bindableCache->convertToKvp($kvp);
108+
$kvp = $this->bindableCache->convertToKvp($kvp, preserveTypes: true);
105109
}
106110
}
107111

‎src/ElementBinder.php‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ class ElementBinder {
88
private HTMLAttributeCollection $htmlAttributeCollection;
99
private PlaceholderBinder $placeholderBinder;
1010

11+
public function registerBindFilter(BindFilter $filter):void {
12+
$this->htmlAttributeBinder->registerBindFilter($filter);
13+
}
14+
1115
public function setDebugSource(?string $debugSource):void {
1216
$this->htmlAttributeBinder->setDebugSource($debugSource);
1317
}
@@ -31,16 +35,17 @@ public function bind(
3135
mixed $value,
3236
Element $context
3337
):void {
34-
if(!is_null($value) && !is_scalar($value) && !is_iterable($value)) {
35-
$value = new BindValue($value);
36-
}
37-
3838
/** @var Element $element */
3939
foreach($this->htmlAttributeCollection->find($context) as $element) {
4040
$this->htmlAttributeBinder->expandAttributes($element);
4141
$this->htmlAttributeBinder->bind($key, $value, $element);
4242
}
4343

44+
if(!is_null($value) && !is_scalar($value) && !is_iterable($value)
45+
&& !$value instanceof BindableValue) {
46+
$value = new BindValue($value);
47+
}
48+
4449
$this->placeholderBinder->bind($key, $value, $context);
4550
}
4651
}

0 commit comments

Comments
 (0)