From 8ede3757a1ebdc85f684b39f97840ceff3269c60 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Thu, 18 Jan 2024 21:15:51 -0500 Subject: [PATCH] relation documentation and fixes. --- README.md | 34 +++- src/ActiveRecord.php | 213 ++++++++++++++------------ tests/ActiveRecordIntegrationTest.php | 159 +++++++++++-------- tests/classes/Contact.php | 8 +- tests/classes/User.php | 10 +- 5 files changed, 251 insertions(+), 173 deletions(-) diff --git a/README.md b/README.md index 3be4658..153d8c2 100644 --- a/README.md +++ b/README.md @@ -295,13 +295,38 @@ $user->isNotNull('id')->find(); ### Relationships You can set several kinds of relationships using this library. You can set one->many and one->one relationships between tables. This requires a little extra setup in the class beforehand. +Setting the `$relations` array is not hard, but guessing the correct syntax can be confusing. + +```php +public $relations = [ + // you can name the key anything you'd like. The name of the ActiveRecord is probably good. Ex: user, contact, client + 'whatever_active_record' => [ + // required + self::HAS_ONE, // this is the type of relationship + + // required + 'Some_Class', // this is the "other" ActiveRecord class this will reference + + // required + 'local_key', // this is the local_key that references the join. + // just FYI, this also only joins to the primary key of the "other" model + + // optional + [ 'eq' => 1, 'select' => 'COUNT(*) as count', 'limit' 5 ], // custom methods you want executed. [] if you don't want any. + + // optional + 'back_reference_name' // this is if you want to back reference this relationship back to itself Ex: $user->contact->user; + ]; +] +``` + ```php class User extends ActiveRecord{ public $table = 'user'; public $primaryKey = 'id'; public $relations = [ - 'contacts' => [ self::HAS_MANY, 'Contact', 'user_id' ], - 'contact' => [ self::HAS_ONE, 'Contact', 'user_id' ], + 'contacts' => [ self::HAS_MANY, Contact::class, 'user_id' ], + 'contact' => [ self::HAS_ONE, Contact::class, 'user_id' ], ]; } @@ -309,9 +334,8 @@ class Contact extends ActiveRecord{ public $table = 'contact'; public $primaryKey = 'id'; public $relations = [ - 'user' => [ self::BELONGS_TO, 'User', 'user_id' ], - 'user_with_backref' => [ self::BELONGS_TO, 'User', 'user_id', [], 'contact' ], - // using 5th param to define backref + 'user' => [ self::BELONGS_TO, User::class, 'user_id' ], + 'user_with_backref' => [ self::BELONGS_TO, User::class, 'user_id', [], 'contact' ], ]; } ``` diff --git a/src/ActiveRecord.php b/src/ActiveRecord.php index 0f9534b..f10bb33 100644 --- a/src/ActiveRecord.php +++ b/src/ActiveRecord.php @@ -127,30 +127,30 @@ abstract class ActiveRecord extends Base 'group' => null ]; - /** - * Possible Events that can be run on the Active Record - * - * @var array - */ - protected array $events = [ - 'beforeInsert', - 'afterInsert', - 'beforeUpdate', - 'afterUpdate', - 'beforeSave', - 'afterSave', - 'beforeDelete', - 'afterDelete', - 'beforeFind', - 'afterFind', - 'beforeFindAll', - 'afterFindAll' - ]; - - /** - * @var array Stored the SQL Expressions of the SQL. - */ - protected array $expressions = []; + /** + * Possible Events that can be run on the Active Record + * + * @var array + */ + protected array $events = [ + 'beforeInsert', + 'afterInsert', + 'beforeUpdate', + 'afterUpdate', + 'beforeSave', + 'afterSave', + 'beforeDelete', + 'afterDelete', + 'beforeFind', + 'afterFind', + 'beforeFindAll', + 'afterFindAll' + ]; + + /** + * @var array Stored the SQL Expressions of the SQL. + */ + protected array $expressions = []; /** * @var array Stored the Expressions of the SQL. @@ -236,7 +236,7 @@ public function __call($name, $args) 'operator' => $this->sqlParts[$name], 'target' => implode(', ', $args) ]); - } + } return $this; } @@ -299,11 +299,17 @@ public function setCustomData(string $key, $value): void $this->custom_data[$key] = $value; } + public function clearData(): self + { + $this->data = []; + return $this; + } + /** * function to reset the $params and $sqlExpressions. * @return ActiveRecord return $this, can using chain method calls. */ - protected function resetQueryData() + protected function resetQueryData(): self { $this->params = []; $this->sqlExpressions = []; @@ -350,11 +356,11 @@ public function find($id = null) $this->resetQueryData()->eq($this->primaryKey, $id); } - $this->processEvent('beforeFind', [ $this ]); + $this->processEvent('beforeFind', [ $this ]); - $result = $this->query($this->limit(1)->buildSql(['select', 'from', 'join', 'where', 'group', 'having', 'order', 'limit', 'offset']), $this->params, $this->resetQueryData(), true); + $result = $this->query($this->limit(1)->buildSql(['select', 'from', 'join', 'where', 'group', 'having', 'order', 'limit', 'offset']), $this->params, $this->resetQueryData(), true); - $this->processEvent('afterFind', [ $result ]); + $this->processEvent('afterFind', [ $result ]); return $result; } @@ -365,10 +371,10 @@ public function find($id = null) public function findAll(): array { - $this->processEvent('beforeFindAll', [ $this ]); - $results = $this->query($this->buildSql(['select', 'from', 'join', 'where', 'group', 'having', 'order', 'limit', 'offset']), $this->params, $this->resetQueryData()); - $this->processEvent('afterFindAll', [ $results ]); - return $results; + $this->processEvent('beforeFindAll', [ $this ]); + $results = $this->query($this->buildSql(['select', 'from', 'join', 'where', 'group', 'having', 'order', 'limit', 'offset']), $this->params, $this->resetQueryData()); + $this->processEvent('afterFindAll', [ $results ]); + return $results; } /** * function to delete current record in database. @@ -376,9 +382,9 @@ public function findAll(): array */ public function delete() { - $this->processEvent('beforeDelete', [ $this ]); - $result = $this->execute($this->eq($this->primaryKey, $this->{$this->primaryKey})->buildSql(['delete', 'from', 'where']), $this->params); - $this->processEvent('afterDelete', [ $this ]); + $this->processEvent('beforeDelete', [ $this ]); + $result = $this->execute($this->eq($this->primaryKey, $this->{$this->primaryKey})->buildSql(['delete', 'from', 'where']), $this->params); + $this->processEvent('afterDelete', [ $this ]); return $result; } /** @@ -397,16 +403,16 @@ public function insert(): ActiveRecord ]); $this->values = new Expressions(['operator'=> 'VALUES', 'target' => new WrapExpressions(['target' => $value])]); - $this->processEvent([ 'beforeInsert', 'beforeSave' ], [ $this ]); - - $this->execute($this->buildSql(['insert', 'values']), $this->params); + $this->processEvent([ 'beforeInsert', 'beforeSave' ], [ $this ]); + + $this->execute($this->buildSql(['insert', 'values']), $this->params); $this->{$this->primaryKey} = $this->pdo->lastInsertId(); - $this->processEvent([ 'afterInsert', 'afterSave' ], [ $this ]); + $this->processEvent([ 'afterInsert', 'afterSave' ], [ $this ]); - return $this->dirty()->resetQueryData(); + return $this->dirty()->resetQueryData(); } - /** + /** * function to build update SQL, and update current record in database, just write the dirty data into database. * @return ActiveRecord if update success return current object */ @@ -419,28 +425,28 @@ public function update(): ActiveRecord $this->addCondition($field, '=', $value, ',', 'set'); } - $this->processEvent([ 'beforeUpdate', 'beforeSave' ] , [ $this ]); + $this->processEvent([ 'beforeUpdate', 'beforeSave' ], [ $this ]); $this->execute($this->eq($this->primaryKey, $this->{$this->primaryKey})->buildSql(['update', 'set', 'where']), $this->params); - $this->processEvent([ 'afterUpdate', 'afterSave' ] , [ $this ]); + $this->processEvent([ 'afterUpdate', 'afterSave' ], [ $this ]); return $this->dirty()->resetQueryData(); } - /** - * Updates or inserts a record - * - * @return ActiveRecord - */ - public function save(): ActiveRecord - { - if($this->{$this->primaryKey}) { - return $this->update(); - } else { - return $this->insert(); - } - } + /** + * Updates or inserts a record + * + * @return ActiveRecord + */ + public function save(): ActiveRecord + { + if ($this->{$this->primaryKey}) { + return $this->update(); + } else { + return $this->insert(); + } + } /** * helper function to exec sql. * @param string $sql The SQL need to be execute. @@ -455,14 +461,14 @@ public function execute(string $sql, array $params = []): bool throw new Exception($this->pdo->errorInfo()[2]); } - $this->processEvent('beforeExecute', [ $statement, $params ]); + $this->processEvent('beforeExecute', [ $statement, $params ]); - $result = $statement->execute($params); + $result = $statement->execute($params); if ($result === false) { throw new Exception($statement->errorInfo()[2]); } - $this->processEvent('afterExecute', [ $statement ]); + $this->processEvent('afterExecute', [ $statement ]); return $result; } /** @@ -477,7 +483,12 @@ public function query(string $sql, array $param = [], ActiveRecord $obj = null, { $sth = $this->pdo->prepare($sql); $called_class = get_called_class(); - $sth->setFetchMode(PDO::FETCH_INTO, ($obj ? $obj : new $called_class )); + $obj = $obj ?: new $called_class($this->pdo); + + // Since we are finding a new record, this makes sure that nothing is persisted on the object since we're really looking for a new object. + $obj->clearData(); + + $sth->setFetchMode(PDO::FETCH_INTO, $obj); $sth->execute($param); if ($single) { return $sth->fetch() ? $obj->dirty() : false; @@ -491,33 +502,44 @@ public function query(string $sql, array $param = [], ActiveRecord $obj = null, /** * helper function to get relation of this object. * There was three types of relations: {BELONGS_TO, HAS_ONE, HAS_MANY} - * @param string $name The name of the relation, the array key when defind the relation. + * @param string $name The name of the relation, the array key when defining the relation. * @return mixed */ protected function &getRelation(string $name) { $relation = $this->relations[$name]; - if ($relation instanceof self || (is_array($relation) && $relation[0] instanceof self)) { + if (is_array($relation) === true) { + // self::BELONGS_TO etc + $relation_type_or_object_name = $relation[0]; + $relation_class = $relation[1] ?? ''; + $relation_local_key = $relation[2] ?? ''; + $relation_array_callbacks = $relation[3] ?? []; + $relation_back_reference = $relation[4] ?? ''; + } + + if ($relation instanceof self || $relation_type_or_object_name instanceof self) { return $relation; } - $this->relations[$name] = $obj = new $relation[1]($this->pdo); - if (isset($relation[3]) && is_array($relation[3])) { - foreach ((array)$relation[3] as $func => $args) { - call_user_func_array([$obj, $func], (array)$args); + + $obj = new $relation_class($this->pdo); + $this->relations[$name] = $obj; + if ($relation_array_callbacks) { + foreach ($relation_array_callbacks as $method => $args) { + call_user_func_array([ $obj, $method ], (array) $args); } } - $backref = isset($relation[4]) ? $relation[4] : ''; - if ((!$relation instanceof self) && self::HAS_ONE == $relation[0]) { - $obj->eq($relation[2], $this->{$this->primaryKey})->find() && $backref && $obj->__set($backref, $this); - } elseif (is_array($relation) && self::HAS_MANY == $relation[0]) { - $this->relations[$name] = $obj->eq($relation[2], $this->{$this->primaryKey})->findAll(); - if ($backref) { + + if ((!$relation instanceof self) && self::HAS_ONE === $relation_type_or_object_name) { + $obj->eq($relation_local_key, $this->{$this->primaryKey})->find() && $relation_back_reference && $obj->__set($relation_back_reference, $this); + } elseif (self::HAS_MANY === $relation_type_or_object_name) { + $this->relations[$name] = $obj->eq($relation_local_key, $this->{$this->primaryKey})->findAll(); + if ($relation_back_reference) { foreach ($this->relations[$name] as $o) { - $o->__set($backref, $this); + $o->__set($relation_back_reference, $this); } } - } elseif ((!$relation instanceof self) && self::BELONGS_TO == $relation[0]) { - $obj->eq($obj->primaryKey, $this->{$relation[2]})->find() && $backref && $obj->__set($backref, $this); + } elseif (!($relation instanceof self) && self::BELONGS_TO === $relation_type_or_object_name) { + $obj->eq($obj->primaryKey, $this->{$relation_local_key})->find() && $relation_back_reference && $obj->__set($relation_back_reference, $this); } return $this->relations[$name]; } @@ -668,7 +690,7 @@ protected function addExpression(Expressions $expressions, string $delimiter) $this->expressions[] = new Expressions(['operator' => $delimiter, 'target' => $expressions]); } } - + /** * helper function to add condition into WHERE. * @param Expressions $exp The expression will be concat into WHERE or SET statement. @@ -684,22 +706,23 @@ protected function addConditionGroup(Expressions $expressions, string $operator, } } - /** - * Process an event that's been set. - * - * @param string|array $event The name (or array of names) of the event from $this->events - * @param array $data_to_pass Usually ends up being $this - * @return void - */ - protected function processEvent($event, array $data_to_pass = []) { - if(is_array($event)=== false) { - $event = [ $event ]; - } - - foreach($event as $event_name) { - if(method_exists($this, $event_name) && in_array($event_name, $this->events, true) === true) { - $this->{$event_name}(...$data_to_pass); - } - } - } + /** + * Process an event that's been set. + * + * @param string|array $event The name (or array of names) of the event from $this->events + * @param array $data_to_pass Usually ends up being $this + * @return void + */ + protected function processEvent($event, array $data_to_pass = []) + { + if (is_array($event)=== false) { + $event = [ $event ]; + } + + foreach ($event as $event_name) { + if (method_exists($this, $event_name) && in_array($event_name, $this->events, true) === true) { + $this->{$event_name}(...$data_to_pass); + } + } + } } diff --git a/tests/ActiveRecordIntegrationTest.php b/tests/ActiveRecordIntegrationTest.php index 0f2d413..daa7f97 100644 --- a/tests/ActiveRecordIntegrationTest.php +++ b/tests/ActiveRecordIntegrationTest.php @@ -102,17 +102,18 @@ public function testUpdateNoChanges() $this->assertIsObject($user_result); } - public function testSave() { - $user = new User(new PDO('sqlite:test.db')); + public function testSave() + { + $user = new User(new PDO('sqlite:test.db')); $user->name = 'demo'; $user->password = 'pass'; $user->save(); // should have inserted - $insert_id = $user->id; - $user->name = 'new name'; - $user->save(); - $this->assertEquals($insert_id, $user->id); - $this->assertEquals('new name', $user->name); - } + $insert_id = $user->id; + $user->name = 'new name'; + $user->save(); + $this->assertEquals($insert_id, $user->id); + $this->assertEquals('new name', $user->name); + } public function testRelations() { @@ -249,60 +250,90 @@ public function testDelete() $this->assertFalse($new_user->find($uid)); } - public function testFindEvents() { - $user = new class(new PDO('sqlite:test.db')) extends User { - public function beforeFind(self $self) { - // This will force it to pull this kind of query - // every time. - $self->eq('name', 'Bob'); - } - - public function afterFind(self $self) { - $self->password = 'joepassword'; - $self->setCustomData('real_name', 'Joe'); - } - }; - $user->name = 'Bob'; - $user->password = 'bobbytables'; - $user->insert(); - $user_record = $user->find(); - $this->assertEquals('Joe', $user_record->real_name); - $this->assertEquals('joepassword', $user_record->password); - } - - public function testInsertEvents() { - $user = new class(new PDO('sqlite:test.db')) extends User { - protected function beforeInsert(self $self) { - $self->password = 'defaultpassword'; - } - - protected function afterInsert(self $self) { - $self->name .= ' after insert'; - } - }; - $user->name = 'Bob'; - $user->password = 'bobbytables'; - $user->insert(); - $this->assertEquals('Bob after insert', $user->name); - $this->assertEquals('defaultpassword', $user->password); - } - - public function testLimit() { - $user = new User(new PDO('sqlite:test.db')); - $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); - $user->insert(); - $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); - $user->insert(); - $user->dirty([ 'name' => 'bob3', 'password' => 'pass3' ]); - $user->insert(); - - $users = $user->limit(2)->findAll(); - $this->assertEquals('bob', $users[0]->name); - $this->assertEquals('bob2', $users[1]->name); - - $users = $user->limit(1, 2)->findAll(); - $this->assertEquals('bob2', $users[0]->name); - $this->assertEquals('bob3', $users[1]->name); - - } + public function testFindEvents() + { + $user = new class(new PDO('sqlite:test.db')) extends User { + public function beforeFind(self $self) + { + // This will force it to pull this kind of query + // every time. + $self->eq('name', 'Bob'); + } + + public function afterFind(self $self) + { + $self->password = 'joepassword'; + $self->setCustomData('real_name', 'Joe'); + } + }; + $user->name = 'Bob'; + $user->password = 'bobbytables'; + $user->insert(); + $user_record = $user->find(); + $this->assertEquals('Joe', $user_record->real_name); + $this->assertEquals('joepassword', $user_record->password); + } + + public function testInsertEvents() + { + $user = new class(new PDO('sqlite:test.db')) extends User { + protected function beforeInsert(self $self) + { + $self->password = 'defaultpassword'; + } + + protected function afterInsert(self $self) + { + $self->name .= ' after insert'; + } + }; + $user->name = 'Bob'; + $user->password = 'bobbytables'; + $user->insert(); + $this->assertEquals('Bob after insert', $user->name); + $this->assertEquals('defaultpassword', $user->password); + } + + public function testLimit() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob3', 'password' => 'pass3' ]); + $user->insert(); + + $users = $user->limit(2)->findAll(); + $this->assertEquals('bob', $users[0]->name); + $this->assertEquals('bob2', $users[1]->name); + + $users = $user->limit(1, 2)->findAll(); + $this->assertEquals('bob2', $users[0]->name); + $this->assertEquals('bob3', $users[1]->name); + } + + public function testCountWithSelect() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob3', 'password' => 'pass3' ]); + $user->insert(); + + $user->select('COUNT(*) as count')->find(); + $this->assertEquals(3, $user->count); + } + + public function testSelectOneColumn() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->select('name')->find(); + $this->assertEquals('bob', $user->name); + $this->assertEmpty($user->password); + } } diff --git a/tests/classes/Contact.php b/tests/classes/Contact.php index fb87163..8ac7093 100644 --- a/tests/classes/Contact.php +++ b/tests/classes/Contact.php @@ -8,8 +8,8 @@ class Contact extends ActiveRecord { public string $table = 'contact'; public string $primaryKey = 'id'; - public array $relations = array( - 'user_with_backref' => array(self::BELONGS_TO, User::class, 'user_id', null, 'contact'), - 'user' => array(self::BELONGS_TO, User::class, 'user_id'), - ); + public array $relations = [ + 'user_with_backref' => [self::BELONGS_TO, User::class, 'user_id', null, 'contact'], + 'user' => [self::BELONGS_TO, User::class, 'user_id'], + ]; } diff --git a/tests/classes/User.php b/tests/classes/User.php index 5ba5372..cef77f3 100644 --- a/tests/classes/User.php +++ b/tests/classes/User.php @@ -8,9 +8,9 @@ class User extends ActiveRecord { public string $table = 'user'; public string $primaryKey = 'id'; - public array $relations = array( - 'contacts' => array(self::HAS_MANY, Contact::class, 'user_id'), - 'contacts_with_backref' => array(self::HAS_MANY, Contact::class, 'user_id', null, 'user'), - 'contact' => array(self::HAS_ONE, Contact::class, 'user_id', array('where' => '1', 'order' => 'id desc')), - ); + public array $relations = [ + 'contacts' => [self::HAS_MANY, Contact::class, 'user_id'], + 'contacts_with_backref' => [self::HAS_MANY, Contact::class, 'user_id', null, 'user'], + 'contact' => [self::HAS_ONE, Contact::class, 'user_id', ['where' => '1', 'order' => 'id desc']], + ]; }