diff --git a/.gitignore b/.gitignore index 8cc7a5a20..a4a4579c3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ phpunit.phar composer.phar composer.lock *.sublime-project -*.sublime-workspace \ No newline at end of file +*.sublime-workspace +*.project diff --git a/src/Jenssegers/Mongodb/Model.php b/src/Jenssegers/Mongodb/Model.php index ae43d190e..ecc94e304 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 Carbon\Carbon; use DateTime; @@ -199,6 +200,43 @@ public function belongsTo($related, $foreignKey = null, $otherKey = null, $relat return new BelongsTo($query, $this, $foreignKey, $otherKey, $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. * @@ -274,4 +312,4 @@ public function __call($method, $parameters) return parent::__call($method, $parameters); } -} +} \ No newline at end of file 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 d03a7f207..04e275b83 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -312,11 +312,14 @@ public function testUnset() public function testDates() { $user = User::create(array('name' => 'John Doe', 'birthday' => new DateTime('1980/1/1'))); + $this->assertInstanceOf('Carbon\Carbon', $user->birthday); $check = User::find($user->_id); + $this->assertInstanceOf('Carbon\Carbon', $check->birthday); $this->assertEquals($user->birthday, $check->birthday); + $user = User::where('birthday', '>', new DateTime('1975/1/1'))->first(); $this->assertEquals('John Doe', $user->name); diff --git a/tests/RelationsTest.php b/tests/RelationsTest.php index ef21749bf..2b9c0bef0 100644 --- a/tests/RelationsTest.php +++ b/tests/RelationsTest.php @@ -3,126 +3,213 @@ class RelationsTest extends PHPUnit_Framework_TestCase { public function setUp() { - } - - public function tearDown() - { - User::truncate(); - Book::truncate(); - Item::truncate(); - Role::truncate(); - } - - public function testHasMany() - { - $author = User::create(array('name' => 'George R. R. Martin')); - Book::create(array('title' => 'A Game of Thrones', 'author_id' => $author->_id)); - Book::create(array('title' => 'A Clash of Kings', 'author_id' => $author->_id)); - - $books = $author->books; - $this->assertEquals(2, count($books)); - - $user = User::create(array('name' => 'John Doe')); - Item::create(array('type' => 'knife', 'user_id' => $user->_id)); - Item::create(array('type' => 'shield', 'user_id' => $user->_id)); - Item::create(array('type' => 'sword', 'user_id' => $user->_id)); - Item::create(array('type' => 'bag', 'user_id' => null)); - - $items = $user->items; - $this->assertEquals(3, count($items)); - } - - public function testBelongsTo() - { - $user = User::create(array('name' => 'George R. R. Martin')); - Book::create(array('title' => 'A Game of Thrones', 'author_id' => $user->_id)); - $book = Book::create(array('title' => 'A Clash of Kings', 'author_id' => $user->_id)); - - $author = $book->author; - $this->assertEquals('George R. R. Martin', $author->name); - - $user = User::create(array('name' => 'John Doe')); - $item = Item::create(array('type' => 'sword', 'user_id' => $user->_id)); - - $owner = $item->user; - $this->assertEquals('John Doe', $owner->name); - } - - public function testHasOne() - { - $user = User::create(array('name' => 'John Doe')); - Role::create(array('type' => 'admin', 'user_id' => $user->_id)); - - $role = $user->role; - $this->assertEquals('admin', $role->type); - } - - public function testWithBelongsTo() + } + + public function tearDown() + { + User::truncate(); + Book::truncate(); + Item::truncate(); + Role::truncate(); + Client::truncate(); + } + + public function testHasMany() + { + $author = User::create(array('name' => 'George R. R. Martin')); + Book::create(array('title' => 'A Game of Thrones', 'author_id' => $author->_id)); + Book::create(array('title' => 'A Clash of Kings', 'author_id' => $author->_id)); + + $books = $author->books; + $this->assertEquals(2, count($books)); + + $user = User::create(array('name' => 'John Doe')); + Item::create(array('type' => 'knife', 'user_id' => $user->_id)); + Item::create(array('type' => 'shield', 'user_id' => $user->_id)); + Item::create(array('type' => 'sword', 'user_id' => $user->_id)); + Item::create(array('type' => 'bag', 'user_id' => null)); + + $items = $user->items; + $this->assertEquals(3, count($items)); + } + + public function testBelongsTo() + { + $user = User::create(array('name' => 'George R. R. Martin')); + Book::create(array('title' => 'A Game of Thrones', 'author_id' => $user->_id)); + $book = Book::create(array('title' => 'A Clash of Kings', 'author_id' => $user->_id)); + + $author = $book->author; + $this->assertEquals('George R. R. Martin', $author->name); + + $user = User::create(array('name' => 'John Doe')); + $item = Item::create(array('type' => 'sword', 'user_id' => $user->_id)); + + $owner = $item->user; + $this->assertEquals('John Doe', $owner->name); + } + + public function testHasOne() + { + $user = User::create(array('name' => 'John Doe')); + Role::create(array('type' => 'admin', 'user_id' => $user->_id)); + + $role = $user->role; + $this->assertEquals('admin', $role->type); + } + + public function testWithBelongsTo() + { + $user = User::create(array('name' => 'John Doe')); + Item::create(array('type' => 'knife', 'user_id' => $user->_id)); + Item::create(array('type' => 'shield', 'user_id' => $user->_id)); + Item::create(array('type' => 'sword', 'user_id' => $user->_id)); + Item::create(array('type' => 'bag', 'user_id' => null)); + + $items = Item::with('user')->get(); + + $user = $items[0]->getRelation('user'); + $this->assertInstanceOf('User', $user); + $this->assertEquals('John Doe', $user->name); + $this->assertEquals(1, count($items[0]->getRelations())); + $this->assertEquals(null, $items[3]->getRelation('user')); + } + + public function testWithHashMany() + { + $user = User::create(array('name' => 'John Doe')); + Item::create(array('type' => 'knife', 'user_id' => $user->_id)); + Item::create(array('type' => 'shield', 'user_id' => $user->_id)); + Item::create(array('type' => 'sword', 'user_id' => $user->_id)); + Item::create(array('type' => 'bag', 'user_id' => null)); + + $user = User::with('items')->find($user->_id); + + $items = $user->getRelation('items'); + $this->assertEquals(3, count($items)); + $this->assertInstanceOf('Item', $items[0]); + } + + public function testWithHasOne() + { + $user = User::create(array('name' => 'John Doe')); + Role::create(array('type' => 'admin', 'user_id' => $user->_id)); + Role::create(array('type' => 'guest', 'user_id' => $user->_id)); + + $user = User::with('role')->find($user->_id); + + $role = $user->getRelation('role'); + $this->assertInstanceOf('Role', $role); + $this->assertEquals('admin', $role->type); + } + + public function testEasyRelation() + { + // Has Many + $user = User::create(array('name' => 'John Doe')); + $item = Item::create(array('type' => 'knife')); + $user->items()->save($item); + + $user = User::find($user->_id); + $items = $user->items; + $this->assertEquals(1, count($items)); + $this->assertInstanceOf('Item', $items[0]); + + // Has one + $user = User::create(array('name' => 'John Doe')); + $role = Role::create(array('type' => 'admin')); + $user->role()->save($role); + + $user = User::find($user->_id); + $role = $user->role; + $this->assertInstanceOf('Role', $role); + $this->assertEquals('admin', $role->type); + } + + public function testHasManyAndBelongsTo() { $user = User::create(array('name' => 'John Doe')); - Item::create(array('type' => 'knife', 'user_id' => $user->_id)); - Item::create(array('type' => 'shield', 'user_id' => $user->_id)); - Item::create(array('type' => 'sword', 'user_id' => $user->_id)); - Item::create(array('type' => 'bag', 'user_id' => null)); - - $items = Item::with('user')->get(); - - $user = $items[0]->getRelation('user'); + + $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); - $this->assertEquals('John Doe', $user->name); - $this->assertEquals(1, count($items[0]->getRelations())); - $this->assertEquals(null, $items[3]->getRelation('user')); - } - - public function testWithHashMany() - { - $user = User::create(array('name' => 'John Doe')); - Item::create(array('type' => 'knife', 'user_id' => $user->_id)); - Item::create(array('type' => 'shield', 'user_id' => $user->_id)); - Item::create(array('type' => 'sword', 'user_id' => $user->_id)); - Item::create(array('type' => 'bag', 'user_id' => null)); - - $user = User::with('items')->find($user->_id); - - $items = $user->getRelation('items'); - $this->assertEquals(3, count($items)); - $this->assertInstanceOf('Item', $items[0]); - } - - public function testWithHasOne() - { - $user = User::create(array('name' => 'John Doe')); - Role::create(array('type' => 'admin', 'user_id' => $user->_id)); - Role::create(array('type' => 'guest', 'user_id' => $user->_id)); - - $user = User::with('role')->find($user->_id); - - $role = $user->getRelation('role'); - $this->assertInstanceOf('Role', $role); - $this->assertEquals('admin', $role->type); + + // 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 testEasyRelation() + public function testHasManyAndBelongsToAttachesExistingModels() { - // Has Many - $user = User::create(array('name' => 'John Doe')); - $item = Item::create(array('type' => 'knife')); - $user->items()->save($item); - - $user = User::find($user->_id); - $items = $user->items; - $this->assertEquals(1, count($items)); - $this->assertInstanceOf('Item', $items[0]); - - // Has one - $user = User::create(array('name' => 'John Doe')); - $role = Role::create(array('type' => 'admin')); - $user->role()->save($role); - - $user = User::find($user->_id); - $role = $user->role; - $this->assertInstanceOf('Role', $role); - $this->assertEquals('admin', $role->type); + $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 + ); + + // Sync multiple records + $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 26143b6be..652d48f51 100644 --- a/tests/models/User.php +++ b/tests/models/User.php @@ -7,13 +7,13 @@ class User extends Eloquent implements UserInterface, RemindableInterface { - protected $collection = 'users'; + protected $collection = 'users'; + + protected $dates = array('birthday'); + + protected static $unguarded = true; - protected $dates = array('birthday'); - - protected static $unguarded = true; - - public function books() + public function books() { return $this->hasMany('Book', 'author_id'); } @@ -27,6 +27,11 @@ public function role() { return $this->hasOne('Role'); } + + public function clients() + { + return $this->belongsToMany('Client'); + } /** * Get the unique identifier for the user. @@ -57,5 +62,4 @@ public function getReminderEmail() { return $this->email; } - -} +} \ No newline at end of file