From 25e20081e173d40e91276dfed813edd3f4efcaaf Mon Sep 17 00:00:00 2001 From: tbreuss Date: Tue, 27 Dec 2022 13:07:26 +0100 Subject: [PATCH] feat: query builder enhancements and documentation, rename get_type function, fix pipeline Signed-off-by: tbreuss --- system/QueryBuilder.php | 75 ++++-- system/SystemInfoPlugin.php | 6 +- system/Translator.php | 2 +- system/functions.php | 2 +- tests/acceptance/HerbieInfoCest.php | 1 + .../pages/2-doc/5-indepth/5-query-builder.md | 225 ++++++++++++++++++ 6 files changed, 285 insertions(+), 26 deletions(-) create mode 100644 website/site/pages/2-doc/5-indepth/5-query-builder.md diff --git a/system/QueryBuilder.php b/system/QueryBuilder.php index 736aafd6..0e284d13 100644 --- a/system/QueryBuilder.php +++ b/system/QueryBuilder.php @@ -25,6 +25,7 @@ final class QueryBuilder implements IteratorAggregate "^=" => 'matchStarts', "~=" => 'matchContainsWords', "$=" => 'matchEnds', + "?=" => 'matchRegex', "=" => 'matchEqual', ">" => 'matchGreaterThan', "<" => 'matchLessThan', @@ -153,7 +154,7 @@ private function parseConditionsInHashFormat(array $conditions): array $items = []; foreach ($conditions as $key => $value) { if (is_scalar($value)) { - $type = \herbie\gettype($value); + $type = \herbie\get_type($value); $items[] = ['match' . ucfirst($type), $key, $value]; } } @@ -178,6 +179,21 @@ public function order(callable|string $order): self return $this; } + public function count(): int + { + return count($this->processed); + } + + /** + * @throws \Exception + */ + public function paginate(int $size): Pagination + { + $this->limit = $size; + $this->processData(); + return new Pagination($this->processed, $this->limit); + } + public function all(): iterable { $this->processData(); @@ -222,7 +238,7 @@ private function processData(): void } } - private function processItem(ArrayAccess|array $item, array $conditions): bool + private function processItem(ArrayAccess|array|int|float|string|bool $item, array $conditions): bool { $whereClauseOperator = array_shift($conditions); @@ -234,26 +250,35 @@ private function processItem(ArrayAccess|array $item, array $conditions): bool foreach ($conditions as $condition) { if (isset($condition[0]) && in_array(strtoupper($condition[0]), self::WHERE_CLAUSE_OPERATORS)) { $status[] = $this->processItem($item, $condition); - } else { - [$operator, $field, $value2] = $condition; - if (!isset($item[$field])) { - $status[] = false; - } else { - /** @var callable $callable */ - $callable = [$this, $operator]; - $value1 = $item[$field]; - if (is_array($value1)) { - $arrStatus = []; - foreach ($value1 as $arrValue1) { - $value2 = $this->convertType($arrValue1, $value2); - $arrStatus[] = call_user_func_array($callable, [$arrValue1, $value2]); - } - $status[] = in_array(true, $arrStatus); - } else { - $value2 = $this->convertType($value1, $value2); - $status[] = call_user_func_array($callable, [$value1, $value2]); - } + continue; + } + + $itemIsScalar = is_scalar($item); + $itemIsArrayable = ($item instanceof ArrayAccess) || is_array($item); + + [$operator, $field, $value2] = $condition; + + if (!$itemIsScalar && !isset($item[$field])) { + $status[] = false; + continue; + } + + /** @var callable $callable */ + $callable = [$this, $operator]; + if ($itemIsScalar && ($field === 'value')) { + $value2 = $this->convertType($item, $value2); + $status[] = call_user_func_array($callable, [$item, $value2]); + } elseif ($itemIsArrayable && isset($item[$field]) && is_array($item[$field])) { + $arrStatus = []; + foreach ($item[$field] as $value1) { + $value2 = $this->convertType($value1, $value2); + $arrStatus[] = call_user_func_array($callable, [$value1, $value2]); } + $status[] = in_array(true, $arrStatus, true); + } elseif ($itemIsArrayable && isset($item[$field])) { + $value1 = $item[$field]; + $value2 = $this->convertType($value1, $value2); + $status[] = call_user_func_array($callable, [$value1, $value2]); } } @@ -387,4 +412,12 @@ protected function matchEnds(string $value1, string $value2): bool $value1 = substr($value1, -1 * strlen($value2)); return strcasecmp($value1, $value2) === 0; } + + protected function matchRegex(string $value1, string $value2): bool + { + if (preg_match($value2, $value1, $matches)) { + return count($matches) > 0; + } + return false; + } } diff --git a/system/SystemInfoPlugin.php b/system/SystemInfoPlugin.php index 0ec62f03..dfb69b72 100644 --- a/system/SystemInfoPlugin.php +++ b/system/SystemInfoPlugin.php @@ -97,7 +97,7 @@ private function getConfig(): array foreach ($this->config->flatten() as $key => $value) { $configs[] = [ $key, - \herbie\gettype($value), + \herbie\get_type($value), $this->filterValue($value) ]; } @@ -187,13 +187,13 @@ private function getTwigGlobalsFromContext(array $context): array foreach ($context as $string => $mixed) { if (is_scalar($mixed)) { $value = $mixed; - $type = \herbie\gettype($mixed); + $type = \herbie\get_type($mixed); } elseif (is_object($mixed)) { $value = get_class($mixed); $type = 'class'; } else { $value = json_encode($mixed); - $type = \herbie\gettype($mixed); + $type = \herbie\get_type($mixed); } $globals[] = [$string, $value, $type]; } diff --git a/system/Translator.php b/system/Translator.php index 17dd8460..fd2959cb 100644 --- a/system/Translator.php +++ b/system/Translator.php @@ -96,7 +96,7 @@ public function addPath(string $category, $path): void if (is_string($path)) { $path = [$path]; } elseif (!is_array($path)) { - $message = sprintf('Argument $path has to be an array or a string, %s given.', \herbie\gettype($path)); + $message = sprintf('Argument $path has to be an array or a string, %s given.', \herbie\get_type($path)); throw new \InvalidArgumentException($message); } $this->paths[$category] = array_merge($this->paths[$category], $path); diff --git a/system/functions.php b/system/functions.php index c118a559..af41e57e 100644 --- a/system/functions.php +++ b/system/functions.php @@ -476,7 +476,7 @@ function array_is_assoc(array $array): bool return array_keys($keys) !== $keys; } -function gettype(mixed $value): string +function get_type(mixed $value): string { $type = \gettype($value); // for historical reasons "double" is returned in case of a float, and not simply "float" diff --git a/tests/acceptance/HerbieInfoCest.php b/tests/acceptance/HerbieInfoCest.php index 510b9876..724a5c7e 100644 --- a/tests/acceptance/HerbieInfoCest.php +++ b/tests/acceptance/HerbieInfoCest.php @@ -29,6 +29,7 @@ public function testNumberAndSortingOfPhpFunctions(AcceptanceTester $I) 'herbie\file_size', 'herbie\get_callable_name', 'herbie\get_constructor_params_to_inject', + 'herbie\get_type', 'herbie\handle_internal_webserver_assets', 'herbie\is_digit', 'herbie\is_natural', diff --git a/website/site/pages/2-doc/5-indepth/5-query-builder.md b/website/site/pages/2-doc/5-indepth/5-query-builder.md new file mode 100644 index 00000000..83cb28b8 --- /dev/null +++ b/website/site/pages/2-doc/5-indepth/5-query-builder.md @@ -0,0 +1,225 @@ +--- +title: Query Builder +layout: doc +--- + +# Query Builder + +Herbie provides a fluent query builder interacting with your content and data in PHP-land. + +~~~php +$data = [ + ['title' => 'Foo', 'layout' => 'blog'], + ['title' => 'Bar', 'layout' => 'news'], + ['title' => 'Baz', 'layout' => 'blog'], +]; +$entries = (new \herbie\QueryBuilder) + ->from($data) + ->where('layout=blog') + ->offset(10) + ->limit(10) + ->order('title') + ->all() +~~~ + +In Twig the same query builder can be instantiated using the `query` function. + +~~~twig +{{ '{%' }} set entries = query(data).where("layout=blog").offset(10).limit(10).order('title').all() {{ '%}' }} +~~~ + +Some list entites also have a built-in query method, so the query builder can be used as follows. + +~~~twig +{{ '{%' }} set entries = site.page_list.where("layout=blog").offset(10).limit(10).order('title').all() {{ '%}' }} +~~~ + +## Retrieving Data + +A query builder allows you to query, filter, and narrow down the results you desire. +The page list query builder allows you to find all the entries in a collection, by a specific author, and so on. + +### Getting multiple records + +The query builder allows you to assemble a query, chain additional constraints onto it, and then invoke the `all` method to get the results: + +~~~twig +{{ '{{' }} query(site.page_list).where("layout=blog").limit(5).all() {{ '}}' }} +~~~ + +This would return a list of the queried items. +In this particular example, you would have a list of five page objects. + +### Getting a single record + +If you only want to get a single record, you may use the `one` method. +This method will return a single data object: + +~~~twig +{{ '{{' }} query(site.page_list).where("layout=blog").one() {{ '}}' }} +~~~ + +## Where + +The heart of the query builder are selectors, which are loosely based on CSS attribute selectors. + +### Selectors + +A selector is a simple text string that specifies fields and values, and that can be applied to a where condition. +It can be one of the following: + + + + + + + + + + + + + + +
=Equal to
!=Not equal to
<Less than
>Greater than
<=Less than or equal to
>=Greater than or equal to
*=Contains phrase/text
~=Contains all words
^=Starts with phrase/text
$=Ends with phrase/text
?=Match regular expression
&Bitwise AND
+ +With `where` clauses the result can be narrowed down as desired. +There are three different formats that can be used for this. + +### String Format + +The String format is best used to specify simple conditions. +For example: + +~~~twig +{{ '{{' }} query(data).where("layout=blog", "title*=news") {{ '}}' }} +~~~ + +You can chain where clauses, filtering records based on more than one condition with AND: + +~~~twig +{{ '{{' }} query(data).where("layout=blog").where("title*=news").where("hidden=false") {{ '}}' }} +~~~ + +Values are type hinted according to the type of the field and one of the scalar types bool, float, int, or string. + +~~~twig +{{ '{{' }} query(data).where("hidden=false", "size=14.25", "age=24", "layout=blog") {{ '}}' }} +~~~ + +#### Multiple Fields + +If you want to match a value in one field or another, you may specify multiple fields separated by a pipe "|" symbol, i.e. + +~~~twig +{{ '{{' }} query(data).where("name|title|menu_title=product") {{ '}}' }} +~~~ + +Using the above syntax, the condition will match any data that have a name, title, or menu_title field of "product" or "Product". + +#### Multiple Values + +You may also specify an either/or value, by separating each of the values that may match with a pipe character "|". + +~~~twig +{{ '{{' }} query(data).where("layout=blog|news") {{ '}}' }} +~~~ + +#### Array Fields + +If the queried field is an array, the operator is applied for each array items as OR conjunction. + +~~~twig +{{ '{{' }} query(data).where("tags=blog") {{ '}}' }} +~~~ + +Using the above syntax, the query will match if one of the tags equals to "blog". + +### Hash Format + +The hash format is best used to specify multiple AND-concatenated sub-conditions each being a simple equality assertion. +It is written as an array whose keys are column names and values the corresponding values that the columns should be. +For example: + +~~~twig +{{ '{{' }} query(data).where({layout: "blog", age: 24, size: 178.5, hidden: false}) {{ '}}' }} +~~~ + +### Operator Format + +The operator format is best used when you have more complex sub queries that are combined with an AND or OR where clause operator. +For example: + +~~~twig +{{ '{{' }} query(data).where(["OR", "layout=blog", "title*=blog|news", "date>=2022-12-12") {{ '}}' }} +~~~ + +With the syntax above, the individual conditions are OR conjuncted. + +The AND/OR where clause operators can be nested: + +~~~twig +{{ '{{' }} query(data).where(["OR", ["AND", "layout=blog", "title*=blog"], ["AND", "date>=2022-12-12", "cached=true"]]) {{ '}}' }} +~~~ + +## Order + +The results can be ordered using the `order` method. +The argument can be a callback or a field name preceded by a plus or minus sign, where plus means ascending and minus means descending. +If the plus or minus sign is omitted, the order is ascending by default. +Here are examples of it's usage. + +Ordered by title descending: + +~~~twig +{{ '{{' }} query(data).order("-title") {{ '}}' }} +~~~ + +Ordered by title ascending: + +~~~twig +{{ '{{' }} query(data).order("title") {{ '}}' }} +{{ '{{' }} query(data).order("+title") {{ '}}' }} +~~~ + +Ordered by title using a callback: + +~~~twig +{{ '{{' }} query(data).order((a, b) => a.title <=> b.title) {{ '}}' }} +~~~ + +## Limit + +You may limit the results by using the `limit` method: + +~~~twig +{{ '{{' }} query(data).limit(10) {{ '}}' }} +~~~ + +## Offset + +You may skip results by using the `offset` method: + +~~~twig +{{ '{{' }} query(data).offset(10).limit(10) {{ '}}' }} +~~~ + +## Count + +The query builder also provides a count method for retrieving the number of records returned. + +~~~twig +{{ '{{' }} query(data).where("layout=blog").count() {{ '}}' }} +~~~ + +## Paginate + +(To be done) + +If you want to get paginated results on a query, you may use the `paginate` method and specify the desired number of results per page. + +~~~twig +{{ '{{' }} query(data).where("layout=blog").paginate(10); {{ '}}' }} +~~~ + +This will return an instance of `herbie\Pagination` that you can use to assemble the pagination style of your choice.