Skip to content

Commit

Permalink
Merge 57eb101 into ae7f590
Browse files Browse the repository at this point in the history
  • Loading branch information
hrach committed Feb 23, 2020
2 parents ae7f590 + 57eb101 commit 0f4514a
Show file tree
Hide file tree
Showing 15 changed files with 109 additions and 63 deletions.
45 changes: 18 additions & 27 deletions doc/collection-functions.texy
@@ -1,23 +1,24 @@
Collection Functions
####################

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

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.
The custom filtering or ordering require own implementation for each storage 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 bring their own interface you have to directly implement.

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

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`.
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 us 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 with an `ArrayCollection`.
\--

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.
Collection functions can be used in `ICollection::findBy()` or `ICollection::orderBy()` methods. Collection function is used as an array argument, first array value is the function identifier (we recommend using function's class name) and then function's arguments as other array values. Collection functions may be used together, also nest together so you can reuse them.

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

// or compose & nest them together
// ICollection::OR is also a collection function
$collection->findBy(
[
ICollection::OR,
Expand Down Expand Up @@ -47,24 +48,19 @@ class UsersRepository extends Nextras\Orm\Repository\Repository
Dbal Functions
==============

To implement Dbal's collection function your class has to implement `Netras\Orm\Collection\Functions\IQueryBuilderFunction` interface.
Dbal collection functions have to implement `Nextras\Orm\Collection\Functions\IQueryBuilderFunction` interface. The only required method takes `DbalQueryBuilderHelper` for easier user input processing, `QueryBuilder` for creating table joins, and user input/function parameters.

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 collection functions together.

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.
Let's see an example: a "Like" collection function; We want to compare any (property) expression through SQL's LIKE operator with a prefix comparison.

/--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!
In the example we would like to use the `LikeFunction` to filter users by their phones that start with `+420` prefix. Our function will implement `IQueryBuilderFunction` interface and will receive `$args` with `phone` and `+420`. 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 passes a `DbalQueryBuilderHelper` that will handle all this for you. Use `processPropertyExpr` method to obtain a `DbalResultExpression` for the `phone` argument. Then just append needed SQL to the returned expression, e.g. LIKE operator with a Dbal's argument. That's all!

/--php
use Nextras\Dbal\QueryBuilder\QueryBuilder;
Expand All @@ -87,26 +83,16 @@ final class LikeFunction implements IQueryBuilderFunction
}
\--

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

Array Functions
===============

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.

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.
Array collection functions have to implement `Netras\Orm\Collection\Functions\IArrayFunction` interface. It is different to Dbal's interface, because the filtering happens directly in PHP now. The only required method takes `ArrayCollectionHelper` for easier entity property processing, `IEntity` entity to check if it should (not) be filtered out, and user input/function parameters.

.[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.
Array collection function returns a mixed value, the kind depends in which context it will be evaluated. The value will be interpreted as boolean in filtering context to indicate if the entity should be filtered out; the value will be used for comparison of two entities in ordering context (using the spaceship operator).

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

/--php
use Nette\Utils\Strings;
Expand All @@ -124,3 +110,8 @@ final class LikeFunction implements IArrayFunction
return Strings::startsWith($value, $args[1]);
}
\--

Our function implements the `IArrayFunction` interface 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 expression. Let's use helper to get the property expression value holder, then read the value from the holder. Finally, we simply compare the value with user-input argument by Nette's string helper.

.[note]
PostgreSQL is case-sensitive, so you should apply the lower function & a functional index; These modifications are a case-specific, therefore the LIKE functionality is not provided in Orm by default.
38 changes: 31 additions & 7 deletions doc/relationships.texy
Expand Up @@ -17,8 +17,9 @@ Use a relationship modifier to define relationship property. Modifiers require t
{m:m EntityName::$reversePropertyName, isMain=true, orderBy=[property=DESC, anotherProperty=ASC]}
\--

`1:m` and `m:m` relationships can define collection's default ordering by `orderBy` property. You may provide either a column name, or an assocciated array where the key is a column expression and the value is an ordering direction.
`1:m` and `m:m` relationships can define collection's default ordering by `orderBy` property. You may provide either a property name, or an associated array where the key is a property expression and the value is an ordering direction.

The property mapping into an actual column name may differ depending on the [conventions | conventions]. By default, Orm strips "id" suffixes when the column contains an foreign key reference.

Cascade
-------
Expand Down Expand Up @@ -223,25 +224,48 @@ foreach ($author->books as $book) {
}
\--

Also, you can use very clever interface to add, remove, and set entities in relationship. Sometimes, it is useful to work with the relationship collection as with the `ICollection`. Just use `get()` method to get it.
Also, you can use convenient methods to add, remove, and set entities in the relationship. The relationship automatically update the reverse side of the relationship (if loaded).

/--php
use Nextras\Orm\Collection\ICollection;

$author->books->add($book);
$author->books->remove($book);
$author->books->set([$book]);
$author->books->get() instanceof ICollection; // true

$book->tags->add($tag);
$book->tags->remove($tag);
$book->tags->set([$tag]);
$book->tags->get() instanceof ICollection; // true
\--

These methods accept both entity instances and an id (primary key's value). If you pass an id, Orm will load the proper entity automatically. This behavior is available only if the entity is "attached" to the repository (fetched from storage, directly attached or inderectly attached via other attached entity).
The relationship property wrapper accepts both entity instances and an id (primary key value). If you pass an id, Orm will load the proper entity automatically. This behavior is available only if the entity is "attached" to the repository (fetched from storage, directly attached or indirectly attached by another attached entity).

/--php
$book->author = 1;
$book->author->id === 1; // true

$book->tags->remove(1);
\--

Collection interface
--------------------

Sometimes, it is useful to work with the relationship as with collection to make further adjustments. Simply call `toCollection()` to receive collection over the relationship.

/--php
$collection = $author->books->toCollection();
$collection = $collection->limitBy(3);
\--

Working with such collection will preserve optimized loading for other entities in the original collection.

/--php
$authors = $orm->authors->findById([1, 2]); // fetches 2 authors

foreach ($authors as $author) {
// 1st call for author #1 fetches data for both authors by
// (SELECT ... WHERE author_id = 1 LIMIT 2) UNION ALL (SELECT ... WHERE author_id = 2 LIMIT 2)
// and returns data just for the author #1.
//
// 2nd call for author #2 uses already fetched data.
$sub = $author->books->toCollection()->limitBy(2);
}
\--
5 changes: 3 additions & 2 deletions src/Collection/Helpers/FetchPairsHelper.php
Expand Up @@ -9,6 +9,7 @@
namespace Nextras\Orm\Collection\Helpers;

use Nextras\Dbal\Utils\DateTimeImmutable;
use Nextras\Orm\Entity\Embeddable\IEmbeddable;
use Nextras\Orm\Entity\IEntity;
use Nextras\Orm\InvalidArgumentException;
use Nextras\Orm\InvalidStateException;
Expand Down Expand Up @@ -66,8 +67,8 @@ private static function getProperty(IEntity $row, array $chain)
$lastPropertyName = "";
do {
$propertyName = array_shift($chain);
if (!$result instanceof IEntity) {
throw new InvalidStateException("Part '$lastPropertyName' of the chain expression does not select IEntity value.");
if (!$result instanceof IEntity && !$result instanceof IEmbeddable) {
throw new InvalidStateException("Part '$lastPropertyName' of the chain expression does not select an IEntity nor an IEmbeddable.");
}
$lastPropertyName = $propertyName;
$result = $result->getValue($propertyName);
Expand Down
11 changes: 10 additions & 1 deletion src/Relationships/HasMany.php
Expand Up @@ -203,12 +203,21 @@ public function set(array $data): bool
}


public function get(): ICollection
public function toCollection(): ICollection
{
return clone $this->getCollection(true);
}


/**
* @deprecated Use toCollection() instead.
*/
public function get(): ICollection
{
return $this->toCollection();
}


public function count(): int
{
return iterator_count($this->getIterator());
Expand Down
2 changes: 1 addition & 1 deletion src/Relationships/IRelationshipCollection.php
Expand Up @@ -52,7 +52,7 @@ public function has($entity): bool;
/**
* Returns collection of all entity.
*/
public function get(): ICollection;
public function toCollection(): ICollection;


/**
Expand Down
Expand Up @@ -54,7 +54,7 @@ class EntityRelationshipsTest extends DataTestCase

Assert::same(1, $book->tags->count());
Assert::same(1, $book->tags->countStored());
Assert::same('Awesome', $book->tags->get()->fetch()->name);
Assert::same('Awesome', $book->tags->toCollection()->fetch()->name);
}


Expand Down
Expand Up @@ -290,7 +290,7 @@ class RelationshipsManyHasManyCollectionTest extends DataTestCase
$this->tags->add($this->createTag());
Assert::count(1, $this->tags->getEntitiesForPersistence());

$this->tags->get()->fetchAll(); // SELECT JOIN + SELECT TAG
$this->tags->toCollection()->fetchAll(); // SELECT JOIN + SELECT TAG
Assert::count(3, $this->tags->getEntitiesForPersistence());
});

Expand All @@ -305,7 +305,7 @@ class RelationshipsManyHasManyCollectionTest extends DataTestCase
$queries = $this->getQueries(function () {
Assert::count(0, $this->tags->getEntitiesForPersistence());

$this->tags->get()->orderBy('id')->limitBy(1)->fetchAll(); // SELECT JOIN + SELECT TAG
$this->tags->toCollection()->orderBy('id')->limitBy(1)->fetchAll(); // SELECT JOIN + SELECT TAG
// one book from releationship
Assert::count(1, $this->tags->getEntitiesForPersistence());
});
Expand Down
Expand Up @@ -26,12 +26,12 @@ class RelationshipManyHasManyTest extends DataTestCase
{
$book = $this->orm->books->getById(1);

$collection = $book->tags->get()->findBy(['name!=' => 'Tag 1'])->orderBy('id');
$collection = $book->tags->toCollection()->findBy(['name!=' => 'Tag 1'])->orderBy('id');
Assert::equal(1, $collection->count());
Assert::equal(1, $collection->countStored());
Assert::equal('Tag 2', $collection->fetch()->name);

$collection = $book->tags->get()->findBy(['name!=' => 'Tag 3'])->orderBy('id');
$collection = $book->tags->toCollection()->findBy(['name!=' => 'Tag 3'])->orderBy('id');
Assert::equal(2, $collection->count());
Assert::equal(2, $collection->countStored());
Assert::equal('Tag 1', $collection->fetch()->name);
Expand All @@ -52,7 +52,7 @@ class RelationshipManyHasManyTest extends DataTestCase
$counts = [];
$countsStored = [];
foreach ($books as $book) {
$limitedTags = $book->tags->get()->limitBy(2)->orderBy('name', ICollection::DESC);
$limitedTags = $book->tags->toCollection()->limitBy(2)->orderBy('name', ICollection::DESC);
foreach ($limitedTags as $tag) {
$tags[] = $tag->id;
}
Expand All @@ -74,7 +74,7 @@ class RelationshipManyHasManyTest extends DataTestCase

foreach ($books as $book) {
$book->setPreloadContainer(null);
foreach ($book->tags->get()->orderBy('name') as $tag) {
foreach ($book->tags->toCollection()->orderBy('name') as $tag) {
$tags[] = $tag->id;
}
}
Expand All @@ -98,7 +98,7 @@ class RelationshipManyHasManyTest extends DataTestCase
public function testCollectionCountWithLimit()
{
$book = $this->orm->books->getById(1);
$collection = $book->tags->get();
$collection = $book->tags->toCollection();
$collection = $collection->orderBy('id')->limitBy(1, 1);
Assert::same(1, $collection->count());
}
Expand Down Expand Up @@ -134,14 +134,14 @@ class RelationshipManyHasManyTest extends DataTestCase
public function testCaching()
{
$book = $this->orm->books->getById(1);
$tags = $book->tags->get()->findBy(['name' => 'Tag 1']);
$tags = $book->tags->toCollection()->findBy(['name' => 'Tag 1']);
Assert::same(1, $tags->count());

$tag = $tags->fetch();
$tag->name = 'XXX';
$this->orm->tags->persistAndFlush($tag);

$tags = $book->tags->get()->findBy(['name' => 'Tag 1']);
$tags = $book->tags->toCollection()->findBy(['name' => 'Tag 1']);
Assert::same(0, $tags->count());
}

Expand Down
Expand Up @@ -132,7 +132,7 @@ class RelationshipManyHasOneTest extends DataTestCase
{
$author = $this->orm->authors->getById(2);

$books = $author->books->get()->limitBy(1);
$books = $author->books->toCollection()->limitBy(1);
$publishers = [];
foreach ($books as $book) {
$publishers[] = $book->publisher->name;
Expand Down
Expand Up @@ -301,7 +301,7 @@ class RelationshipsOneHasManyCollectionTest extends DataTestCase
$this->books->add($this->createBook());
Assert::count(1, $this->books->getEntitiesForPersistence());

$this->books->get()->fetchAll(); // SELECT
$this->books->toCollection()->fetchAll(); // SELECT
Assert::count(3, $this->books->getEntitiesForPersistence());
});

Expand All @@ -316,7 +316,7 @@ class RelationshipsOneHasManyCollectionTest extends DataTestCase
$queries = $this->getQueries(function () {
Assert::count(0, $this->books->getEntitiesForPersistence());

$this->books->get()->limitBy(1)->fetchAll();
$this->books->toCollection()->limitBy(1)->fetchAll();
if ($this->section === Helper::SECTION_ARRAY) {
// array collection loads the book relationship during filtering the related books
Assert::count(2, $this->books->getEntitiesForPersistence());
Expand Down
Expand Up @@ -41,7 +41,7 @@ class RelationshipOneHasManyCompositePkTest extends DataTestCase
$authors = $this->orm->authors->findAll()->orderBy('id');

foreach ($authors as $author) {
foreach ($author->tagFollowers->get()->limitBy(2)->orderBy('tag', ICollection::DESC) as $tagFollower) {
foreach ($author->tagFollowers->toCollection()->limitBy(2)->orderBy('tag', ICollection::DESC) as $tagFollower) {
$tagFollowers[] = $tagFollower->getRawValue('tag');
}
}
Expand Down

0 comments on commit 0f4514a

Please sign in to comment.