Skip to content

Commit

Permalink
Merge 69bc4f6 into e89325b
Browse files Browse the repository at this point in the history
  • Loading branch information
hrach committed Feb 9, 2020
2 parents e89325b + 69bc4f6 commit 4f34969
Show file tree
Hide file tree
Showing 39 changed files with 708 additions and 759 deletions.
147 changes: 81 additions & 66 deletions doc/collection-functions.texy
@@ -1,111 +1,126 @@
Collection Functions
####################

Collection custom functions are a powerful extension point that will allow you to write a custom collection filtering or sorting behavior.
Collection functions are a powerful extension point that will allow you to write a custom filtering or ordering behavior.

First, you have to define if you want to write "filter function" or just a plain "function".

- **filter functions**
- easy to write
- can be nested
- limited functionality
- `Nextras\Orm\Mapper\Dbal\CustomFunctions\IQueryBuilderFilterFunction`
- `Nextras\Orm\Mapper\Memory\CustomFunctions\IArrayFilterFunction`
- **functions**
- difficult to write
- cannot be nested
- unlimited functionality
- `Nextras\Orm\Mapper\Dbal\CustomFunctions\IQueryBuilderFunction`
- `Nextras\Orm\Mapper\Memory\CustomFunctions\IArrayFunction`

Custom functions are defined as classes implementing specific interfaces. Orm comes with two sets of interfaces: Dbal interfaces and Array interfaces.
To implement custom filtering or ordering, you have do write your implementation. Ideally, you write it for both - Dbal & Array, because you may need it in both cases (e.g. on persisted / unpersisted relationship collection). Both of the Dbal & Array implementation brings their own interface you have to implement in your collection functions.

/--div .[note]
**Why we have ArrayCollection and DbalCollection?**

Collection itself is independent from storage implementation, it's your choice if your custom function will work in both cases - for `ArrayCollection` and `DbalCollection`. Let's remind you, ArrayCollections are commonly used in relationships, when you set new entities into the relationship, until the relationship is persisted, you will use an ArrayCollection.
\--

Filtering functions can be used in `ICollection::findBy()` method. The expression is similar to using `OR` function (which is internally implemented as a custom function). Pass the function identifier (we recommend function's class name) and then function's arguments to apply the function.

/--php
$collection->findBy([FilterFunction::class, 'arg1', 'arg2']);

// or nested
$collection->findBy([
ICollection::OR,
[FilterFunction::class, 'arg1', 'arg2'],
[AnotherFunction::class, 'arg3'],
]);
Collection itself is independent from storage implementation. It is your choice if your collection function will work in both cases - for `ArrayCollection` and `DbalCollection`. Let's remind you, `ArrayCollection`s are commonly used in relationships when you set new entities into the relationship but until the relationship is persisted, you will work on an `ArrayCollection`.
\--

The plain functions must be applied by `ICollection::applyFunction()` method.
Collection functions can be used in `ICollection::findBy()` or `ICollection::orderBy()` method. The basic filtering is also done through collection functions so you can reuse it in your collection function's composition. First, pass the function identifier (we recommend function's class name) and then function's arguments as an array argument.

/--php
$collection->applyFunction(Function::class, 'arg1', 'arg2');
// use directly a function call definition
$collection->findBy([MyFunction::class, 'arg1', 'arg2']);

// or compose & nest them together
$collection->findBy(
[
ICollection::OR,
[MyFunction::class, 'arg1', 'arg2'],
[AnotherFunction::class, 'arg3'],
]
);
\--

Functions are registered per Repository. To do so override `Repository::createCollectionFunction($name)` method to return your custom function instances.
Functions are registered per repository. To do so, override `Repository::createCollectionFunction($name)` method to return your collection functions' instances.

/--php
class UsersRepository extends Nextras\Orm\Repository\Repository
{
// ...
public function createCollectionFunction(string $name)
{
if ($name === MyCustomFunction::class) {
return new MyCustomFUnction();
if ($name === MyFunction::class) {
return new MyFunction();
} else {
return parent::createCollectionFunction($name);
}
}
}
\--

Like Filtering Function
=======================
Dbal Functions
==============

Let's create "LIKE" filtering function. Filtering by like expression is a little bit different in each database engine.
To implement Dbal's collection function your class has to implement `Netras\Orm\Collection\Functions\IQueryBuilderFunction` interface.

.[note]
PostgreSQL is case-sensitive, so you should apply lower function & functional index; These modifications are case-specific, therefore the LIKE functionality is not provided in Orm by default.
The only required method takes:
- DbalQueryBuilderHelper for easier user input processing,
- QueryBuilder for creating table joins,
- user input/function parameters.

Collection function has to return `DbalExpressionResult` object. This objects holds bits of SQL clauses which may be processed by Netras Dbal's SqlProcessor. Because you are not adding filters directly to QueryBuilder but rather return them, you may compose multiple regular and custom collection functions together.

Let's see an example: a "Like" collection function; We want to compare any (property) expression through SQL's LIKE operator to a user-input value.

/--php
$users->findBy(
[LikeFunction::class, 'phone', '+420']
);
\--

In the example we would like to use LikeFunction to filter users by their phones, which starts with `+420` prefix. Our function will implement IQueryBuilderFunction interface and will receive `$args` witch `phone` and `+420` user inputs. But, the first argument may be quite dynamic. What if user pass `address->zipcode` (e.g. a relationship expression) instead of simple `phone`, such expression would require table joins, and doing it all by hand would be difficult. Therefore Orm always pass a DbalQueryBuilderHelper which will handle this for you, even in the simple case. Use `processPropertyExpr` method to obtain a DbalResultExpression for `phone` argument. Then just append needed SQL to the expression, e.g. LIKE operator with an Dbal's argument. That's all!

/--php
use Nette\Utils\Strings;
use Nextras\Dbal\QueryBuilder\QueryBuilder;
use Nextras\Orm\Collection\Helpers\ArrayCollectionHelper;
use Nextras\Orm\Entity\IEntity;
use Nextras\Orm\Mapper\Dbal\CustomFunctions\IQueryBuilderFilterFunction;
use Nextras\Orm\Mapper\Dbal\QueryBuilderHelper;
use Nextras\Orm\Mapper\Memory\CustomFunctions\IArrayFilterFunction;
use Nextras\Orm\Collection\Helpers\DbalExpressionResult;
use Nextras\Orm\Collection\Helpers\DbalQueryBuilderHelper;

final class LikeFilterFunction implements IArrayFilterFunction, IQueryBuilderFilterFunction
final class LikeFunction implements IQueryBuilderFunction
{
public function processArrayFilter(ArrayCollectionHelper $helper, IEntity $entity, array $args): bool
public function processQueryBuilderExpression(
DbalQueryBuilderHelper $helper,
QueryBuilder $builder,
array $args
): DbalExpressionResult
{
// check if we received enough arguments
assert(count($args) === 2 && is_string($args[0]) && is_string($args[1]));
\assert(\count($args) === 2 && \is_string($args[0]) && \is_string($args[1]));

// get the value and check if it starts with the requested string
$value = $helper->getValue($entity, $args[0])->value;
return Strings::startsWith($value, $args[1]);
$expression = $helper->processPropertyExpr($builder, $args[0]);
return $expression->append('LIKE %like_', $args[1]);
}
}
\--

The value that is processed by helper may not be just a column, but another expression returned from other collection function.

public function processQueryBuilderFilter(QueryBuilderHelper $helper, QueryBuilder $builder, array $args): array
{
// check if we received enough arguments
assert(count($args) === 2 && is_string($args[0]) && is_string($args[1]));
Array Functions
===============

// convert expression to column name (also this autojoins needed tables)
$column = $helper->processPropertyExpr($builder, $args[0])->column;
return ['%column LIKE %like_', $column, $args[1]];
}
}
\--
Array collection functions implements `Netras\Orm\Collection\Functions\IArrayFunction` interface. It is different to Dbal's interface, because the filtering now happens directly in PHP.

The only required method takes:
- ArrayCollectionHelper for easier entity property processing,
- IEntity entity to check if should (not) be filtered out,
- user input/function parameters.

In the example we implement LIKE (with placeholder at the end) both for Array & Dbal collection. As you can see, you can write custom SQL as well as you can filter the entity by any PHP code. The final usage is quite simple.
Array collection functions returns mixed value, it depends in which context they will be evaluated. In filtering context the value will be interpreted as "true-thy" to indicate if the entity should be filtered out; in ordering context the value will be used for comparison of two entities.

Let's see an example: a "Like" collection function; We want to compare any (property) expression to passed user-input value with prefix comparison.

.[note]
PostgreSQL is case-sensitive, so you should apply the lower function & a functional index; These modifications are case-specific, therefore the LIKE functionality is not provided in Orm by default.

Our function will implement `IArrayFunction` and will receive helper objects & user-input. The same as in Dbal's example, the user property argument may vary from simple property access to traversing through relationship. Let's use helper to get the property expression value holder, from which we will obtain the specific value. Then we simply compare it with user-input argument by Nette's string helper.

/--php
$users->findBy([LikeFilterFunction::class, 'name', 'Jon']);
use Nette\Utils\Strings;
use Nextras\Orm\Collection\Functions\IArrayFunction;
use Nextras\Orm\Collection\Helpers\ArrayCollectionHelper;
use Nextras\Orm\Entity\IEntity;

final class LikeFunction implements IArrayFunction
{
public function processArrayExpression(ArrayCollectionHelper $helper, IEntity $entity, array $args)
{
\assert(\count($args) === 2 && \is_string($args[0]) && \is_string($args[1]));

$value = $helper->getValue($entity, $args[0])->value;
return Strings::startsWith($value, $args[1]);
}
\--
27 changes: 7 additions & 20 deletions src/Collection/ArrayCollection.php
Expand Up @@ -107,19 +107,15 @@ public function findBy(array $where): ICollection
}


public function orderBy(string $propertyPath, string $direction = self::ASC): ICollection
public function orderBy($expression, string $direction = self::ASC): ICollection
{
$collection = clone $this;
$collection->collectionSorter[] = [$propertyPath, $direction];
return $collection;
}


public function orderByMultiple(array $properties): ICollection
{
$collection = clone $this;
foreach ($properties as $property => $direction) {
$collection->collectionSorter[] = [$property, $direction];
if (is_array($expression) && !isset($expression[0])) {
foreach ($expression as $subExpression => $subDirection) {
$collection->collectionSorter[] = [$subExpression, $subDirection];
}
} else {
$collection->collectionSorter[] = [$expression, $direction];
}
return $collection;
}
Expand All @@ -141,14 +137,6 @@ public function limitBy(int $limit, int $offset = null): ICollection
}


public function applyFunction(string $functionName, ...$args): ICollection
{
$collection = clone $this;
$collection->collectionFunctions[] = $this->getHelper()->createFunction($functionName, $args);
return $collection;
}


public function fetch(): ?IEntity
{
if (!$this->fetchIterator) {
Expand Down Expand Up @@ -190,7 +178,6 @@ public function getIterator(): Iterator
$collection->relationshipMapper = null;
$collection->relationshipParent = null;
$entityIterator = $this->relationshipMapper->getIterator($this->relationshipParent, $collection);

} else {
$this->processData();
$entityIterator = new EntityIterator(array_values($this->data));
Expand Down
16 changes: 0 additions & 16 deletions src/Collection/Collection.php

This file was deleted.

Expand Up @@ -6,16 +6,16 @@
* @link https://github.com/nextras/orm
*/

namespace Nextras\Orm\Mapper\Dbal;
namespace Nextras\Orm\Collection;

use Iterator;
use Nextras\Dbal\IConnection;
use Nextras\Dbal\QueryBuilder\QueryBuilder;
use Nextras\Orm\Collection\EntityIterator;
use Nextras\Orm\Collection\Helpers\DbalQueryBuilderHelper;
use Nextras\Orm\Collection\Helpers\FetchPairsHelper;
use Nextras\Orm\Collection\ICollection;
use Nextras\Orm\Entity\IEntity;
use Nextras\Orm\Mapper\Dbal\Conventions\Conventions;
use Nextras\Orm\Mapper\Dbal\DbalMapper;
use Nextras\Orm\Mapper\IRelationshipMapper;
use Nextras\Orm\MemberAccessException;
use Nextras\Orm\NoResultException;
Expand Down Expand Up @@ -44,7 +44,7 @@ class DbalCollection implements ICollection
/** @var QueryBuilder */
protected $queryBuilder;

/** @var QueryBuilderHelper */
/** @var DbalQueryBuilderHelper */
protected $helper;

/** @var array|null */
Expand Down Expand Up @@ -102,25 +102,32 @@ public function getByIdChecked($primaryValue): IEntity
public function findBy(array $where): ICollection
{
$collection = clone $this;
$filterArgs = $collection->getHelper()->processFilterFunction($collection->queryBuilder, $where);
$collection->queryBuilder->andWhere(...$filterArgs);
$expression = $collection->getHelper()->processFilterFunction($collection->queryBuilder, $where);
if ($expression->isHavingClause) {
$collection->queryBuilder->andHaving(...$expression->args);
} else {
$collection->queryBuilder->andWhere(...$expression->args);
}
return $collection;
}


public function orderBy(string $propertyPath, string $direction = ICollection::ASC): ICollection
{
return $this->orderByMultiple([$propertyPath => $direction]);
}


public function orderByMultiple(array $properties): ICollection
public function orderBy($expression, string $direction = ICollection::ASC): ICollection
{
$collection = clone $this;
$helper = $collection->getHelper();
$builder = $collection->queryBuilder;
foreach ($properties as $propertyPath => $direction) {
$helper->processOrder($builder, $propertyPath, $direction);
if (is_array($expression)) {
if (!isset($expression[0])) {
foreach ($expression as $subExpression => $subDirection) {
$orderArgs = $collection->getHelper()->processOrder($collection->queryBuilder, $subExpression, $subDirection);
$collection->queryBuilder->addOrderBy('%ex', $orderArgs);
}
} else {
$orderArgs = $collection->getHelper()->processOrder($collection->queryBuilder, $expression, $direction);
$collection->queryBuilder->addOrderBy('%ex', $orderArgs);
}
} else {
$orderArgs = $collection->getHelper()->processOrder($collection->queryBuilder, $expression, $direction);
$collection->queryBuilder->addOrderBy('%ex', $orderArgs);
}
return $collection;
}
Expand All @@ -142,18 +149,6 @@ public function limitBy(int $limit, int $offset = null): ICollection
}


public function applyFunction(string $functionName, ...$args): ICollection
{
$collection = clone $this;
$collection->queryBuilder = $collection->getHelper()->processApplyFunction(
$collection->queryBuilder,
$functionName,
$args
);
return $collection;
}


public function fetch(): ?IEntity
{
if (!$this->fetchIterator) {
Expand Down Expand Up @@ -322,7 +317,7 @@ protected function getHelper()
{
if ($this->helper === null) {
$repository = $this->mapper->getRepository();
$this->helper = new QueryBuilderHelper($repository->getModel(), $repository, $this->mapper);
$this->helper = new DbalQueryBuilderHelper($repository->getModel(), $repository, $this->mapper);
}

return $this->helper;
Expand Down
16 changes: 2 additions & 14 deletions src/Collection/EmptyCollection.php
Expand Up @@ -15,7 +15,7 @@
use Nextras\Orm\NoResultException;


final class EmptyCollection implements ICollection
class EmptyCollection implements ICollection
{
/** @var IRelationshipMapper|null */
private $relationshipMapper;
Expand Down Expand Up @@ -51,13 +51,7 @@ public function findBy(array $where): ICollection
}


public function orderBy(string $propertyPath, string $direction = self::ASC): ICollection
{
return clone $this;
}


public function orderByMultiple(array $properties): ICollection
public function orderBy($propertyPath, string $direction = self::ASC): ICollection
{
return clone $this;
}
Expand All @@ -75,12 +69,6 @@ public function limitBy(int $limit, int $offset = null): ICollection
}


public function applyFunction(string $functionName, ...$args): ICollection
{
return clone $this;
}


public function fetch(): ?IEntity
{
return null;
Expand Down

0 comments on commit 4f34969

Please sign in to comment.