lithium\data\model\Relationship
Uses
AutoConfigurable
The Relationship
class encapsulates the data and functionality necessary to link two model
classes together.
Source
class Relationship {
use AutoConfigurable;
/**
* Class dependencies.
*
* @var array
*/
protected $_classes = [
'entity' => 'lithium\data\Entity'
];
/**
* A relationship linking type defined by one document or record (or multiple) being embedded
* within another.
*/
const LINK_EMBEDDED = 'embedded';
/**
* The reciprocal of `LINK_EMBEDDED`, this defines a linking type wherein an embedded document
* references the document that contains it.
*/
const LINK_CONTAINED = 'contained';
/**
* A one-to-one or many-to-one relationship in which a key contains an ID value linking to
* another document or record.
*/
const LINK_KEY = 'key';
/**
* A many-to-many relationship in which a key contains an embedded array of IDs linking to other
* records or documents.
*/
const LINK_KEY_LIST = 'keylist';
/**
* A relationship defined by a database-native reference mechanism, linking a key to an
* arbitrary record or document in another data collection or entirely separate database.
*/
const LINK_REF = 'ref';
/**
* Constructor. Constructs an object that represents a relationship between two model classes.
*
* @param array $config The relationship's configuration, which defines how the two models in
* question are bound. The available options are:
* - `'name'` _string_: The name of the relationship in the context of the
* originating model. For example, a `Posts` model might define a relationship to
* a `Users` model like so:
* `public $hasMany = ['Author' => ['to' => 'Users']];`
* In this case, the relationship is bound to the `Users` model, but `'Author'` would
* be the relationship name. This is the name with which the relationship is
* referenced in the originating model.
* - `'key'` _mixed_: An array of fields that define the relationship, where the
* keys are fields in the originating model, and the values are fields in the
* target model. If the relationship is not defined by keys, this array should be
* empty.
* - `'type'` _string_: The type of relationship. Should be one of `'belongsTo'`,
* `'hasOne'` or `'hasMany'`.
* - `'from'` _string_: The fully namespaced class name of the model where this
* relationship originates.
* - `'to'` _string_: The fully namespaced class name of the model that this
* relationship targets.
* - `'link'` _string_: A constant specifying how the object bound to the
* originating model is linked to the object bound to the target model. For
* relational databases, the only valid value is `LINK_KEY`, which means a foreign
* key in one object matches another key (usually the primary key) in the other.
* For document-oriented and other non-relational databases, different types of
* linking, including key lists, database reference objects (such as MongoDB's
* `MongoDBRef`), or even embedding.
* - `'fields'` _mixed_: An array of the subset of fields that should be selected
* from the related object(s) by default. If set to `true` (the default), all
* fields are selected.
* - `'fieldName'` _string_: The name of the field used when accessing the related
* data in a result set. For example, in the case of `Posts hasMany Comments`, the
* field name defaults to `'comments'`, so comment data is accessed (assuming
* `$post = Posts::first()`) as `$post->comments`.
* - `'constraints'` _mixed_: A string or array containing additional constraints
* on the relationship query. If a string, can contain a literal SQL fragment or
* other database-native value. If an array, maps fields from the related object
* either to fields elsewhere, or to arbitrary expressions. In either case, _the
* values specified here will be literally interpreted by the database_.
* - `'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.
* @return void
*/
public function __construct(array $config = []) {
$defaults = [
'name' => null,
'key' => [],
'type' => null,
'to' => null,
'from' => null,
'link' => static::LINK_KEY,
'fields' => true,
'fieldName' => null,
'constraints' => [],
'strategy' => null
];
$config += $defaults;
if (!$config['type'] || !$config['fieldName']) {
throw new ConfigException("`'type'`, `'fieldName'` and `'from'` options can't be empty.");
}
if (!$config['to'] && !$config['name']) {
throw new ConfigException("`'to'` and `'name'` options can't both be empty.");
}
$this->_autoConfig($config, []);
$this->_autoInit($config);
}
/**
* Initializes the `Relationship` object by attempting to automatically generate any values
* that were not provided in the constructor configuration.
*/
protected function _init() {
$config =& $this->_config;
if (!$config['to']) {
$assoc = preg_replace("/\\w+$/", "", $config['from']) . $config['name'];
$config['to'] = Libraries::locate('models', $assoc);
} elseif (!strpos($config['to'], '\\')) {
$config['to'] = preg_replace("/\\w+$/", "", $config['from']) . $config['to'];
}
if (!$config['key'] || !is_array($config['key'])) {
$config['key'] = $this->_keys($config['key']);
}
if ($config['strategy']) {
$config = (array) $config['strategy']($this) + $config;
unset($this->_config['strategy']);
}
}
/**
* Returns the named configuration item, or all configuration data, if no parameter is given.
*
* @param string $key The name of the configuration item to return, or `null` to return all
* items.
* @return mixed Returns a single configuration item (mixed), or an array of all items.
*/
public function data($key = null) {
if (!$key) {
return $this->_config;
}
return isset($this->_config[$key]) ? $this->_config[$key] : null;
}
/**
* Allows relationship configuration items to be queried by name as methods.
*
* @param string $name The name of the configuration item to query.
* @param array $args Unused.
* @return mixed Returns the value of the given configuration item.
*/
public function __call($name, $args = []) {
return $this->data($name);
}
/**
* Gets a related object (or objects) for the given object connected to it by this relationship.
*
* @param object $object The object to get the related data for.
* @param array $options Additional options to merge into the query to be performed, where
* applicable.
* @return object Returns the object(s) for this relationship.
*/
public function get($object, array $options = []) {
$link = $this->link();
$strategies = $this->_strategies();
if (!isset($strategies[$link]) || !is_callable($strategies[$link])) {
$msg = "Attempted to get object for invalid relationship link type `{$link}`.";
throw new ConfigException($msg);
}
return $strategies[$link]($object, $this, $options);
}
/**
* Generates query parameters for a related object (or objects) for the given object
* connected to it by this relationship.
*
* @param object $object The object to get the related data for.
* @return object Returns the object(s) for this relationship.
*/
public function query($object) {
$conditions = (array) $this->constraints();
foreach ($this->key() as $from => $to) {
if (empty($object->{$from})) {
return;
}
$conditions[$to] = $object->{$from};
if ($conditions[$to] instanceof Traversable) {
$conditions[$to] = iterator_to_array($conditions[$to], false);
}
if (empty($conditions[$to])) {
return;
}
}
$fields = $this->fields();
$fields = $fields === true ? null : $fields;
return compact('conditions', 'fields');
}
/**
* Build foreign keys from primary keys array.
*
* @param $primaryKey An array where keys are primary keys and values are
* the associated values of primary keys.
* @return array An array where keys are foreign keys and values are
* the associated values of foreign keys.
*/
public function foreignKey($primaryKey) {
$result = [];
$entity = $this->_classes['entity'];
$keys = ($this->type() === 'belongsTo') ? array_flip($this->key()) : $this->key();
$primaryKey = ($primaryKey instanceof $entity) ? $primaryKey->to('array') : $primaryKey;
foreach ($keys as $key => $foreignKey) {
$result[$foreignKey] = $primaryKey[$key];
}
return $result;
}
/**
* Generates an array of relationship key pairs, where the keys are fields on the origin model,
* and values are fields on the lniked model.
*/
protected function _keys($keys) {
if (!$keys) {
return [];
}
$config = $this->_config;
$hasType = ($config['type'] === 'hasOne' || $config['type'] === 'hasMany');
$related = Libraries::locate('models', $config[$hasType ? 'from' : 'to']);
if (!$related || !class_exists($related)) {
throw new ClassNotFoundException("Related model class '{$related}' not found.");
}
if (!$related::key()) {
throw new ConfigException("No key defined for related model `{$related}`.");
}
$keys = (array) $keys;
$related = (array) $related::key();
if (count($keys) !== count($related)) {
$msg = "Unmatched keys in relationship `{$config['name']}` between models ";
$msg .= "`{$config['from']}` and `{$config['to']}`.";
throw new ConfigException($msg);
}
return $hasType ? array_combine($related, $keys) : array_combine($keys, $related);
}
/**
* Strategies used to query related objects, indexed by key.
*/
protected function _strategies() {
return [
static::LINK_EMBEDDED => function($object, $relationship) {
$fieldName = $relationship->fieldName();
return $object->{$fieldName};
},
static::LINK_CONTAINED => function($object, $relationship) {
$isArray = ($relationship->type() === "hasMany");
return $isArray ? $object->parent()->parent() : $object->parent();
},
static::LINK_KEY => function($object, $relationship, $options) {
$model = $relationship->to();
$method = ($relationship->type() === "hasMany") ? 'all' : 'first';
if (!$query = $relationship->query($object)) {
return $method === 'first' ? null : $model::create([], ['class' => 'set']);
}
return $model::$method(Set::merge((array) $query, (array) $options));
},
static::LINK_KEY_LIST => function($object, $relationship, $options) {
$model = $relationship->to();
if (!$query = $relationship->query($object)) {
return $model::create([], ['class' => 'set']);
}
return $model::all(Set::merge((array) $query, (array) $options));
}
];
}
/**
* Fetch data related to a whole collection and embed the result in it.
*
* @param mixed $collection A collection of data.
* @param array $options The embed query options.
* @return mixed The fetched data.
*/
public function embed(&$collection, $options = []) {
$keys = $this->key();
if (count($keys) !== 1) {
throw new Exception("The embedding doesn't support composite primary key.");
}
switch($this->type()) {
case 'belongsTo';
return $this->_embedBelongsTo($collection, $options);
case 'hasMany';
if ($this->link() === static::LINK_KEY_LIST) {
return $this->_embedHasManyAsList($collection, $options);
}
return $this->_embedHasMany($collection, $options);
case 'hasOne';
return $this->_embedHasOne($collection, $options);
default:
throw new Exception("Error {$this->type()} is unsupported ");
}
}
/**
* Fetch belongsTo related data to a whole collection and embed the result in it.
*
* @param mixed $collection A collection of data.
* @param array $options The embed query options.
* @return mixed The fetched data.
*/
protected function _embedBelongsTo(&$collection, $options) {
$keys = $this->key();
$formKey = key($keys);
$toKey = current($keys);
$related = [];
$indexes = $this->_index($collection, $formKey);
$related = $this->_find(array_keys($indexes), $options);
$indexes = $this->_index($related, $toKey);
$fieldName = $this->fieldName();
foreach ($collection as $index => $source) {
if (is_object($source)) {
$value = (string) $source->{$formKey};
if (isset($indexes[$value])) {
$source->{$fieldName} = $related[$indexes[$value]];
}
} else {
$value = (string) $source[$formKey];
if (isset($indexes[$value])) {
$collection[$index][$fieldName] = $related[$indexes[$value]];
}
}
}
return $related;
}
/**
* Fetch hasMany related data to a whole collection and embed the result in it.
*
* @param mixed $collection A collection of data.
* @param array $options The embed query options.
* @return mixed The fetched data.
*/
protected function _embedHasMany(&$collection, $options) {
$keys = $this->key();
$formKey = key($keys);
$toKey = current($keys);
$related = [];
$indexes = $this->_index($collection, $formKey);
$related = $this->_find(array_keys($indexes), $options);
$fieldName = $this->fieldName();
foreach ($collection as $index => $entity) {
if (is_object($entity)) {
$entity->{$fieldName} = [];
} else {
$collection[$index][$fieldName] = [];
}
}
foreach ($related as $index => $entity) {
$isObject = is_object($entity);
$values = $isObject ? $entity->{$toKey} : $entity[$toKey];
$values = is_array($values) || $values instanceof Traversable ? $values : [$values];
foreach ($values as $value) {
$value = (string) $value;
if (isset($indexes[$value])) {
if ($isObject) {
$source = $collection[$indexes[$value]];
$source->{$fieldName}[] = $entity;
} else {
$collection[$indexes[$value]][$fieldName][] = $entity;
}
}
}
}
return $related;
}
/**
* Fetch hasMany related data (through an embedded list) to a whole collection and embed the result in it.
*
* @param mixed $collection A collection of data.
* @param array $options The embed query options.
* @return mixed The fetched data.
*/
protected function _embedHasManyAsList(&$collection, $options) {
$keys = $this->key();
$formKey = key($keys);
$toKey = current($keys);
$related = [];
$list = $this->_list($collection, $formKey);
$related = $this->_find($list, $options);
$indexes = $this->_index($related, $toKey);
$fieldName = $this->fieldName();
foreach ($collection as $index => $source) {
if (is_object($source)) {
$list = $source->{$formKey};
$source->{$fieldName} = [];
foreach ($list as $id) {
$id = (string) $id;
if (isset($indexes[$id])) {
$source->{$fieldName}[] = $related[$indexes[$id]];
}
}
} else {
$list = $source[$formKey];
$collection[$index][$fieldName] = [];
foreach ($list as $id) {
$id = (string) $id;
if (isset($indexes[$id])) {
$collection[$index][$fieldName][] = $related[$indexes[$id]];
}
}
}
}
return $related;
}
/**
* Fetch hasOne related data to a whole collection and embed the result in it.
*
* @param mixed $collection A collection of data.
* @param array $options The embed query options.
* @return mixed The fetched data.
*/
protected function _embedOne(&$collection, $options) {
$keys = $this->key();
$formKey = key($keys);
$toKey = current($keys);
$related = [];
$indexes = $this->_index($collection, $formKey);
$related = $this->_find(array_keys($indexes), $options);
$fieldName = $this->fieldName();
foreach ($related as $index => $entity) {
if (is_object($entity)) {
$value = (string) $entity->{$toKey};
if (isset($indexes[$value])) {
$source = $collection[$indexes[$value]];
$source->{$fieldName} = $entity;
}
} else {
$value = (string) $entity[$toKey];
if (isset($indexes[$value])) {
$collection[$indexes[$value]][$fieldName] = $entity;
}
}
}
return $related;
}
/**
* Gets all entities attached to a collection en entities.
*
* @param mixed $id An id or an array of ids.
* @return object A collection of items matching the id/ids.
*/
protected function _find($id, $options = []) {
if ($this->link() !== static::LINK_KEY && $this->link() !== static::LINK_KEY_LIST) {
throw new Exception("This relation is not based on a foreign key.");
}
if ($id === []) {
return [];
}
$to = $this->to();
$options += ['conditions' => []];
$options['conditions'] = array_merge($options['conditions'], [
current($this->key()) => $id
]);
return $to::find('all', $options);
}
/**
* Indexes a collection.
*
* @param mixed $collection An collection to extract index from.
* @param string $name The field name to build index for.
* @return array An array of indexes where keys are `$name` values and
* values the correcponding index in the collection.
*/
protected function _index($collection, $name) {
$indexes = [];
foreach ($collection as $key => $entity) {
$id = is_object($entity) ? $entity->{$name} : $entity[$name];
$indexes[(string) $id] = $key;
}
return $indexes;
}
/**
* Extract embedded hasMany foreign keys from collection.
*
* @param mixed $collection An collection to extract keys from.
* @param string $name The field name of the key.
* @return array An array of keys.
*/
protected function _list($collection, $name) {
$list = [];
foreach ($collection as $key => $entity) {
$array = is_object($entity) ? $entity->{$name} : $entity[$name];
foreach ($array as $id) {
$list[] = $id;
}
}
return $list;
}
}