diff --git a/src/Jenssegers/Mongodb/Model.php b/src/Jenssegers/Mongodb/Model.php index bbefcc472..a3bd99846 100644 --- a/src/Jenssegers/Mongodb/Model.php +++ b/src/Jenssegers/Mongodb/Model.php @@ -7,6 +7,7 @@ use Jenssegers\Mongodb\DatabaseManager as Resolver; use Jenssegers\Mongodb\Builder as QueryBuilder; use Jenssegers\Mongodb\Relations\BelongsTo; +use Jenssegers\Mongodb\Relations\BelongsToMany; use DateTime; use MongoId; @@ -175,6 +176,43 @@ public function belongsTo($related, $foreignKey = null) return new BelongsTo($query, $this, $foreignKey, $relation); } + + /** + * Define a many-to-many relationship. + * + * @param string $related + * @param string $table + * @param string $foreignKey + * @param string $otherKey + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function belongsToMany($related, $collection = null, $foreignKey = null, $otherKey = null) + { + $caller = $this->getBelongsToManyCaller(); + + // First, we'll need to determine the foreign key and "other key" for the + // relationship. Once we have determined the keys we'll make the query + // instances as well as the relationship instances we need for this. + $foreignKey = $foreignKey ?: $this->getForeignKey() . 's'; + + $instance = new $related; + + $otherKey = $otherKey ?: $instance->getForeignKey() . 's'; + // If no table name was provided, we can guess it by concatenating the two + // models using underscores in alphabetical order. The two model names + // are transformed to snake case from their default CamelCase also. + if (is_null($collection)) + { + $collection = snake_case(str_plural(class_basename($related))); + } + + // Now we're ready to create a new query builder for the related model and + // the relationship instances for the relation. The relations will set + // appropriate query constraint and entirely manages the hydrations. + $query = $instance->newQuery(); + + return new BelongsToMany($query, $this, $collection, $foreignKey, $otherKey, $caller['function']); + } /** * Get a new query builder instance for the connection. diff --git a/src/Jenssegers/Mongodb/Relations/BelongsToMany.php b/src/Jenssegers/Mongodb/Relations/BelongsToMany.php new file mode 100644 index 000000000..a0aff091e --- /dev/null +++ b/src/Jenssegers/Mongodb/Relations/BelongsToMany.php @@ -0,0 +1,402 @@ +getSelectColumns($columns); + + $models = $this->query->addSelect($select)->getModels(); + + // If we actually found models we will also eager load any relationships that + // have been specified as needing to be eager loaded. This will solve the + // n + 1 query problem for the developer and also increase performance. + if (count($models) > 0) + { + $models = $this->query->eagerLoadRelations($models); + } + + return $this->related->newCollection($models); + } + + /** + * Set the select clause for the relation query. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + protected function getSelectColumns(array $columns = array('*')) + { + return $columns; + } + + /** + * Get a paginator for the "select" statement. + * + * @param int $perPage + * @param array $columns + * @return \Illuminate\Pagination\Paginator + */ + public function paginate($perPage = null, $columns = array('*')) + { + $this->query->addSelect($this->getSelectColumns($columns)); + + // When paginating results, we need to add the pivot columns to the query and + // then hydrate into the pivot objects once the results have been gathered + // from the database since this isn't performed by the Eloquent builder. + $pager = $this->query->paginate($perPage, $columns); + + return $pager; + } + + + /** + * Set the base constraints on the relation query. + * + * @return void + */ + public function addConstraints() + { + if (static::$constraints) + { + // Make sure that the primary key of the parent + // is in the relationship array of keys + $this->query->whereIn($this->foreignKey, array($this->parent->getKey())); + } + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + public function addEagerConstraints(array $models) + { + $this->query->whereIn($this->getForeignKey(), $this->getKeys($models)); + } + + /** + * Save a new model and attach it to the parent model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param array $joining + * @param bool $touch + * @return \Illuminate\Database\Eloquent\Model + */ + public function save(Model $model, array $joining = array(), $touch = true) + { + $model->save(array('touch' => false)); + + $this->attach($model->getKey(), $joining, $touch); + + return $model; + } + + /** + * Create a new instance of the related model. + * + * @param array $attributes + * @param array $joining + * @param bool $touch + * @return \Illuminate\Database\Eloquent\Model + */ + public function create(array $attributes, array $joining = array(), $touch = true) + { + $instance = $this->related->newInstance($attributes); + + // Save the new instance before we attach it to other models + $instance->save(array('touch' => false)); + + // Attach to the parent instance + $this->attach($instance->_id, $attributes, $touch); + + return $instance; + } + + /** + * Sync the intermediate tables with a list of IDs. + * + * @param array $ids + * @param bool $detaching + * @return void + */ + public function sync(array $ids, $detaching = true) + { + // First we need to attach any of the associated models that are not currently + // in this joining table. We'll spin through the given IDs, checking to see + // if they exist in the array of current ones, and if not we will insert. + $current = $this->parent->{$this->otherKey}; + + // Check if the current array exists or not on the parent model and create it + // if it does not exist + if (is_null($current)) $current = array(); + + $records = $this->formatSyncList($ids); + + $detach = array_diff($current, array_keys($records)); + + // Next, we will take the differences of the currents and given IDs and detach + // all of the entities that exist in the "current" array but are not in the + // the array of the IDs given to the method which will complete the sync. + if ($detaching and count($detach) > 0) + { + $this->detach($detach); + } + + // Now we are finally ready to attach the new records. Note that we'll disable + // touching until after the entire operation is complete so we don't fire a + // ton of touch operations until we are totally done syncing the records. + $this->attachNew($records, $current, false); + + $this->touchIfTouching(); + } + + /** + * Format the sync list so that it is keyed by ID. + * + * @param array $records + * @return array + */ + protected function formatSyncList(array $records) + { + $results = array(); + + foreach ($records as $id => $attributes) + { + if ( ! is_array($attributes)) + { + list($id, $attributes) = array($attributes, array()); + } + + $results[$id] = $attributes; + } + + return $results; + } + + /** + * Attach all of the IDs that aren't in the current array. + * + * @param array $records + * @param array $current + * @param bool $touch + * @return void + */ + protected function attachNew(array $records, array $current, $touch = true) + { + foreach ($records as $id => $attributes) + { + // If the ID is not in the list of existing pivot IDs, we will insert a new pivot + // record, otherwise, we will just update this existing record on this joining + // table, so that the developers will easily update these records pain free. + if ( ! in_array($id, $current)) + { + $this->attach($id, $attributes, $touch); + } + } + } + + /** + * Attach a model to the parent. + * + * @param mixed $id + * @param array $attributes + * @param bool $touch + * @return void + */ + public function attach($id, array $attributes = array(), $touch = true) + { + if ($id instanceof Model) $id = $id->getKey(); + + // Generate a new parent query instance + $parent = $this->newParentQuery(); + + // Generate a new related query instance + $related = $this->related->newInstance(); + + // Set contraints on the related query + $related = $related->where($this->related->getKeyName(), $id); + + $records = $this->createAttachRecords((array) $id, $attributes); + + // Get the ID's to attach to the two documents + $otherIds = array_pluck($records, $this->otherKey); + $foreignIds = array_pluck($records, $this->foreignKey); + + // Attach to the parent model + $parent->push($this->otherKey, $otherIds[0])->update(array()); + + // Attach to the related model + $related->push($this->foreignKey, $foreignIds[0])->update(array()); + } + + /** + * Create an array of records to insert into the pivot table. + * + * @param array $ids + * @return void + */ + protected function createAttachRecords($ids, array $attributes) + { + $records = array();; + + // To create the attachment records, we will simply spin through the IDs given + // and create a new record to insert for each ID. Each ID may actually be a + // key in the array, with extra attributes to be placed in other columns. + foreach ($ids as $key => $value) + { + $records[] = $this->attacher($key, $value, $attributes, false); + } + + return $records; + } + + /** + * Detach models from the relationship. + * + * @param int|array $ids + * @param bool $touch + * @return int + */ + public function detach($ids = array(), $touch = true) + { + if ($ids instanceof Model) $ids = (array) $ids->getKey(); + + $query = $this->newParentQuery(); + + // If associated IDs were passed to the method we will only delete those + // associations, otherwise all of the association ties will be broken. + // We'll return the numbers of affected rows when we do the deletes. + $ids = (array) $ids; + + if (count($ids) > 0) + { + $query->whereIn($this->otherKey, $ids); + } + + if ($touch) $this->touchIfTouching(); + + // Once we have all of the conditions set on the statement, we are ready + // to run the delete on the pivot table. Then, if the touch parameter + // is true, we will go ahead and touch all related models to sync. + foreach($ids as $id) + { + $query->pull($this->otherKey, $id); + } + + return count($ids); + } + + /** + * If we're touching the parent model, touch. + * + * @return void + */ + public function touchIfTouching() + { + if ($this->touchingParent()) $this->getParent()->touch(); + + if ($this->getParent()->touches($this->relationName)) $this->touch(); + } + + /** + * Determine if we should touch the parent on sync. + * + * @return bool + */ + protected function touchingParent() + { + return $this->getRelated()->touches($this->guessInverseRelation()); + } + + /** + * Attempt to guess the name of the inverse of the relation. + * + * @return string + */ + protected function guessInverseRelation() + { + return strtolower(str_plural(class_basename($this->getParent()))); + } + + /** + * Create a new query builder for the parent + * + * @return Jenssegers\Mongodb\Builder + */ + protected function newParentQuery() + { + $query = $this->parent->newQuery(); + + return $query->where($this->parent->getKeyName(), '=', $this->parent->getKey()); + } + + /** + * Build model dictionary keyed by the relation's foreign key. + * + * @param \Illuminate\Database\Eloquent\Collection $results + * @return array + */ + protected function buildDictionary(Collection $results) + { + $foreign = $this->foreignKey; + + // First we will build a dictionary of child models keyed by the foreign key + // of the relation so that we will easily and quickly match them to their + // parents without having a possibly slow inner loops for every models. + $dictionary = array(); + + foreach ($results as $result) + { + foreach ($result->$foreign as $single) + { + $dictionary[$single][] = $result; + } + } + + return $dictionary; + } + + /** + * Get the related model's updated at column name. + * + * @return string + */ + public function getRelatedFreshUpdate() + { + return array($this->related->getUpdatedAtColumn() => $this->related->freshTimestamp()); + } + + /** + * Get the fully qualified foreign key for the relation. + * + * @return string + */ + public function getForeignKey() + { + return $this->foreignKey; + } + + /** + * Get the fully qualified "other key" for the relation. + * + * @return string + */ + public function getOtherKey() + { + return $this->otherKey; + } +} \ No newline at end of file diff --git a/tests/ModelTest.php b/tests/ModelTest.php index 505023d8a..70af9303e 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -303,5 +303,4 @@ public function testUnset() $this->assertFalse(isset($user2->note1)); $this->assertFalse(isset($user2->note2)); } - -} +} \ No newline at end of file diff --git a/tests/RelationsTest.php b/tests/RelationsTest.php index 182efc0b8..b646014de 100644 --- a/tests/RelationsTest.php +++ b/tests/RelationsTest.php @@ -3,6 +3,11 @@ class RelationsTest extends PHPUnit_Framework_TestCase { public function setUp() { + User::truncate(); + Book::truncate(); + Item::truncate(); + Role::truncate(); + Client::truncate(); } public function tearDown() @@ -11,8 +16,9 @@ public function tearDown() Book::truncate(); Item::truncate(); Role::truncate(); + Client::truncate(); } - + public function testHasMany() { $author = User::create(array('name' => 'George R. R. Martin')); @@ -101,5 +107,90 @@ public function testWithHasOne() $this->assertInstanceOf('Role', $role); $this->assertEquals('admin', $role->type); } + + public function testHasManyAndBelongsTo() + { + $user = User::create(array('name' => 'John Doe')); + + $user->clients()->save(new Client(array('name' => 'Pork Pies Ltd.'))); + $user->clients()->create(array('name' => 'Buffet Bar Inc.')); + + $user = User::with('clients')->find($user->_id); + + $client = Client::with('users')->first(); + + $clients = $client->getRelation('users'); + $users = $user->getRelation('clients'); + + $this->assertInstanceOf('Illuminate\Database\Eloquent\Collection', $users); + $this->assertInstanceOf('Illuminate\Database\Eloquent\Collection', $clients); + $this->assertInstanceOf('Client', $users[0]); + $this->assertInstanceOf('User', $clients[0]); + $this->assertCount(2, $user->clients); + $this->assertCount(1, $client->users); + + // Now create a new user to an existing client + $client->users()->create(array('name' => 'Jane Doe')); + + $otherClient = User::where('name', '=', 'Jane Doe')->first()->clients()->get(); + + $this->assertInstanceOf('Illuminate\Database\Eloquent\Collection', $otherClient); + $this->assertInstanceOf('Client', $otherClient[0]); + $this->assertCount(1, $otherClient); + + // Now attach an existing client to an existing user + $user = User::where('name', '=', 'Jane Doe')->first(); + $client = Client::Where('name', '=', 'Buffet Bar Inc.')->first(); + + // Check the models are what they should be + $this->assertInstanceOf('Client', $client); + $this->assertInstanceOf('User', $user); + + // Assert they are not attached + $this->assertFalse(in_array($client->_id, $user->client_ids)); + $this->assertFalse(in_array($user->_id, $client->user_ids)); + + // Attach the client to the user + $user->clients()->attach($client); + + // Get the new user model + $user = User::where('name', '=', 'Jane Doe')->first(); + $client = Client::Where('name', '=', 'Buffet Bar Inc.')->first(); + + // Assert they are attached + $this->assertTrue(in_array($client->_id, $user->client_ids)); + $this->assertTrue(in_array($user->_id, $client->user_ids)); + } + public function testHasManyAndBelongsToAttachesExistingModels() + { + $user = User::create(array('name' => 'John Doe', 'client_ids' => array('1234523'))); + + $clients = array( + Client::create(array('name' => 'Pork Pies Ltd.'))->_id, + Client::create(array('name' => 'Buffet Bar Inc.'))->_id + ); + + $moreClients = array( + Client::create(array('name' => 'Boloni Ltd.'))->_id, + Client::create(array('name' => 'Meatballs Inc.'))->_id + ); + + $user->clients()->sync($clients); + + $user = User::with('clients')->find($user->_id); + + // Assert non attached ID's are detached succesfully + $this->assertFalse(in_array('1234523', $user->client_ids)); + + // Assert there are two client objects in the relationship + $this->assertCount(2, $user->clients); + + $user->clients()->sync($moreClients); + + $user = User::with('clients')->find($user->_id); + + // Assert there are now 4 client objects in the relationship + $this->assertCount(4, $user->clients); + } } diff --git a/tests/models/Client.php b/tests/models/Client.php new file mode 100644 index 000000000..de55ceab6 --- /dev/null +++ b/tests/models/Client.php @@ -0,0 +1,14 @@ +belongsToMany('User'); + } +} \ No newline at end of file diff --git a/tests/models/User.php b/tests/models/User.php index 022df5427..2e6862760 100644 --- a/tests/models/User.php +++ b/tests/models/User.php @@ -25,6 +25,11 @@ public function role() { return $this->hasOne('Role'); } + + public function clients() + { + return $this->belongsToMany('Client'); + } /** * Get the unique identifier for the user.