Skip to content

Commit

Permalink
feat: query builder enhancements and documentation, rename get_type f…
Browse files Browse the repository at this point in the history
…unction, fix pipeline

Signed-off-by: tbreuss <[email protected]>
  • Loading branch information
tbreuss committed Dec 27, 2022
1 parent 18a64a6 commit 25e2008
Show file tree
Hide file tree
Showing 6 changed files with 285 additions and 26 deletions.
75 changes: 54 additions & 21 deletions system/QueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ final class QueryBuilder implements IteratorAggregate
"^=" => 'matchStarts',
"~=" => 'matchContainsWords',
"$=" => 'matchEnds',
"?=" => 'matchRegex',
"=" => 'matchEqual',
">" => 'matchGreaterThan',
"<" => 'matchLessThan',
Expand Down Expand Up @@ -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];
}
}
Expand All @@ -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();
Expand Down Expand Up @@ -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);

Expand All @@ -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]);
}
}

Expand Down Expand Up @@ -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;
}
}
6 changes: 3 additions & 3 deletions system/SystemInfoPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
];
}
Expand Down Expand Up @@ -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];
}
Expand Down
2 changes: 1 addition & 1 deletion system/Translator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion system/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions tests/acceptance/HerbieInfoCest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
225 changes: 225 additions & 0 deletions website/site/pages/2-doc/5-indepth/5-query-builder.md
Original file line number Diff line number Diff line change
@@ -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:

<table class="pure-table pure-table-horizontal">
<tr><td style='width:5%'>=</td><td>Equal to</td></tr>
<tr><td>!=</td><td>Not equal to</td></tr>
<tr><td>&lt;</td><td>Less than</td></tr>
<tr><td>&gt;</td><td>Greater than</td></tr>
<tr><td>&lt;=</td><td>Less than or equal to</td></tr>
<tr><td>&gt;=</td><td>Greater than or equal to</td></tr>
<tr><td>*=</td><td>Contains phrase/text</td></tr>
<tr><td>~=</td><td>Contains all words</td></tr>
<tr><td>^=</td><td>Starts with phrase/text</td></tr>
<tr><td>$=</td><td>Ends with phrase/text</td></tr>
<tr><td>?=</td><td>Match regular expression</td></tr>
<tr><td>&</td><td>Bitwise AND</td></tr>
</table>

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.

0 comments on commit 25e2008

Please sign in to comment.