feat: add Liquid filters where_exp and map_to_i

This commit is contained in:
Benjamin Nussbaum 2025-10-01 21:57:11 +02:00
parent 4af4bfe14a
commit 93dacb0baf
5 changed files with 629 additions and 3 deletions

View file

@ -2,6 +2,7 @@
namespace App\Liquid\Filters; namespace App\Liquid\Filters;
use App\Liquid\Utils\ExpressionUtils;
use Keepsuit\Liquid\Filters\FiltersProvider; use Keepsuit\Liquid\Filters\FiltersProvider;
/** /**
@ -89,4 +90,47 @@ class Data extends FiltersProvider
{ {
return json_decode($json, true); return json_decode($json, true);
} }
/**
* Filter a collection using an expression
*
* @param mixed $input The collection to filter
* @param string $variable The variable name to use in the expression
* @param string $expression The expression to evaluate
* @return array The filtered collection
*/
public function where_exp(mixed $input, string $variable, string $expression): array
{
// Return input as-is if it's not an array or doesn't have values method
if (! is_array($input)) {
return is_string($input) ? [$input] : [];
}
// Convert hash to array of values if needed
if (ExpressionUtils::isAssociativeArray($input)) {
$input = array_values($input);
}
$condition = ExpressionUtils::parseCondition($expression);
$result = [];
foreach ($input as $object) {
if (ExpressionUtils::evaluateCondition($condition, $variable, $object)) {
$result[] = $object;
}
}
return $result;
}
/**
* Convert array of strings to integers
*
* @param array $input Array of string numbers
* @return array Array of integers
*/
public function map_to_i(array $input): array
{
return array_map('intval', $input);
}
} }

View file

@ -0,0 +1,159 @@
<?php
namespace App\Liquid\Utils;
/**
* Utility class for parsing and evaluating expressions in Liquid filters
*/
class ExpressionUtils
{
/**
* Check if an array is associative
*/
public static function isAssociativeArray(array $array): bool
{
if (empty($array)) {
return false;
}
return array_keys($array) !== range(0, count($array) - 1);
}
/**
* Parse a condition expression into a structured format
*/
public static function parseCondition(string $expression): array
{
$expression = mb_trim($expression);
// Handle logical operators (and, or)
if (str_contains($expression, ' and ')) {
$parts = explode(' and ', $expression, 2);
return [
'type' => 'and',
'left' => self::parseCondition(mb_trim($parts[0])),
'right' => self::parseCondition(mb_trim($parts[1])),
];
}
if (str_contains($expression, ' or ')) {
$parts = explode(' or ', $expression, 2);
return [
'type' => 'or',
'left' => self::parseCondition(mb_trim($parts[0])),
'right' => self::parseCondition(mb_trim($parts[1])),
];
}
// Handle comparison operators
$operators = ['>=', '<=', '!=', '==', '>', '<', '='];
foreach ($operators as $operator) {
if (str_contains($expression, $operator)) {
$parts = explode($operator, $expression, 2);
return [
'type' => 'comparison',
'left' => mb_trim($parts[0]),
'operator' => $operator === '=' ? '==' : $operator,
'right' => mb_trim($parts[1]),
];
}
}
// If no operator found, treat as a simple expression
return [
'type' => 'simple',
'expression' => $expression,
];
}
/**
* Evaluate a condition against an object
*/
public static function evaluateCondition(array $condition, string $variable, mixed $object): bool
{
switch ($condition['type']) {
case 'and':
return self::evaluateCondition($condition['left'], $variable, $object) &&
self::evaluateCondition($condition['right'], $variable, $object);
case 'or':
return self::evaluateCondition($condition['left'], $variable, $object) ||
self::evaluateCondition($condition['right'], $variable, $object);
case 'comparison':
$leftValue = self::resolveValue($condition['left'], $variable, $object);
$rightValue = self::resolveValue($condition['right'], $variable, $object);
return match ($condition['operator']) {
'==' => $leftValue === $rightValue,
'!=' => $leftValue !== $rightValue,
'>' => $leftValue > $rightValue,
'<' => $leftValue < $rightValue,
'>=' => $leftValue >= $rightValue,
'<=' => $leftValue <= $rightValue,
default => false,
};
case 'simple':
$value = self::resolveValue($condition['expression'], $variable, $object);
return (bool) $value;
default:
return false;
}
}
/**
* Resolve a value from an expression, variable, or literal
*/
public static function resolveValue(string $expression, string $variable, mixed $object): mixed
{
$expression = mb_trim($expression);
// If it's the variable name, return the object
if ($expression === $variable) {
return $object;
}
// If it's a property access (e.g., "n.age"), resolve it
if (str_starts_with($expression, $variable.'.')) {
$property = mb_substr($expression, mb_strlen($variable) + 1);
if (is_array($object) && array_key_exists($property, $object)) {
return $object[$property];
}
if (is_object($object) && property_exists($object, $property)) {
return $object->$property;
}
return null;
}
// Try to parse as a number
if (is_numeric($expression)) {
return str_contains($expression, '.') ? (float) $expression : (int) $expression;
}
// Try to parse as boolean
if (in_array(mb_strtolower($expression), ['true', 'false'])) {
return mb_strtolower($expression) === 'true';
}
// Try to parse as null
if (mb_strtolower($expression) === 'null') {
return null;
}
// Return as string (remove quotes if present)
if ((str_starts_with($expression, '"') && str_ends_with($expression, '"')) ||
(str_starts_with($expression, "'") && str_ends_with($expression, "'"))) {
return mb_substr($expression, 1, -1);
}
return $expression;
}
}

View file

@ -29,7 +29,6 @@ assign collection = json_string | parse_json
{{ tide | json }} {{ tide | json }}
{%- endfor %} {%- endfor %}
LIQUID LIQUID
,
]); ]);
$result = $plugin->render('full'); $result = $plugin->render('full');
@ -55,7 +54,6 @@ assign collection = json_string | parse_json
{{ tide | json }} {{ tide | json }}
{%- endfor %} {%- endfor %}
LIQUID LIQUID
,
]); ]);
$result = $plugin->render('full'); $result = $plugin->render('full');
@ -81,7 +79,6 @@ assign collection = json_string | parse_json
{{ tide | json }} {{ tide | json }}
{%- endfor %} {%- endfor %}
LIQUID LIQUID
,
]); ]);
$result = $plugin->render('full'); $result = $plugin->render('full');
@ -122,3 +119,58 @@ it('keeps scalar url_encode behavior intact', function (): void {
expect($output)->toBe('hello+world'); expect($output)->toBe('hello+world');
}); });
test('where_exp filter works in liquid template', function (): void {
$plugin = Plugin::factory()->create([
'markup_language' => 'liquid',
'render_markup' => <<<'LIQUID'
{% liquid
assign nums = "1, 2, 3, 4, 5" | split: ", " | map_to_i
assign filtered = nums | where_exp: "n", "n >= 3"
%}
{% for num in filtered %}
{{ num }}
{%- endfor %}
LIQUID
]);
$result = $plugin->render('full');
// Debug: Let's see what the actual output is
// The issue might be that the HTML contains "1" in other places
// Let's check if the filtered numbers are actually in the content
$this->assertStringContainsString('3', $result);
$this->assertStringContainsString('4', $result);
$this->assertStringContainsString('5', $result);
// Instead of checking for absence of 1 and 2, let's verify the count
// The filtered result should only contain 3, 4, 5
$filteredContent = strip_tags($result);
$this->assertStringNotContainsString('1', $filteredContent);
$this->assertStringNotContainsString('2', $filteredContent);
});
test('where_exp filter works with object properties', function (): void {
$plugin = Plugin::factory()->create([
'markup_language' => 'liquid',
'render_markup' => <<<'LIQUID'
{% liquid
assign users = '[{"name":"Alice","age":25},{"name":"Bob","age":30},{"name":"Charlie","age":35}]' | parse_json
assign adults = users | where_exp: "user", "user.age >= 30"
%}
{% for user in adults %}
{{ user.name }} ({{ user.age }})
{%- endfor %}
LIQUID
]);
$result = $plugin->render('full');
// Should output users >= 30
$this->assertStringContainsString('Bob (30)', $result);
$this->assertStringContainsString('Charlie (35)', $result);
// Should not contain users < 30
$this->assertStringNotContainsString('Alice (25)', $result);
});

View file

@ -325,3 +325,173 @@ test('parse_json filter handles primitive values', function (): void {
expect($filter->parse_json('false'))->toBe(false); expect($filter->parse_json('false'))->toBe(false);
expect($filter->parse_json('null'))->toBe(null); expect($filter->parse_json('null'))->toBe(null);
}); });
test('map_to_i filter converts string numbers to integers', function (): void {
$filter = new Data();
$input = ['1', '2', '3', '4', '5'];
expect($filter->map_to_i($input))->toBe([1, 2, 3, 4, 5]);
});
test('map_to_i filter handles mixed string numbers', function (): void {
$filter = new Data();
$input = ['5', '4', '3', '2', '1'];
expect($filter->map_to_i($input))->toBe([5, 4, 3, 2, 1]);
});
test('map_to_i filter handles decimal strings', function (): void {
$filter = new Data();
$input = ['1.5', '2.7', '3.0'];
expect($filter->map_to_i($input))->toBe([1, 2, 3]);
});
test('map_to_i filter handles empty array', function (): void {
$filter = new Data();
$input = [];
expect($filter->map_to_i($input))->toBe([]);
});
test('where_exp filter returns string as array when input is string', function (): void {
$filter = new Data();
$input = 'just a string';
expect($filter->where_exp($input, 'la', 'le'))->toBe(['just a string']);
});
test('where_exp filter filters numbers with comparison', function (): void {
$filter = new Data();
$input = [1, 2, 3, 4, 5];
expect($filter->where_exp($input, 'n', 'n >= 3'))->toBe([3, 4, 5]);
});
test('where_exp filter filters numbers with greater than', function (): void {
$filter = new Data();
$input = [1, 2, 3, 4, 5];
expect($filter->where_exp($input, 'n', 'n > 2'))->toBe([3, 4, 5]);
});
test('where_exp filter filters numbers with less than', function (): void {
$filter = new Data();
$input = [1, 2, 3, 4, 5];
expect($filter->where_exp($input, 'n', 'n < 4'))->toBe([1, 2, 3]);
});
test('where_exp filter filters numbers with equality', function (): void {
$filter = new Data();
$input = [1, 2, 3, 4, 5];
expect($filter->where_exp($input, 'n', 'n == 3'))->toBe([3]);
});
test('where_exp filter filters numbers with not equal', function (): void {
$filter = new Data();
$input = [1, 2, 3, 4, 5];
expect($filter->where_exp($input, 'n', 'n != 3'))->toBe([1, 2, 4, 5]);
});
test('where_exp filter filters objects by property', function (): void {
$filter = new Data();
$input = [
['name' => 'Alice', 'age' => 25],
['name' => 'Bob', 'age' => 30],
['name' => 'Charlie', 'age' => 35],
];
expect($filter->where_exp($input, 'person', 'person.age >= 30'))->toBe([
['name' => 'Bob', 'age' => 30],
['name' => 'Charlie', 'age' => 35],
]);
});
test('where_exp filter filters objects by string property', function (): void {
$filter = new Data();
$input = [
['name' => 'Alice', 'role' => 'admin'],
['name' => 'Bob', 'role' => 'user'],
['name' => 'Charlie', 'role' => 'admin'],
];
expect($filter->where_exp($input, 'user', 'user.role == "admin"'))->toBe([
['name' => 'Alice', 'role' => 'admin'],
['name' => 'Charlie', 'role' => 'admin'],
]);
});
test('where_exp filter handles and operator', function (): void {
$filter = new Data();
$input = [
['name' => 'Alice', 'age' => 25, 'active' => true],
['name' => 'Bob', 'age' => 30, 'active' => false],
['name' => 'Charlie', 'age' => 35, 'active' => true],
];
expect($filter->where_exp($input, 'person', 'person.age >= 30 and person.active == true'))->toBe([
['name' => 'Charlie', 'age' => 35, 'active' => true],
]);
});
test('where_exp filter handles or operator', function (): void {
$filter = new Data();
$input = [
['name' => 'Alice', 'age' => 25, 'role' => 'admin'],
['name' => 'Bob', 'age' => 30, 'role' => 'user'],
['name' => 'Charlie', 'age' => 35, 'role' => 'user'],
];
expect($filter->where_exp($input, 'person', 'person.age < 30 or person.role == "admin"'))->toBe([
['name' => 'Alice', 'age' => 25, 'role' => 'admin'],
]);
});
test('where_exp filter handles simple boolean expressions', function (): void {
$filter = new Data();
$input = [
['name' => 'Alice', 'active' => true],
['name' => 'Bob', 'active' => false],
['name' => 'Charlie', 'active' => true],
];
expect($filter->where_exp($input, 'person', 'person.active'))->toBe([
['name' => 'Alice', 'active' => true],
['name' => 'Charlie', 'active' => true],
]);
});
test('where_exp filter handles empty array', function (): void {
$filter = new Data();
$input = [];
expect($filter->where_exp($input, 'n', 'n >= 3'))->toBe([]);
});
test('where_exp filter handles associative array', function (): void {
$filter = new Data();
$input = [
'a' => 1,
'b' => 2,
'c' => 3,
];
expect($filter->where_exp($input, 'n', 'n >= 2'))->toBe([2, 3]);
});
test('where_exp filter handles non-array input', function (): void {
$filter = new Data();
$input = 123;
expect($filter->where_exp($input, 'n', 'n >= 3'))->toBe([]);
});
test('where_exp filter handles null input', function (): void {
$filter = new Data();
$input = null;
expect($filter->where_exp($input, 'n', 'n >= 3'))->toBe([]);
});

View file

@ -0,0 +1,201 @@
<?php
use App\Liquid\Utils\ExpressionUtils;
test('isAssociativeArray returns true for associative array', function (): void {
$array = ['a' => 1, 'b' => 2, 'c' => 3];
expect(ExpressionUtils::isAssociativeArray($array))->toBeTrue();
});
test('isAssociativeArray returns false for indexed array', function (): void {
$array = [1, 2, 3, 4, 5];
expect(ExpressionUtils::isAssociativeArray($array))->toBeFalse();
});
test('isAssociativeArray returns false for empty array', function (): void {
$array = [];
expect(ExpressionUtils::isAssociativeArray($array))->toBeFalse();
});
test('parseCondition handles simple comparison', function (): void {
$result = ExpressionUtils::parseCondition('n >= 3');
expect($result)->toBe([
'type' => 'comparison',
'left' => 'n',
'operator' => '>=',
'right' => '3',
]);
});
test('parseCondition handles equality comparison', function (): void {
$result = ExpressionUtils::parseCondition('user.role == "admin"');
expect($result)->toBe([
'type' => 'comparison',
'left' => 'user.role',
'operator' => '==',
'right' => '"admin"',
]);
});
test('parseCondition handles and operator', function (): void {
$result = ExpressionUtils::parseCondition('user.age >= 30 and user.active == true');
expect($result)->toBe([
'type' => 'and',
'left' => [
'type' => 'comparison',
'left' => 'user.age',
'operator' => '>=',
'right' => '30',
],
'right' => [
'type' => 'comparison',
'left' => 'user.active',
'operator' => '==',
'right' => 'true',
],
]);
});
test('parseCondition handles or operator', function (): void {
$result = ExpressionUtils::parseCondition('user.age < 30 or user.role == "admin"');
expect($result)->toBe([
'type' => 'or',
'left' => [
'type' => 'comparison',
'left' => 'user.age',
'operator' => '<',
'right' => '30',
],
'right' => [
'type' => 'comparison',
'left' => 'user.role',
'operator' => '==',
'right' => '"admin"',
],
]);
});
test('parseCondition handles simple expression', function (): void {
$result = ExpressionUtils::parseCondition('user.active');
expect($result)->toBe([
'type' => 'simple',
'expression' => 'user.active',
]);
});
test('evaluateCondition handles comparison with numbers', function (): void {
$condition = ExpressionUtils::parseCondition('n >= 3');
expect(ExpressionUtils::evaluateCondition($condition, 'n', 5))->toBeTrue();
expect(ExpressionUtils::evaluateCondition($condition, 'n', 2))->toBeFalse();
expect(ExpressionUtils::evaluateCondition($condition, 'n', 3))->toBeTrue();
});
test('evaluateCondition handles comparison with strings', function (): void {
$condition = ExpressionUtils::parseCondition('user.role == "admin"');
$user = ['role' => 'admin'];
expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeTrue();
$user = ['role' => 'user'];
expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeFalse();
});
test('evaluateCondition handles and operator', function (): void {
$condition = ExpressionUtils::parseCondition('user.age >= 30 and user.active == true');
$user = ['age' => 35, 'active' => true];
expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeTrue();
$user = ['age' => 25, 'active' => true];
expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeFalse();
$user = ['age' => 35, 'active' => false];
expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeFalse();
});
test('evaluateCondition handles or operator', function (): void {
$condition = ExpressionUtils::parseCondition('user.age < 30 or user.role == "admin"');
$user = ['age' => 25, 'role' => 'user'];
expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeTrue();
$user = ['age' => 35, 'role' => 'admin'];
expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeTrue();
$user = ['age' => 35, 'role' => 'user'];
expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeFalse();
});
test('evaluateCondition handles simple boolean expression', function (): void {
$condition = ExpressionUtils::parseCondition('user.active');
$user = ['active' => true];
expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeTrue();
$user = ['active' => false];
expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeFalse();
});
test('resolveValue returns object when expression matches variable', function (): void {
$object = ['name' => 'Alice', 'age' => 25];
expect(ExpressionUtils::resolveValue('user', 'user', $object))->toBe($object);
});
test('resolveValue resolves property access for arrays', function (): void {
$object = ['name' => 'Alice', 'age' => 25];
expect(ExpressionUtils::resolveValue('user.name', 'user', $object))->toBe('Alice');
expect(ExpressionUtils::resolveValue('user.age', 'user', $object))->toBe(25);
});
test('resolveValue resolves property access for objects', function (): void {
$object = new stdClass();
$object->name = 'Alice';
$object->age = 25;
expect(ExpressionUtils::resolveValue('user.name', 'user', $object))->toBe('Alice');
expect(ExpressionUtils::resolveValue('user.age', 'user', $object))->toBe(25);
});
test('resolveValue returns null for non-existent properties', function (): void {
$object = ['name' => 'Alice'];
expect(ExpressionUtils::resolveValue('user.age', 'user', $object))->toBeNull();
});
test('resolveValue parses numeric values', function (): void {
expect(ExpressionUtils::resolveValue('123', 'user', []))->toBe(123);
expect(ExpressionUtils::resolveValue('45.67', 'user', []))->toBe(45.67);
});
test('resolveValue parses boolean values', function (): void {
expect(ExpressionUtils::resolveValue('true', 'user', []))->toBeTrue();
expect(ExpressionUtils::resolveValue('false', 'user', []))->toBeFalse();
expect(ExpressionUtils::resolveValue('TRUE', 'user', []))->toBeTrue();
expect(ExpressionUtils::resolveValue('FALSE', 'user', []))->toBeFalse();
});
test('resolveValue parses null value', function (): void {
expect(ExpressionUtils::resolveValue('null', 'user', []))->toBeNull();
expect(ExpressionUtils::resolveValue('NULL', 'user', []))->toBeNull();
});
test('resolveValue removes quotes from strings', function (): void {
expect(ExpressionUtils::resolveValue('"hello"', 'user', []))->toBe('hello');
expect(ExpressionUtils::resolveValue("'world'", 'user', []))->toBe('world');
});
test('resolveValue returns expression as-is for unquoted strings', function (): void {
expect(ExpressionUtils::resolveValue('hello', 'user', []))->toBe('hello');
expect(ExpressionUtils::resolveValue('world', 'user', []))->toBe('world');
});