library/q.php
<?php
declare(strict_types=1);
/**
* This subpackage provides a small, robust templating mechansim for binding
* values in queries safely and with some smart shortcuts enabled.
*/
namespace WabiORM;
/**
* Renders query templates, which hopefully reduce some of the plains of working
* with plain SQL.
*
* However, the template should be close enough to both plain SQL and existing
* template languages that it's easy to identify intent.
*
* @see docs/queries
* @subpackage WabiORM.Q
* @param string $template The query template string
* @param array $data The data to be bound in the query
* @return array
*/
function q(string $template, array $data): array {
$processors = processors();
$getValue = array_accessor_cached($data);
return parse_bindings(
$template, invoke_processor($processors, $getValue)
);
}
/**
* Returns whether the value or array of values are scalar.
*
* @internal
* @subpackage WabiORM.Q
* @param array|scalar $value The value to test
* @return bool
*/
function are_values_sane($value): bool {
if (\is_array($value)) {
$filtered = filter(map($value, 'WabiORM\is_value_sane'));
return count($filtered) === count($value);
}
return is_value_sane($value);
}
/**
* Partial application which wraps access to the passed data array in safety
* checks.
*
* @internal
* @subpackage WabiORM.Q
* @param array $data The data to wrap
* @return callable
*/
function array_accessor(array $data): callable {
return function (string $id) use ($data) {
invariant(
array_key_exists($id, $data),
'"' . $id . '" does not exist in given data'
);
invariant(
are_values_sane($data[$id]),
'value for "' . $id . '" must be a scalar ("' . gettype($data[$id]) . '" given)'
);
return $data[$id];
};
}
/**
* Partial application which wraps access to the passed data array in safety
* checks.
*
* This wraps over the top of array_accessor to reduce validation checks for
* values that may be accessed multiple times.
*
* @internal
* @subpackage WabiORM.Q
* @param array $data The data to wrap
* @return callable
*/
function array_accessor_cached(array $data): callable {
$accessor = array_accessor($data);
$checked = [];
return function (string $id) use ($accessor, &$checked) {
if (\array_key_exists($id, $checked)) {
return $checked[$id];
}
return $checked[$id] = $accessor($id);
};
}
/**
* Strips away characters which are not valid for use as an identifier.
*
* @internal
* @subpackage WabiORM.Q
* @param string $expression The binding expression to clean
* @return string
*/
function id_from_expr(string $expression): string {
return preg_replace('/[^_a-zA-Z0-9]+/', '', $expression);
}
/**
* Partial application which, when invoked, attempts to match a binding
* expression to a processor.
*
* @internal
* @subpackage WabiORM.Q
* @param array $processors The processors which can be invoked
* @param callable $getValue An interface which can be used to get a value
* @return callable
*/
function invoke_processor(array $processors, callable $getValue): callable {
return function (string $expr) use ($processors, $getValue) {
$id = id_from_expr($expr);
$processor = str_replace($id, 'id', $expr);
invariant(
array_key_exists($processor, $processors),
'unable to process expression "' . $expr . '"'
);
return $processors[$processor]($id, $getValue($id));
};
}
/**
* Returns whether the value can be used inside of a binding.
*
* @param scalar|null $value The value to test
* @return boolean
*/
function is_value_sane($value): bool {
return is_scalar($value) || is_null($value);
}
/**
* Returns the string indexes of the opening and closing tags for the next
* binding.
*
* This will also check for malformed tags.
*
* @internal
* @subpackage WabiORM.Q
* @param string $template The query string template to return the binding for
* @return array
*/
function next_binding_position(string $template): array {
$opens = strpos($template, '{');
if ($opens === false) {
return [false, false];
}
$closes = strpos($template, '}', $opens + 1);
invariant(
$closes !== false,
'missing closing "}" after character ' . $opens . ' of "' . $template . '"',
);
return [$opens, $closes];
}
/**
* Parses binding expressions from the given template and returns the resultant
* query and params.
*
* Processing of the binding expressions themselves is delegated to the given
* handler.
*
* @internal
* @subpackage WabiORM.Q
* @param string $template The query string template
* @param callable $handler The callback to be invoked when a binding is found
* @return array
*/
function parse_bindings(string $template, callable $handler): array {
$carry = $template;
$final = '';
$params = [];
[$opens, $closes] = next_binding_position($carry);
while ($opens !== false) {
$final .= substr($carry, 0, $opens);
$expr = substr($carry, $opens + 1, $closes - $opens - 1);
$carry = substr($carry, $closes + 1);
$result = $handler($expr);
$final .= $result[0];
array_push($params, ...$result[1]);
[$opens, $closes] = next_binding_position($carry);
}
if ($carry) {
$final .= $carry;
}
return [$final, $params];
}
/**
* Returns an array which maps expressions to a processor function.
*
* @internal
* @subpackage WabiORM.Q
* @return array
*/
function processors(): array {
static $processors;
if (! $processors) {
$processors = [
'id' => direct_processor(),
'*id' => raw_processor(),
'=id' => equals_processor(false),
'!id' => equals_processor(true),
'>id' => greater_processor(false),
'>=id' => greater_processor(true),
'<id' => lesser_processor(false),
'<=id' => lesser_processor(true),
'%id' => like_processor(true, false),
'%id%' => like_processor(true, true),
'id%' => like_processor(false, true),
'...id' => splat_processor(),
];
}
return $processors;
}
/**
* Returns a processor for handling direct binding expressions.
*
* @internal
* @subpackage WabiORM.Q
* @return callable
*/
function direct_processor(): callable {
return function ($identifier, $value) {
if (\is_array($value)) {
$repeated = str_repeat('?, ', count($value));
$trimmed = trim($repeated, ', ');
return [$trimmed, $value];
}
return ['?', [$value]];
};
}
/**
* Returns a processor for handling equality binding expressions.
*
* @internal
* @subpackage WabiORM.Q
* @param boolean $negate Whether to test for inequality instead
* @return callable
*/
function equals_processor(bool $negate): callable {
return function (string $identifier, $value) use ($negate) {
if (is_array($value) && count($value) > 1) {
return in_condition($identifier, $value, $negate);
}
if (is_array($value)) {
return equals_condition($identifier, first($value), $negate);
}
return equals_condition($identifier, $value, $negate);
};
}
/**
* Returns a processor for handling greater than/equal to binding expressions.
*
* @internal
* @subpackage WabiORM.Q
* @param boolean $equalTo Whether to also test for equality
* @return callable
*/
function greater_processor(bool $equalTo): callable {
return function (string $identifier, $value) use ($equalTo) {
return compound_condition($identifier, $value, $equalTo ? '>=' : '>');
};
}
/**
* Returns a processor for handling lesser than/equal to binding expressions.
*
* @internal
* @subpackage WabiORM.Q
* @param boolean $equalTo Whether to also test for equality
* @return callable
*/
function lesser_processor(bool $equalTo): callable {
return function (string $identifier, $value) use ($equalTo) {
return compound_condition($identifier, $value, $equalTo ? '<=' : '<');
};
}
/**
* Returns a processor for handling text-matching binding expressions.
*
* @internal
* @subpackage WabiORM.Q
* @param boolean $before
* @param boolean $after
* @return callable
*/
function like_processor(bool $before, bool $after): callable {
return function (string $identifier, $value) use ($before, $after) {
$params = map((array)$value, function ($value) use ($before, $after) {
if ($before) {
$value = '%' . $value;
}
if ($after) {
$value = $value . '%';
}
return $value;
});
return compound_condition($identifier, $params, 'like');
};
}
/**
* Returns a processor for handling raw binding expressions.
*
* @internal
* @subpackage WabiORM.Q
* @return callable
*/
function raw_processor(): callable {
return function ($identifier, $value) {
if (\is_array($value)) {
return [csvise($value), []];
}
return [$value, []];
};
}
/**
* Returns a processor for handling splat binding expressions.
*
* @return callable
*/
function splat_processor(): callable {
return function (string $identifier, $values): array {
invariant(
is_array($values),
'"..." bindings can only be used on arrays'
);
$conditions = [];
$params = [];
foreach ($values as $key => $value) {
[$condition, $param] = equals_condition($key, $value, false);
$conditions[] = $condition;
array_push($params, ...$param);
}
return [csvise($conditions), $params];
};
}
/**
* Returns a potentially compound condition (i.e. `(cond1 or cond2)`).
*
* The operator of the condition can be specified by the caller.
*
* @internal
* @subpackage WabiORM.Q
* @param string $field
* @param mixed $values
* @param string $op
* @return array
*/
function compound_condition(string $field, $values, string $op): array {
$values = to_array($values);
$condition = '';
foreach ($values as $value) {
$condition .= $field . ' ' . $op . ' ? or ';
}
$condition = trim($condition, ' or ');
if (count($values) > 1) {
$condition = '(' . $condition . ')';
}
return [$condition, $values];
}
/**
* Returns an equals or not equals condition for use in queries.
*
* @internal
* @subpackage WabiORM.Q
* @param string $field
* @param boolean $negate
* @return string
*/
function equals_condition(string $field, $value, bool $negate): array {
return [$field . ($negate ? ' != ?' : ' = ?'), [$value]];
}
/**
* Returns an 'in' or 'not in' condition for use in queries.
*
* @internal
* @subpackage WabiORM.Q
* @param string $field
* @param array $value
* @param integer $count
* @return string
*/
function in_condition(string $field, array $value, bool $negate): array {
$markers = \trim(str_repeat('?, ', count($value)), ', ');
$expr = $field . ($negate ? ' not in (' : ' in (') . $markers . ')';
return [$expr, $value];
}