Skip to content

Commit

Permalink
Relations via array (#375)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tigrov authored Aug 17, 2024
1 parent 6f3c8b0 commit 721cbf2
Show file tree
Hide file tree
Showing 12 changed files with 246 additions and 64 deletions.
6 changes: 6 additions & 0 deletions src/ActiveRecord.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Yiisoft\Db\Exception\Exception;
use Yiisoft\Db\Exception\InvalidArgumentException;
use Yiisoft\Db\Exception\InvalidConfigException;
use Yiisoft\Db\Schema\SchemaInterface;
use Yiisoft\Db\Schema\TableSchemaInterface;

use function array_diff;
Expand Down Expand Up @@ -84,6 +85,11 @@ public function attributes(): array
return $this->getTableSchema()->getColumnNames();
}

public function columnType(string $columnName): string
{
return $this->getTableSchema()->getColumn($columnName)?->getType() ?? SchemaInterface::TYPE_STRING;
}

public function filterCondition(array $condition, array $aliases = []): array
{
$result = [];
Expand Down
7 changes: 7 additions & 0 deletions src/ActiveRecordInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Yiisoft\Db\Exception\InvalidConfigException;
use Yiisoft\Db\Exception\NotSupportedException;
use Yiisoft\Db\Exception\StaleObjectException;
use Yiisoft\Db\Schema\SchemaInterface;

interface ActiveRecordInterface
{
Expand All @@ -25,6 +26,12 @@ interface ActiveRecordInterface
*/
public function attributes(): array;

/**
* Returns the abstract type of the column. See {@see SchemaInterface} constants started with prefix `TYPE_` for
* possible abstract types.
*/
public function columnType(string $columnName): string;

/**
* Returns the database connection used by the Active Record instance.
*/
Expand Down
71 changes: 46 additions & 25 deletions src/ActiveRelationTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
use Yiisoft\Db\Exception\InvalidArgumentException;
use Yiisoft\Db\Exception\InvalidConfigException;
use Yiisoft\Db\Exception\NotSupportedException;
use Yiisoft\Db\QueryBuilder\Condition\ArrayOverlapsCondition;
use Yiisoft\Db\QueryBuilder\Condition\InCondition;
use Yiisoft\Db\QueryBuilder\Condition\JsonOverlapsCondition;
use Yiisoft\Db\Schema\SchemaInterface;

use function array_column;
use function array_combine;
Expand All @@ -19,6 +23,7 @@
use function array_filter;
use function array_flip;
use function array_intersect_key;
use function array_key_first;
use function array_keys;
use function array_merge;
use function array_unique;
Expand Down Expand Up @@ -505,11 +510,11 @@ protected function filterByModels(array $models): void

if (count($attributes) === 1) {
/** single key */
$attribute = reset($this->link);
$linkedAttribute = reset($this->link);

if ($model instanceof ActiveRecordInterface) {
foreach ($models as $model) {
$value = $model->getAttribute($attribute);
$value = $model->getAttribute($linkedAttribute);

if ($value !== null) {
if (is_array($value)) {
Expand All @@ -521,8 +526,8 @@ protected function filterByModels(array $models): void
}
} else {
foreach ($models as $model) {
if (isset($model[$attribute])) {
$value = $model[$attribute];
if (isset($model[$linkedAttribute])) {
$value = $model[$linkedAttribute];

if (is_array($value)) {
$values = [...$values, ...$value];
Expand All @@ -533,31 +538,47 @@ protected function filterByModels(array $models): void
}
}

if (!empty($values)) {
$scalarValues = array_filter($values, 'is_scalar');
$nonScalarValues = array_diff_key($values, $scalarValues);

$scalarValues = array_unique($scalarValues);
$values = [...$scalarValues, ...$nonScalarValues];
if (empty($values)) {
$this->emulateExecution();
$this->andWhere('1=0');
return;
}
} else {
$nulls = array_fill_keys($this->link, null);

if ($model instanceof ActiveRecordInterface) {
foreach ($models as $model) {
$value = $model->getAttributes($this->link);
$scalarValues = array_filter($values, 'is_scalar');
$nonScalarValues = array_diff_key($values, $scalarValues);

if (!empty($value)) {
$values[] = array_combine($attributes, array_merge($nulls, $value));
}
$scalarValues = array_unique($scalarValues);
$values = [...$scalarValues, ...$nonScalarValues];

$attribute = reset($attributes);
/** @var string $columnName */
$columnName = array_key_first($this->link);

match ($this->getARInstance()->columnType($columnName)) {
'array' => $this->andWhere(new ArrayOverlapsCondition($attribute, $values)),
SchemaInterface::TYPE_JSON => $this->andWhere(new JsonOverlapsCondition($attribute, $values)),
default => $this->andWhere(new InCondition($attribute, 'IN', $values)),
};

return;
}

$nulls = array_fill_keys($this->link, null);

if ($model instanceof ActiveRecordInterface) {
foreach ($models as $model) {
$value = $model->getAttributes($this->link);

if (!empty($value)) {
$values[] = array_combine($attributes, array_merge($nulls, $value));
}
} else {
foreach ($models as $model) {
$value = array_intersect_key($model, $nulls);
}
} else {
foreach ($models as $model) {
$value = array_intersect_key($model, $nulls);

if (!empty($value)) {
$values[] = array_combine($attributes, array_merge($nulls, $value));
}
if (!empty($value)) {
$values[] = array_combine($attributes, array_merge($nulls, $value));
}
}
}
Expand All @@ -568,7 +589,7 @@ protected function filterByModels(array $models): void
return;
}

$this->andWhere(['in', $attributes, $values]);
$this->andWhere(new InCondition($attributes, 'IN', $values));
}

private function getModelKeys(ActiveRecordInterface|array $activeRecord, array $attributes): array
Expand Down
60 changes: 60 additions & 0 deletions tests/ActiveRecordTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use DivisionByZeroError;
use ReflectionException;
use Yiisoft\ActiveRecord\ActiveQuery;
use Yiisoft\ActiveRecord\ArArrayHelper;
use Yiisoft\ActiveRecord\ConnectionProvider;
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Animal;
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Cat;
Expand All @@ -25,6 +26,7 @@
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderItem;
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderItemWithNullFK;
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderWithFactory;
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Promotion;
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Profile;
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Type;
use Yiisoft\ActiveRecord\Tests\Support\Assert;
Expand Down Expand Up @@ -1110,4 +1112,62 @@ public function testSerialization(): void
serialize($profile)
);
}

public function testRelationViaJson()
{
if (in_array($this->db()->getDriverName(), ['oci', 'sqlsrv'], true)) {
$this->markTestSkipped('Oracle and MSSQL drivers do not support JSON columns.');
}

$this->checkFixture($this->db(), 'promotion');

$promotionQuery = new ActiveQuery(Promotion::class);
/** @var Promotion[] $promotions */
$promotions = $promotionQuery->with('itemsViaJson')->all();

$this->assertSame([1, 2], ArArrayHelper::getColumn($promotions[0]->getItemsViaJson(), 'id'));
$this->assertSame([3, 4, 5], ArArrayHelper::getColumn($promotions[1]->getItemsViaJson(), 'id'));
$this->assertSame([1, 3], ArArrayHelper::getColumn($promotions[2]->getItemsViaJson(), 'id'));
$this->assertCount(0, $promotions[3]->getItemsViaJson());

/** Test inverse relation */
foreach ($promotions as $promotion) {
foreach ($promotion->getItemsViaJson() as $item) {
$this->assertTrue($item->isRelationPopulated('promotionsViaJson'));
}
}

$this->assertSame([1, 3], ArArrayHelper::getColumn($promotions[0]->getItemsViaJson()[0]->getPromotionsViaJson(), 'id'));
$this->assertSame([1], ArArrayHelper::getColumn($promotions[0]->getItemsViaJson()[1]->getPromotionsViaJson(), 'id'));
$this->assertSame([2, 3], ArArrayHelper::getColumn($promotions[1]->getItemsViaJson()[0]->getPromotionsViaJson(), 'id'));
$this->assertSame([2], ArArrayHelper::getColumn($promotions[1]->getItemsViaJson()[1]->getPromotionsViaJson(), 'id'));
$this->assertSame([2], ArArrayHelper::getColumn($promotions[1]->getItemsViaJson()[2]->getPromotionsViaJson(), 'id'));
$this->assertSame([1, 3], ArArrayHelper::getColumn($promotions[2]->getItemsViaJson()[0]->getPromotionsViaJson(), 'id'));
$this->assertSame([2, 3], ArArrayHelper::getColumn($promotions[2]->getItemsViaJson()[1]->getPromotionsViaJson(), 'id'));
}

public function testLazzyRelationViaJson()
{
if (in_array($this->db()->getDriverName(), ['oci', 'sqlsrv'], true)) {
$this->markTestSkipped('Oracle and MSSQL drivers do not support JSON columns.');
}

$this->checkFixture($this->db(), 'item');

$itemQuery = new ActiveQuery(Item::class);
/** @var Item[] $items */
$items = $itemQuery->all();

$this->assertFalse($items[0]->isRelationPopulated('promotionsViaJson'));
$this->assertFalse($items[1]->isRelationPopulated('promotionsViaJson'));
$this->assertFalse($items[2]->isRelationPopulated('promotionsViaJson'));
$this->assertFalse($items[3]->isRelationPopulated('promotionsViaJson'));
$this->assertFalse($items[4]->isRelationPopulated('promotionsViaJson'));

$this->assertSame([1, 3], ArArrayHelper::getColumn($items[0]->getPromotionsViaJson(), 'id'));
$this->assertSame([1], ArArrayHelper::getColumn($items[1]->getPromotionsViaJson(), 'id'));
$this->assertSame([2, 3], ArArrayHelper::getColumn($items[2]->getPromotionsViaJson(), 'id'));
$this->assertSame([2], ArArrayHelper::getColumn($items[3]->getPromotionsViaJson(), 'id'));
$this->assertSame([2], ArArrayHelper::getColumn($items[4]->getPromotionsViaJson(), 'id'));
}
}
50 changes: 36 additions & 14 deletions tests/Driver/Pgsql/ActiveRecordTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Traversable;
use Yiisoft\ActiveRecord\ActiveQuery;
use Yiisoft\ActiveRecord\ArArrayHelper;
use Yiisoft\ActiveRecord\Tests\Driver\Pgsql\Stubs\Item;
use Yiisoft\ActiveRecord\Tests\Driver\Pgsql\Stubs\Promotion;
use Yiisoft\ActiveRecord\Tests\Driver\Pgsql\Stubs\Type;
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\ArrayAndJsonTypes;
Expand Down Expand Up @@ -445,26 +446,47 @@ public function testRelationViaArray()

$promotionQuery = new ActiveQuery(Promotion::class);
/** @var Promotion[] $promotions */
$promotions = $promotionQuery->with('items')->all();
$promotions = $promotionQuery->with('itemsViaArray')->all();

$this->assertSame([1, 2], ArArrayHelper::getColumn($promotions[0]->getItems(), 'id'));
$this->assertSame([3, 4, 5], ArArrayHelper::getColumn($promotions[1]->getItems(), 'id'));
$this->assertSame([1, 3], ArArrayHelper::getColumn($promotions[2]->getItems(), 'id'));
$this->assertCount(0, $promotions[3]->getItems());
$this->assertSame([1, 2], ArArrayHelper::getColumn($promotions[0]->getItemsViaArray(), 'id'));
$this->assertSame([3, 4, 5], ArArrayHelper::getColumn($promotions[1]->getItemsViaArray(), 'id'));
$this->assertSame([1, 3], ArArrayHelper::getColumn($promotions[2]->getItemsViaArray(), 'id'));
$this->assertCount(0, $promotions[3]->getItemsViaArray());

/** Test inverse relation */
foreach ($promotions as $promotion) {
foreach ($promotion->getItems() as $item) {
$this->assertTrue($item->isRelationPopulated('promotions'));
foreach ($promotion->getItemsViaArray() as $item) {
$this->assertTrue($item->isRelationPopulated('promotionsViaArray'));
}
}

$this->assertSame([1, 3], ArArrayHelper::getColumn($promotions[0]->getItems()[0]->getPromotions(), 'id'));
$this->assertSame([1], ArArrayHelper::getColumn($promotions[0]->getItems()[1]->getPromotions(), 'id'));
$this->assertSame([2, 3], ArArrayHelper::getColumn($promotions[1]->getItems()[0]->getPromotions(), 'id'));
$this->assertSame([2], ArArrayHelper::getColumn($promotions[1]->getItems()[1]->getPromotions(), 'id'));
$this->assertSame([2], ArArrayHelper::getColumn($promotions[1]->getItems()[2]->getPromotions(), 'id'));
$this->assertSame([1, 3], ArArrayHelper::getColumn($promotions[2]->getItems()[0]->getPromotions(), 'id'));
$this->assertSame([2, 3], ArArrayHelper::getColumn($promotions[2]->getItems()[1]->getPromotions(), 'id'));
$this->assertSame([1, 3], ArArrayHelper::getColumn($promotions[0]->getItemsViaArray()[0]->getPromotionsViaArray(), 'id'));
$this->assertSame([1], ArArrayHelper::getColumn($promotions[0]->getItemsViaArray()[1]->getPromotionsViaArray(), 'id'));
$this->assertSame([2, 3], ArArrayHelper::getColumn($promotions[1]->getItemsViaArray()[0]->getPromotionsViaArray(), 'id'));
$this->assertSame([2], ArArrayHelper::getColumn($promotions[1]->getItemsViaArray()[1]->getPromotionsViaArray(), 'id'));
$this->assertSame([2], ArArrayHelper::getColumn($promotions[1]->getItemsViaArray()[2]->getPromotionsViaArray(), 'id'));
$this->assertSame([1, 3], ArArrayHelper::getColumn($promotions[2]->getItemsViaArray()[0]->getPromotionsViaArray(), 'id'));
$this->assertSame([2, 3], ArArrayHelper::getColumn($promotions[2]->getItemsViaArray()[1]->getPromotionsViaArray(), 'id'));
}

public function testLazzyRelationViaArray()
{
$this->checkFixture($this->db(), 'item');

$itemQuery = new ActiveQuery(Item::class);
/** @var Item[] $items */
$items = $itemQuery->all();

$this->assertFalse($items[0]->isRelationPopulated('promotionsViaArray'));
$this->assertFalse($items[1]->isRelationPopulated('promotionsViaArray'));
$this->assertFalse($items[2]->isRelationPopulated('promotionsViaArray'));
$this->assertFalse($items[3]->isRelationPopulated('promotionsViaArray'));
$this->assertFalse($items[4]->isRelationPopulated('promotionsViaArray'));

$this->assertSame([1, 3], ArArrayHelper::getColumn($items[0]->getPromotionsViaArray(), 'id'));
$this->assertSame([1], ArArrayHelper::getColumn($items[1]->getPromotionsViaArray(), 'id'));
$this->assertSame([2, 3], ArArrayHelper::getColumn($items[2]->getPromotionsViaArray(), 'id'));
$this->assertSame([2], ArArrayHelper::getColumn($items[3]->getPromotionsViaArray(), 'id'));
$this->assertSame([2], ArArrayHelper::getColumn($items[4]->getPromotionsViaArray(), 'id'));
}
}
6 changes: 3 additions & 3 deletions tests/Driver/Pgsql/Stubs/Item.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ final class Item extends \Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Item
public function relationQuery(string $name): ActiveQueryInterface
{
return match ($name) {
'promotions' => $this->hasMany(Promotion::class, ['item_ids' => 'id']),
'promotionsViaArray' => $this->hasMany(Promotion::class, ['array_item_ids' => 'id']),
default => parent::relationQuery($name),
};
}

/** @return Promotion[] */
public function getPromotions(): array
public function getPromotionsViaArray(): array
{
return $this->relation('promotions');
return $this->relation('promotionsViaArray');
}
}
15 changes: 5 additions & 10 deletions tests/Driver/Pgsql/Stubs/Promotion.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,9 @@
namespace Yiisoft\ActiveRecord\Tests\Driver\Pgsql\Stubs;

use Yiisoft\ActiveRecord\ActiveQueryInterface;
use Yiisoft\ActiveRecord\ActiveRecord;

final class Promotion extends ActiveRecord
final class Promotion extends \Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Promotion
{
public int $id;
/** @var int[] $item_ids */
public array $item_ids;
public string $title;

public function getTableName(): string
{
return '{{%promotion}}';
Expand All @@ -22,14 +16,15 @@ public function getTableName(): string
public function relationQuery(string $name): ActiveQueryInterface
{
return match ($name) {
'items' => $this->hasMany(Item::class, ['id' => 'item_ids'])->inverseOf('promotions'),
'itemsViaArray' => $this->hasMany(Item::class, ['id' => 'array_item_ids'])
->inverseOf('promotionsViaArray'),
default => parent::relationQuery($name),
};
}

/** @return Item[] */
public function getItems(): array
public function getItemsViaArray(): array
{
return $this->relation('items');
return $this->relation('itemsViaArray');
}
}
7 changes: 7 additions & 0 deletions tests/Stubs/ActiveRecord/Item.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public function relationQuery(string $name): ActiveQueryInterface
{
return match ($name) {
'category' => $this->getCategoryQuery(),
'promotionsViaJson' => $this->hasMany(Promotion::class, ['json_item_ids' => 'id']),
default => parent::relationQuery($name),
};
}
Expand Down Expand Up @@ -54,4 +55,10 @@ public function getCategoryQuery(): ActiveQuery
{
return $this->hasOne(Category::class, ['id' => 'category_id']);
}

/** @return Promotion[] */
public function getPromotionsViaJson(): array
{
return $this->relation('promotionsViaJson');
}
}
Loading

0 comments on commit 721cbf2

Please sign in to comment.