Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix for AR case insensitive relation #17595

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion framework/db/ActiveRelationTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@
*/
trait ActiveRelationTrait
{
/**
* @var bool whether use strtolower() for values while populating relation.
* That is helpful for MySQL and other databases that can join records
* by strings with different case such as Key1 = key1. By default Active Record
* relation population checks are case sensitive so while records are selected
* they are not populated properly.
*/
public $caseInsensitiveKeys = false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think "caseSensitiveKeys" is better than "caseInsensitiveKeys" to avoid double negative

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After thinking about it, it seems that the whole issue could be considered a bug and the option isn't needed. Case insensitive comparison should be always used.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@samdark AFAIK it depends on DBMS. If comparison is case sensitive on DB layer, then it should be case sensitive also in AR.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really. In case of case sensitive comparison on DB layer JOIN would not return anything so there will be no population to do.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure about that? I thought that related models are always populated by separate query, so there is no JOIN to filter results. With eager loading you can populate records for both key1 and Key1 in one query - they will mix each other if you ignore case.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@samdark MySQL tests failed. These tests are run only on MySQL.

@onmotion This is result of eager loading optimization. Such case is handled by 2 queries:

  1. Find all products (select * from product)
  2. Find all related attributes for products selected in first query (select * from product_attribute where product_sku in ('ARTi01', 'ARTI01')).

If you ignore case on processing related records, attributes for 'ARTi01' and 'ARTI01' will be mixed and attributes from ARTi01 will appear in ARTI01.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@onmotion what do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mb we can use caseSensitiveKeys = true option by default? Any proposals by @rob006 ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. That might be a good thing to do.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, I don't really like this solution. Whether this key should be case-sensitive depends on column collation (which could be overwritten by query). ActiveRecord does not look like a good place for caseInsensitiveKeys setting. After this change AR assumes which DBMS is used and with what collation - neither of it should be AR concern.

/**
* @var bool whether this query represents a relation to more than one record.
* This property is only used in relational context. If true, this relation will
Expand Down Expand Up @@ -600,7 +608,7 @@ private function normalizeModelKey($value)
$value = $value->__toString();
}

return $value;
return $this->caseInsensitiveKeys ? strtolower($value) : $value;
}

/**
Expand Down
29 changes: 29 additions & 0 deletions tests/data/ar/Product.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php


namespace yiiunit\data\ar;


/**
* Class Product
* @package yiiunit\data\ar
*
* @property string $sku
* @property string $title
* @property ProductAttribute[] $productAttributes
*/
class Product extends ActiveRecord
{
public static function tableName()
{
return 'product';
}

/**
* @return ProductAttribute[]|\yii\db\ActiveQuery
*/
public function getProductAttributes()
{
return $this->hasMany(ProductAttribute::className(), ['product_sku' => 'sku']);
}
}
31 changes: 31 additions & 0 deletions tests/data/ar/ProductAttribute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php


namespace yiiunit\data\ar;


/**
* Class Product
* @package yiiunit\data\ar
*
* @property int $id
* @property string $product_sku
* @property string $value
* @property Product $product
*/
class ProductAttribute extends ActiveRecord
{
public static function tableName()
{
return 'product_attribute';
}

/**
* @return Product|\yii\db\ActiveQuery
*/
public function getProduct()
{
return $this->hasOne(Product::className(), ['sku' => 'product_sku'])->inverseOf('productAttributes');
}

}
19 changes: 19 additions & 0 deletions tests/data/mysql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ DROP TABLE IF EXISTS `T_constraints_2` CASCADE;
DROP TABLE IF EXISTS `T_constraints_1` CASCADE;
DROP TABLE IF EXISTS `T_upsert` CASCADE;
DROP TABLE IF EXISTS `T_upsert_1`;
DROP TABLE IF EXISTS `product`;
DROP TABLE IF EXISTS `product_attribute`;

CREATE TABLE `constraints`
(
Expand Down Expand Up @@ -402,3 +404,20 @@ CREATE TABLE `T_upsert_1` (
`a` int(11) NOT NULL,
PRIMARY KEY (`a`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE product (
sku varchar(45) NOT NULL PRIMARY KEY,
title varchar(128) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE product_attribute (
id INTEGER NOT NULL PRIMARY KEY,
product_sku varchar(45),
value varchar(128)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

INSERT INTO `product` (sku, title) VALUES ('ARTi01', 'Yii1');
INSERT INTO `product` (sku, title) VALUES ('ARTI02', 'Yii2');
INSERT INTO `product_attribute` (id, product_sku, value) VALUES (1, 'ARTI01', 'UPPERCASE');
INSERT INTO `product_attribute` (id, product_sku, value) VALUES (2, 'ARTI01', 'UPPERCASE2');
INSERT INTO `product_attribute` (id, product_sku, value) VALUES (3, 'ARTi01', 'EXACT');
19 changes: 19 additions & 0 deletions tests/data/sqlite.sql
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ DROP TABLE IF EXISTS "T_constraints_2";
DROP TABLE IF EXISTS "T_constraints_1";
DROP TABLE IF EXISTS "T_upsert";
DROP TABLE IF EXISTS "T_upsert_1";
DROP TABLE IF EXISTS "product";
DROP TABLE IF EXISTS "product_attribute";

CREATE TABLE "profile" (
id INTEGER NOT NULL,
Expand Down Expand Up @@ -360,3 +362,20 @@ CREATE TABLE "T_upsert_1"
(
"a" INTEGER NOT NULL PRIMARY KEY
);

CREATE TABLE "product" (
sku varchar(45) NOT NULL,
title varchar(128) NOT NULL,
PRIMARY KEY (sku)
);

CREATE TABLE "product_attribute" (
id INTEGER NOT NULL PRIMARY KEY,
product_sku varchar(45),
value varchar(128)
);
INSERT INTO "product" (sku, title) VALUES ('ARTi01', 'Yii1');
INSERT INTO "product" (sku, title) VALUES ('ARTI02', 'Yii2');
INSERT INTO "product_attribute" (id, product_sku, value) VALUES (1, 'ARTI01', 'UPPERCASE');
INSERT INTO "product_attribute" (id, product_sku, value) VALUES (2, 'ARTI01', 'UPPERCASE2');
INSERT INTO "product_attribute" (id, product_sku, value) VALUES (3, 'ARTi01', 'EXACT');
35 changes: 35 additions & 0 deletions tests/framework/db/ActiveRecordTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
use yiiunit\data\ar\OrderItemWithNullFK;
use yiiunit\data\ar\OrderWithConstructor;
use yiiunit\data\ar\OrderWithNullFK;
use yiiunit\data\ar\Product;
use yiiunit\data\ar\ProductAttribute;
use yiiunit\data\ar\Profile;
use yiiunit\data\ar\ProfileWithConstructor;
use yiiunit\data\ar\Type;
Expand Down Expand Up @@ -200,6 +202,39 @@ public function testFindLazyViaTable()
$this->assertInternalType('array', $order);
}

/**
* related to https://github.com/yiisoft/active-record/issues/22#issuecomment-443460996
*/
public function testCaseInsensitiveKeys()
{
if (!in_array($this->driverName, ['mysql'])) {
$this->markTestSkipped('This test only for databases that make case insensitive search by key like MySQL or postgres with citext');
}

$productAttributes = ProductAttribute::find()->where(['product_sku' => 'ARTi01'])->all();
$this->assertCount(3, $productAttributes); // there are here ARTi01 and ARTI01 records

\Yii::$container->set(ActiveQuery::className(), [
'caseInsensitiveKeys' => false,
]);

$product = Product::find()->where(['sku' => 'ARTi01'])->with('productAttributes')->one(); // join for ARTi01
$this->assertNotNull($product);
$this->assertNotCount(3, $product->productAttributes); // joined one record of 3

\Yii::$container->set(ActiveQuery::className(), [
'caseInsensitiveKeys' => true,
]);

$product = Product::find()->where(['sku' => 'ARTi01'])->with('productAttributes')->one(); // join for ARTi01
$this->assertCount(3, $product->productAttributes);

\Yii::$container->set(ActiveQuery::className(), [
'caseInsensitiveKeys' => false,
]);

}

public function testFindEagerViaTable()
{
$orders = Order::find()->with('books')->orderBy('id')->all();
Expand Down