Skip to content

Commit

Permalink
HABTM Model relationship support for READ queries using Database da…
Browse files Browse the repository at this point in the history
…tasources.
  • Loading branch information
jails committed Aug 5, 2014
1 parent c70684e commit b17d2e8
Show file tree
Hide file tree
Showing 19 changed files with 1,080 additions and 34 deletions.
2 changes: 1 addition & 1 deletion data/Entity.php
Expand Up @@ -155,7 +155,7 @@ public function __construct(array $config = array()) {

protected function _init() {
parent::_init();
$this->_updated = $this->_data;
$this->set($this->_data);
}

/**
Expand Down
10 changes: 9 additions & 1 deletion data/Model.php
Expand Up @@ -117,6 +117,13 @@ class Model extends \lithium\core\StaticObject {
*/
public $belongsTo = array();

/**
* Model hasAndBelongsToMany relations.
*
* @var array
*/
public $hasAndBelongsToMany = array();

/**
* Stores model instances for internal use.
*
Expand Down Expand Up @@ -175,10 +182,11 @@ class Model extends \lithium\core\StaticObject {
* - `belongsTo`
* - `hasOne`
* - `hasMany`
* - `hasAndBelongsToMany`
*
* @var array
*/
protected $_relationTypes = array('belongsTo', 'hasOne', 'hasMany');
protected $_relationTypes = array('belongsTo', 'hasOne', 'hasMany', 'hasAndBelongsToMany');

/**
* Store available relation names for this model which still unloaded.
Expand Down
2 changes: 1 addition & 1 deletion data/collection/RecordSet.php
Expand Up @@ -158,7 +158,7 @@ protected function _hydrateRecord($relations, $primary, $record, $min, $max, $na
$field = $relMap[$relName]['fieldName'];
$relModel = $relMap[$relName]['model'];

if ($relMap[$relName]['type'] === 'hasMany') {
if (preg_match('/Many$/', $relMap[$relName]['type'])) {
$rel = array();
$main = $relModel::key($record[$min][$relName]);
$i = $min;
Expand Down
80 changes: 80 additions & 0 deletions data/entity/Record.php
Expand Up @@ -19,6 +19,86 @@ protected function _init() {
$this->_handlers += array('stdClass' => function($item) { return $item; });
}

/**
* PHP magic method used when accessing fields as document properties, i.e. `$record->id`.
*
* Also manage scalar datas for relations. Indeed, on form submission relations datas are
* provided by a select input which generally provided the following array:
*
* {{{
* array(
* 'id' => 3
* 'Comment' => array(
* '5', '6', '9
* );
* }}}
*
* To avoid painfull pre-processing, this function will automagically manage such relation
* array by reformating it into the following expected format:
*
* {{{
* 'Post' => array(
* 'id' => 3
* 'Comment' => array(
* array(
* 'id' => '5'
* },
* array(
* 'id' => '6'
* },
* array(
* 'id' => '9'
* },
* );
* }}}
*
* @param $name The field name, as specified with an object property.
* @return mixed Returns the value of the field specified in `$name`, and wraps complex data
* types in sub-`Document` objects.
*/
public function __set($name, $value = null) {
parent::__set($name, $value);
$model = $this->_model;
$result = $this->_updated[$name];
if (is_object($result) || $result === null || !$model || !$rel = $model::relations($name)) {
return $result;
}

$primary = $model::key();
$primary = !is_array($primary) ? $primary : null;
$type = $rel->type();
$modelTo = $rel->to();
if ($type === 'hasOne' || $type === 'belongsTo') {
$exists = false;
if (is_scalar($result) && $primary) {
$result = array($primary => $result);
$exists = true;
}
if (is_array($result)) {
$result = $modelTo::create($result, array(
'defaults' => false, 'class' => 'entity', 'exists' => $exists
));
}
} else {
$result = $result ? (array) $result : array();
foreach ($result as $key => $entity) {
if (!is_object($entity)) {
$exists = false;
if (is_scalar($entity) && $primary) {
$result[$key] = array($primary => $entity);
$exists = true;
}
$result[$key] = $modelTo::create($result[$key], array(
'defaults' => false, 'class' => 'entity', 'exists' => $exists
));
}
}
$result = $modelTo::create($result, array('class' => 'set'));
}
$this->_updated[$name] = $result;
return $result;
}

/**
* Converts a `Record` object to another specified format.
*
Expand Down
5 changes: 4 additions & 1 deletion data/model/Relationship.php
Expand Up @@ -103,6 +103,8 @@ class Relationship extends \lithium\core\Object {
* - `'strategy'` _closure_: An anonymous function used by an instantiating class,
* such as a database object, to provide additional, dynamic configuration, after
* the `Relationship` instance has finished configuring itself.
* - `via` _string_: HABTM specific option with indicate the relation name of the
* middle class
*/
public function __construct(array $config = array()) {
$defaults = array(
Expand All @@ -115,7 +117,8 @@ public function __construct(array $config = array()) {
'fields' => true,
'fieldName' => null,
'constraints' => array(),
'strategy' => null
'strategy' => null,
'via' => null
);
$config += $defaults;

Expand Down
83 changes: 70 additions & 13 deletions data/source/Database.php
Expand Up @@ -226,19 +226,66 @@ protected function _init() {
}
extract($with[$relPath]);
}
$to = $context->alias($alias, $relPath);

$deps[$to] = $deps[$from];
$deps[$to][] = $from;
if ($rel->type() !== 'hasAndBelongsToMany') {
$to = $context->alias($alias, $relPath);

if ($context->relationships($relPath) === null) {
$context->relationships($relPath, array(
'type' => $rel->type(),
'model' => $rel->to(),
'fieldName' => $rel->fieldName(),
'alias' => $to
));
$self->join($context, $rel, $from, $to, $constraints);
$deps[$to] = $deps[$from];
$deps[$to][] = $from;

if ($context->relationships($relPath) === null) {
$context->relationships($relPath, array(
'type' => $rel->type(),
'model' => $rel->to(),
'fieldName' => $rel->fieldName(),
'alias' => $to
));
$self->join($context, $rel, $from, $to, $constraints);
}
} else {
$nameVia = $rel->data('via');
$relnameVia = $path ? $path . '.' . $nameVia : $nameVia;

if (!$relVia = $model::relations($nameVia)) {
$message = "Model relationship `{$nameVia}` not found.";
throw new QueryException($message);
}

if (!$config = $context->relationships($relnameVia)) {
$aliasVia = $context->alias($nameVia, $relnameVia);
$context->relationships($relnameVia, array(
'type' => $relVia->type(),
'model' => $relVia->to(),
'fieldName' => $relVia->fieldName(),
'alias' => $aliasVia
));
$self->join($context, $relVia, $from, $aliasVia, $self->on($rel));
} else {
$aliasVia = $config['alias'];
}

$deps[$aliasVia] = $deps[$from];
$deps[$aliasVia][] = $from;

if (!$context->relationships($relPath)) {
$to = $context->alias($alias, $relPath);
$modelVia = $relVia->data('to');
if (!$relTo = $modelVia::relations($name)) {
$message = "Model relationship `{$name}` ";
$message .= "via `{$nameVia}` not found.";
throw new QueryException($message);
}
$context->relationships($relPath, array(
'type' => $rel->type(),
'model' => $relTo->to(),
'fieldName' => $rel->fieldName(),
'alias' => $to
));
$self->join($context, $relTo, $aliasVia, $to, $constraints);
}

$deps[$to] = $deps[$aliasVia];
$deps[$to][] = $aliasVia;
}

if (!empty($childs)) {
Expand Down Expand Up @@ -708,18 +755,25 @@ public function calculation($type, $query, array $options = array()) {
*/
public function relationship($class, $type, $name, array $config = array()) {
$primary = $class::meta('key');
$fieldName = $this->relationFieldName($type, $name);
$from = $class;

if (is_array($primary)) {
$key = array_combine($primary, $primary);
} elseif ($type === 'hasMany' || $type === 'hasOne') {
$secondary = Inflector::underscore(Inflector::singularize($class::meta('name')));
$key = array($primary => "{$secondary}_id");
} elseif ($type === 'hasAndBelongsToMany') {
$secondary = Inflector::underscore(Inflector::singularize($name));
$key = array($primary => "{$secondary}_id");
$viaRel = $from::relations($config['via']);
$via = $viaRel->to();
$toRel = $via::relations($name);
$config += array('to' => $toRel->to());
} else {
$key = Inflector::underscore(Inflector::singularize($name)) . '_id';
}

$from = $class;
$fieldName = $this->relationFieldName($type, $name);
$config += compact('type', 'name', 'key', 'from', 'fieldName');
return $this->_instance('relationship', $config);
}
Expand Down Expand Up @@ -1529,6 +1583,9 @@ protected function _aliasing($name, $alias, $map = array()) {
* @return array A constraints array.
*/
public function on($rel, $aliasFrom = null, $aliasTo = null, $constraints = array()) {
if ($rel->type() === 'hasAndBelongsToMany') {
return $constraints;
}
$model = $rel->from();

$aliasFrom = $aliasFrom ?: $model::meta('name');
Expand Down
2 changes: 2 additions & 0 deletions tests/cases/data/ModelTest.php
Expand Up @@ -312,6 +312,7 @@ public function testRelationshipIntrospection() {
'fieldName' => 'mock_post',
'constraints' => array(),
'strategy' => null,
'via' => null,
'init' => true
);
$this->assertEqual($expected, MockComment::relations('MockPost')->data());
Expand All @@ -327,6 +328,7 @@ public function testRelationshipIntrospection() {
'fieldName' => 'mock_comments',
'constraints' => array(),
'strategy' => null,
'via' => null,
'init' => true
);
$this->assertEqual($expected, MockPost::relations('MockComment')->data());
Expand Down
49 changes: 45 additions & 4 deletions tests/cases/data/entity/RecordTest.php
Expand Up @@ -11,31 +11,35 @@
use lithium\data\Connections;
use lithium\data\entity\Record;
use lithium\data\Schema;
use lithium\tests\mocks\data\MockSource;
use lithium\tests\mocks\data\model\MockDatabase;
use lithium\tests\mocks\data\MockPost;
use lithium\tests\mocks\data\MockComment;
use lithium\tests\mocks\data\MockSource;

class RecordTest extends \lithium\test\Unit {

protected $_record = null;
protected $_schema = null;

public function setUp() {
Connections::add('mockconn', array('object' => new MockSource()));
Connections::add('mockconn', array('object' => new MockDatabase()));

$schema = new Schema(array(
$this->_schema = new Schema(array(
'fields' => array(
'id' => 'int', 'title' => 'string', 'body' => 'text'
)
));
MockPost::config(array(
'meta' => array('connection' => 'mockconn', 'key' => 'id', 'locked' => true),
'schema' => $schema
'schema' => $this->_schema
));
$this->_record = new Record(array('model' => 'lithium\tests\mocks\data\MockPost'));
}

public function tearDown() {
Connections::remove('mockconn');
MockPost::reset();
MockComment::reset();
}

/**
Expand Down Expand Up @@ -121,12 +125,49 @@ public function testRecordExists() {
}

public function testMethodDispatch() {
Connections::add('mocksource', array('object' => new MockSource()));
MockPost::config(array(
'meta' => array('connection' => 'mocksource', 'key' => 'id', 'locked' => true),
'schema' => $this->_schema
));
$result = $this->_record->save(array('title' => 'foo'));
$this->assertEqual('create', $result['query']->type());
$this->assertEqual(array('title' => 'foo'), $result['query']->data());

$this->expectException("Unhandled method call `invalid`.");
$this->assertNull($this->_record->invalid());
Connections::remove('mocksource');
}

public function testSetOnMany() {
$this->_schema->append(array('published' => array('type' => 'string', 'default' => 'N')));
MockComment::config(array(
'meta' => array('connection' => 'mockconn', 'key' => 'id', 'locked' => true),
'schema' => $this->_schema
));
$this->_record->mock_comments = array('5', '6', '7');

$expected = array(array('id' => 5), array('id' => 6), array('id' => 7));
$result = $this->_record->mock_comments->to('array', array('indexed' => false));
$this->assertEqual($expected, $result);
}

public function testSetOnSingle() {
MockComment::config(array(
'meta' => array('connection' => 'mockconn', 'key' => 'id', 'locked' => true),
'schema' => $this->_schema
));
$this->_schema->append(array('published' => array('type' => 'string', 'default' => 'N')));
MockPost::config(array(
'meta' => array('connection' => 'mockconn', 'key' => 'id', 'locked' => true),
'schema' => $this->_schema
));
$this->_record = new Record(array('model' => 'lithium\tests\mocks\data\MockComment'));
$this->_record->mock_post = 5;

$expected = array('id' => 5);
$result = $this->_record->mock_post->to('array', array('indexed' => false));
$this->assertEqual($expected, $result);
}
}

Expand Down
2 changes: 2 additions & 0 deletions tests/cases/data/source/MongoDbTest.php
Expand Up @@ -549,6 +549,7 @@ public function testRelationshipGeneration() {
'fields' => true,
'fieldName' => 'mockPost',
'constraints' => array(),
'via' => null,
'init' => true
);
$this->assertEqual($expected, $result->data());
Expand Down Expand Up @@ -596,6 +597,7 @@ public function testRelationshipGenerationWithPluralNamingConvention() {
'fields' => true,
'fieldName' => 'mockPost',
'constraints' => array(),
'via' => null,
'init' => true
);
$this->assertEqual($expected, $result->data());
Expand Down

0 comments on commit b17d2e8

Please sign in to comment.