GithubHelp home page GithubHelp logo

hareshpatel1990 / cake3-tests Goto Github PK

View Code? Open in Web Editor NEW

This project forked from beporter/cake3-tests

0.0 2.0 0.0 248 KB

A sample CakePHP 3.x codebase to demonstrate and work through some limitations and "newness" of Cake 3. Demonstrates cakephp/cakephp#6696

License: MIT License

ApacheConf 0.28% Shell 1.40% Batchfile 0.96% PHP 91.08% CSS 6.28%

cake3-tests's Introduction

CakePHP 3.x Testbed

A demonstration CakePHP 3.x codebase to illustrate and work through some limitations and my own unfamiliarity with Cake 3.

Installation

  1. git clone [email protected]:beporter/cake3-tests.git
  2. cd cake3-tests
  3. composer install
  4. vendor/bin/phpunit # This will show failing tests from below.

Current Issues

tl;dr: See PostsTableTest::testFindCommenterAndFindRecent().

If you know how to resolve any of these issues, please feel free to post an Issue, or if you're feeling really charitable, submit a pull request.

Composing contain() clauses

It doesn't seem to be possible to compose multiple calls to Query::contain() together for the same table relationship.

For example, the following code only filters the Comments association by Comments.published_date, and not also by Comments.author.

$this->Posts->find()
    ->contain(['Comments' => function ($q) {
        return $q->andWhere([
            'Comments.author' => 'John Doe',
        ]);
    }])
    ->contain(['Comments' => function ($q) {
        return $q->andWhere([
            'Comments.published_date >=' => new Time('7 days ago'),
        ]);
    }]);

Now obviously this is a contrived example, but what if we had two different custom finder methods that tried to do this separately?

class PostsTable extends Table
{
    public function findCommenter(Query $query, array $options)
    {
        if (!count($options)) {
        	return $query;
        }
        $authorName = array_shift($options);

        $query->contain(['Comments' => function ($q) use ($authorName) {
            return $q->andWhere([
                'Comments.author' => $authorName,
            ]);
        }]);
        return $query;
    }

    public function findRecent(Query $query, array $options)
    {
        $query->contain(['Comments' => function ($q) {
            return $q->andWhere([
                'Comments.published_date >=' => new Time('7 days ago'),
            ]);
        }]);
        return $query;
    }
}

Now when you try to compose these together, you end up with only the last contain() used:

$this->Posts->find('commenter', ['John Doe'])->find('recent');

Extracting sub-entity keys

tl;dr: See PostsTableTest::testResultSetExtract().

So assume for a moment that the compose query above actually returns a $resultSet.

We would have a Collection of Post Entities, and inside each one, we'd have a comments property that was an array of Comment Entities.

In Cake 2, we could have done this:

$authors = Hash::extract($resultSet, '{n}.Comment.{n}.author');
/* Result:
[
    0 => 'John Doe',
    1 => 'Jane Doe',
]
*/

But this no longer works in Cake 3. Even though the ResultSet class implements the CollectionInterface by way of the CollectionTrait, the ::extract() method isn't capable of retrieving values from sub-Entities. In other words, this doesn't work in Cake 3:

$authors = $resultSet->extract('{n}.comments.{n}.author')->toArray();
/* Result:
[
    0 => null,  // <-- bwah?!
    1 => null,
]
*/

Applying [conditions] using the "far" table in a belongsToMany relationship.

tl;dr: See PostsTableTest::testSaveNewPostWithTags().

Let's say I have Posts, and Tags. Posts can be assigned many Tags, and Tags can be re-used on many Posts. This is a classic belongsToMany relationship, and is represented in the database using a "glue" table, conventionally named posts_tags and containing at minimum a post_id and a tag_id.

But what if our Tags have additional properties? Say for example that like StackOverflow, some of our Tags are "sponsored" and we need to present them in the finished app separately from "unsponsored" Tags.

Well, we could add a boolean field to the Tags table call is_sponsored and use it to indicate which "bucket" a Tag belongs to.

# Dump of table tags
# ------------------------------------------------------------

CREATE TABLE `tags` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL DEFAULT '' COMMENT 'Display name of the Tag.',
  `is_sponsored` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT 'Like StackOverflow, some tags can come from sponsors and need to be displayed and handled separately.',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='Available Tags for assignment to Posts. Some tags are from sponsors.';

Then, for the sake of easy filtering and look up, we can define a few custom associations in the PostsTable:

    public function initialize(array $config)
    {
        parent::initialize($config);

        $this->table('posts');
        $this->displayField('title');
        $this->primaryKey('id');

        $this->hasMany('Comments', [
            'foreignKey' => 'post_id'
        ]);

        // This is the "normal" association, and will contain ALL
        // associated Tags, regardless of the `is_sponsored` value.
        $this->belongsToMany('Tags', [
            'foreignKey' => 'post_id',
            'targetForeignKey' => 'tag_id',
            'joinTable' => 'posts_tags'
        ]);

        // Extra convenience associations. Groups any associated Tags
        // into "sponsored" and "unsponsored" buckets.
        $this->belongsToMany('SponsoredTags', [
            'className' => 'Tags',
            'foreignKey' => 'post_id',
            'targetForeignKey' => 'tag_id',
            'joinTable' => 'posts_tags',
            'conditions' => [
                'SponsoredTags.is_sponsored' => true,
            ],
        ]);
        $this->belongsToMany('UnsponsoredTags', [
            'className' => 'Tags',
            'foreignKey' => 'post_id',
            'targetForeignKey' => 'tag_id',
            'joinTable' => 'posts_tags',
            'conditions' => [
                'SponsoredTags.is_sponsored' => false,
            ],
        ]);
    }

These relationships give us nice lists of Tags that are already pre-sorted by whether they are sponsored or not.

We must remember to also make these accessible in our Entity:

class Post extends Entity
{
    /**
     * Fields that can be mass assigned using newEntity() or patchEntity().
     *
     * @var array
     */
    protected $_accessible = [
        'title' => true,
        'body' => true,
        'comments' => true,
        'tags' => true,
        'sponsored_tags' => true,  // NEW!
        'unsponsored_tags' => true,  // NEW!
    ];
}

So here's where the issue comes in: When you try to save these relationships, you're going to run problems.

Take this example request data array:

$data = [
    'title' => 'Post with sponsored and unsponsored tags',
    'body' => '
        This demonstrates request data where default (baked) multi-select
        inputs have been used for `sponsored_tags._ids` and
        `unsponsored_tags._ids`.
    ',
    'sponsored_tags' => [
        '_ids' => [
            4, // Loadsys
        ],
    ],
    'unsponsored_tags' => [
        '_ids' => [
            2, // bugs
            3, // orm
        ],
    ],
];

In our controller, we would of course have to remember to tell the ORM that we want it to retain this related data:

$entityOptions = [
    'associated' => ['SponsoredTags', 'UnsponsoredTags'],
];
$entity = $this->Posts->newEntity($data, $entityOptions);

And then we save it:

$result = $this->Posts->save($entity);

...which produces the following error:

PDOException: SQLSTATE[42S22]: Column not found:
  1054 Unknown column 'SponsoredTags.is_sponsored' in 'where clause'

ROOT/vendor/cakephp/cakephp/src/Database/Statement/MysqlStatement.php:36
ROOT/vendor/cakephp/cakephp/src/Database/Connection.php:270
ROOT/vendor/cakephp/cakephp/src/Database/Query.php:174
ROOT/vendor/cakephp/cakephp/src/ORM/Query.php:872
ROOT/vendor/cakephp/cakephp/src/Datasource/QueryTrait.php:272
ROOT/vendor/cakephp/cakephp/src/ORM/Query.php:823
ROOT/vendor/cakephp/cakephp/src/Datasource/QueryTrait.php:131
ROOT/vendor/cakephp/cakephp/src/ORM/Association/BelongsToMany.php:816
ROOT/vendor/cakephp/cakephp/src/ORM/Association/BelongsToMany.php:765
ROOT/vendor/cakephp/cakephp/src/Database/Connection.php:557
ROOT/vendor/cakephp/cakephp/src/ORM/Association/BelongsToMany.php:786
ROOT/vendor/cakephp/cakephp/src/ORM/Association/BelongsToMany.php:463
ROOT/vendor/cakephp/cakephp/src/ORM/AssociationCollection.php:251
ROOT/vendor/cakephp/cakephp/src/ORM/AssociationCollection.php:227
ROOT/vendor/cakephp/cakephp/src/ORM/AssociationCollection.php:192
ROOT/vendor/cakephp/cakephp/src/ORM/Table.php:1457
ROOT/vendor/cakephp/cakephp/src/ORM/Table.php:1377
ROOT/vendor/cakephp/cakephp/src/Database/Connection.php:557
ROOT/vendor/cakephp/cakephp/src/ORM/Table.php:1378
ROOT/tests/TestCase/Model/Table/PostsTableTest.php:214

The SQL query being executed looks like this:

SELECT
    PostsTags.post_id AS `PostsTags__post_id`,
    PostsTags.tag_id AS `PostsTags__tag_id`
FROM
    posts_tags PostsTags
WHERE
    (post_id = :c0 AND SponsoredTags.is_sponsored = :c1)

...which obviously is wrong and bad because there's no JOIN on Tags AS SponsoredTags in there that would make the is_sponsored field available for use in the WHERE clause.


The source of this error is in \Cake\ORM\Assoiation\BelongsToMany::replaceLinks():

    public function replaceLinks(EntityInterface $sourceEntity, array $targetEntities, array $options = [])
    {
        $bindingKey = (array)$this->bindingKey();
        $primaryValue = $sourceEntity->extract($bindingKey);
        if (count(array_filter($primaryValue, 'strlen')) !== count($bindingKey)) {
            $message = 'Could not find primary key value for source entity';
            throw new InvalidArgumentException($message);
        }
        return $this->junction()->connection()->transactional(
            function () use ($sourceEntity, $targetEntities, $primaryValue, $options) {
                $foreignKey = (array)$this->foreignKey();
                $hasMany = $this->source()->association($this->_junctionTable->alias());
                $existing = $hasMany->find('all')
                    ->where(array_combine($foreignKey, $primaryValue));
                $associationConditions = $this->conditions();
                if ($associationConditions) {
// !! RIGHT HERE !!
                    $existing->andWhere($associationConditions);
                }
                $jointEntities = $this->_collectJointEntities($sourceEntity, $targetEntities);
                $inserts = $this->_diffLinks($existing, $jointEntities, $targetEntities);
                if ($inserts && !$this->_saveTarget($sourceEntity, $inserts, $options)) {
                    return false;
                }
                $property = $this->property();
                if (count($inserts)) {
                    $inserted = array_combine(
                        array_keys($inserts),
                        (array)$sourceEntity->get($property)
                    );
                    $targetEntities = $inserted + $targetEntities;
                }
                ksort($targetEntities);
                $sourceEntity->set($property, array_values($targetEntities));
                $sourceEntity->dirty($property, false);
                return true;
            }
        );
    }

This method is intended to delete, add or update any records in the join table in order to make them "match" with the set of IDs provided in our Table::save() call. Preserving the conditions through this process in this case is necessary. Without them, we'd wipe out any existing unsponsored link records when we saved the updated list of sponsored records, and vice versa. We need to make sure we only operate on those PostsTags records where the post_id matches our new record from the Table::save() like always, but also where the associated Tag.is_sponsored is either specifically true or false.

The solution seems to be that ::replaceLinks() needs to ->contain() the target table when conditions are present so all possible fields that are relevant to the association are available for use. (However, doing this for every association may be ill-advised for a number of reasons, hence restricting it to only those times when conditions are present that might involve the target table.)

License

MIT

cake3-tests's People

Contributors

beporter avatar

Watchers

James Cloos avatar  avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.