diff --git a/doc/Developer-Domain-Driven-Design.md b/doc/Developer-Domain-Driven-Design.md new file mode 100644 index 0000000000..b8d886aa0d --- /dev/null +++ b/doc/Developer-Domain-Driven-Design.md @@ -0,0 +1,239 @@ +Domain-Driven-Design +============== + +* [Home](help) + * [Developer Intro](help/Developers-Intro) + +Friendica uses class structures inspired by Domain-Driven-Design programming patterns. +This page is meant to explain what it means in practical terms for Friendica development. + +## Inspiration + +- https://designpatternsphp.readthedocs.io/en/latest/Structural/DependencyInjection/README.html +- https://designpatternsphp.readthedocs.io/en/latest/Creational/SimpleFactory/README.html +- https://designpatternsphp.readthedocs.io/en/latest/More/Repository/README.html +- https://designpatternsphp.readthedocs.io/en/latest/Creational/FactoryMethod/README.html +- https://designpatternsphp.readthedocs.io/en/latest/Creational/Prototype/README.html + +## Core concepts + +### Models and Collections + +Instead of anonymous arrays of arrays of database field values, we have Models and collections to take full advantage of PHP type hints. + +Before: +```php +function doSomething(array $intros) +{ + foreach ($intros as $intro) { + $introId = $intro['id']; + } +} + +$intros = \Friendica\Database\DBA::selectToArray('intros', [], ['uid' => local_user()]); + +doSomething($intros); +``` + +After: +```php +function doSomething(\Friendica\Collection\Introductions $intros) +{ + foreach ($intros as $intro) { + /** @var $intro \Friendica\Model\Introduction */ + $introId = $intro->id; + } +} + +/** @var $intros \Friendica\Collection\Introductions */ +$intros = \Friendica\DI::intro()->select(['uid' => local_user()]); + +doSomething($intros); +``` + +### Dependency Injection + +Under this concept, we want class objects to carry with them the dependencies they will use. +Instead of calling global/static function/methods, objects use their own class members. + +Before: +```php +class Model +{ + public $id; + + function save() + { + return \Friendica\Database\DBA::update('table', get_object_vars($this), ['id' => $this->id]); + } +} +``` + +After: +```php +class Model +{ + /** + * @var \Friendica\Database\Database + */ + protected $dba; + + public $id; + + function __construct(\Friendica\Database\Database $dba) + { + $this->dba = $dba; + } + + function save() + { + return $this->dba->update('table', get_object_vars($this), ['id' => $this->id]); + } +} +``` + +The main advantage is testability. +Another one is avoiding dependency circles and avoid implicit initializing. +In the first example the method `save()` has to be tested with the `DBA::update()` method, which may or may not have dependencies itself. + +In the second example we can mock `\Friendica\Database\Database`, e.g. overload the class by replacing its methods by placeholders, which allows us to test only `Model::save()` and nothing else implicitly. + +The main drawback is lengthy constructors for dependency-heavy classes. +To alleviate this issue we are using [DiCe](https://r.je/dice) to simplify the instantiation of the higher level objects Friendica uses. + +We also added a convenience factory named `\Friendica\DI` that creates some of the most common objects used in modules. + +### Factories + +Since we added a bunch of parameters to class constructors, instantiating objects has become cumbersome. +To keep it simple, we are using Factories. +Factories are classes used to generate other objects, centralizing the dependencies required in their constructor. +Factories encapsulate more or less complex creation of objects and create them redundancy free. + +Before: +```php +$model = new Model(\Friendica\DI::dba()); +$model->id = 1; +$model->key = 'value'; + +$model->save(); +``` + +After: +```php +class Factory +{ + /** + * @var \Friendica\Database\Database + */ + protected $dba; + + function __construct(\Friendica\Database\Database $dba) + { + $this->dba; + } + + public function create() + { + return new Model($this->dba); + } +} + +$model = \Friendica\DI::factory()->create(); +$model->id = 1; +$model->key = 'value'; + +$model->save(); +``` + +Here, `DI::factory()` returns an instance of `Factory` that can then be used to create a `Model` object without having to care about its dependencies. + +### Repositories + +Last building block of our code architecture, repositories are meant as the interface between models and how they are stored. +In Friendica they are stored in a relational database but repositories allow models not to have to care about it. +Repositories also act as factories for the Model they are managing. + +Before: +```php +class Model +{ + /** + * @var \Friendica\Database\Database + */ + protected $dba; + + public $id; + + function __construct(\Friendica\Database\Database $dba) + { + $this->dba = $dba; + } + + function save() + { + return $this->dba->update('table', get_object_vars($this), ['id' => $this->id]); + } +} + +class Factory +{ + /** + * @var \Friendica\Database\Database + */ + protected $dba; + + function __construct(\Friendica\Database\Database $dba) + { + $this->dba; + } + + public function create() + { + return new Model($this->dba); + } +} + + +$model = \Friendica\DI::factory()->create(); +$model->id = 1; +$model->key = 'value'; + +$model->save(); +``` + +After: +```php +class Model { + public $id; +} + +class Repository extends Factory +{ + /** + * @var \Friendica\Database\Database + */ + protected $dba; + + function __construct(\Friendica\Database\Database $dba) + { + $this->dba; + } + + public function create() + { + return new Model($this->dba); + } + + public function save(Model $model) + { + return $this->dba->update('table', get_object_vars($model), ['id' => $model->id]); + } +} + +$model = \Friendica\DI::repository()->create(); +$model->id = 1; +$model->key = 'value'; + +\Friendica\DI::repository()->save($model); +``` diff --git a/doc/Developers-Intro.md b/doc/Developers-Intro.md index ae9a856b8e..f72ff4abeb 100644 --- a/doc/Developers-Intro.md +++ b/doc/Developers-Intro.md @@ -41,6 +41,8 @@ If you have seen Friendica you probably have ideas to improve it, haven't you? ## Programming +Friendica uses an implementation of [Domain-Driven-Design](help/Developer-Domain-Driven-Design), please make sure to check out the provided links for hints at the expected code architecture. + ### Composer Friendica uses [Composer](https://getcomposer.org) to manage dependencies libraries and the class autoloader both for libraries and namespaced Friendica classes. diff --git a/doc/Home.md b/doc/Home.md index d58cfeca6a..9ed552bd3a 100644 --- a/doc/Home.md +++ b/doc/Home.md @@ -45,6 +45,7 @@ Friendica Documentation and Resources * [Help on Vagrant](help/Vagrant) * [Bugs and Issues](help/Bugs-and-Issues) * Code structure + * [Domain-Driven-Design](help/Developer-Domain-Driven-Design) * [Addon Development](help/Addons) * [Theme Development](help/themes) * [Smarty 3 Templates](help/smarty3-templates) diff --git a/mod/notifications.php b/mod/notifications.php index 73730b50a5..420ac65a4d 100644 --- a/mod/notifications.php +++ b/mod/notifications.php @@ -31,14 +31,14 @@ function notifications_post(App $a) } if ($request_id) { - $Intro = DI::intro()->fetch(['id' => $request_id, 'uid' => local_user()]); + $intro = DI::intro()->selectFirst(['id' => $request_id, 'uid' => local_user()]); switch ($_POST['submit']) { case L10n::t('Discard'): - $Intro->discard(); + $intro->discard(); break; case L10n::t('Ignore'): - $Intro->ignore(); + $intro->ignore(); break; } diff --git a/src/Api/BaseEntity.php b/src/Api/BaseEntity.php new file mode 100644 index 0000000000..4bc15943df --- /dev/null +++ b/src/Api/BaseEntity.php @@ -0,0 +1,18 @@ +id = $publicContact['id']; + $this->username = $publicContact['nick']; + $this->acct = + strpos($publicContact['url'], $baseUrl->get() . '/') === 0 ? + $publicContact['nick'] : + $publicContact['addr']; + $this->display_name = $publicContact['name']; + $this->locked = !empty($apcontact['manually-approve']); + $this->created_at = DateTimeFormat::utc($publicContact['created'], DateTimeFormat::ATOM); + $this->followers_count = $apcontact['followers_count'] ?? 0; + $this->following_count = $apcontact['following_count'] ?? 0; + $this->statuses_count = $apcontact['statuses_count'] ?? 0; + $this->note = BBCode::convert($publicContact['about'], false); + $this->url = $publicContact['url']; + $this->avatar = $userContact['avatar'] ?? $publicContact['avatar']; + $this->avatar_static = $userContact['avatar'] ?? $publicContact['avatar']; + // No header picture in Friendica + $this->header = ''; + $this->header_static = ''; + // No custom emojis per account in Friendica + $this->emojis = []; + // No metadata fields in Friendica + $this->fields = []; + $this->bot = ($publicContact['contact-type'] == Contact::TYPE_NEWS); + $this->group = ($publicContact['contact-type'] == Contact::TYPE_COMMUNITY); + $this->discoverable = !$publicContact['unsearchable']; + + $publicContactLastItem = $publicContact['last-item'] ?: DBA::NULL_DATETIME; + $userContactLastItem = $userContact['last-item'] ?? DBA::NULL_DATETIME; + + $lastItem = $userContactLastItem > $publicContactLastItem ? $userContactLastItem : $publicContactLastItem; + $this->last_status_at = $lastItem != DBA::NULL_DATETIME ? DateTimeFormat::utc($lastItem, DateTimeFormat::ATOM) : null; + } +} diff --git a/src/Api/Entity/Mastodon/Emoji.php b/src/Api/Entity/Mastodon/Emoji.php new file mode 100644 index 0000000000..f3dbfa5e7d --- /dev/null +++ b/src/Api/Entity/Mastodon/Emoji.php @@ -0,0 +1,22 @@ +id = $introduction_id; + } +} diff --git a/src/Api/Mastodon/Instance.php b/src/Api/Entity/Mastodon/Instance.php similarity index 73% rename from src/Api/Mastodon/Instance.php rename to src/Api/Entity/Mastodon/Instance.php index 6652641b49..c3828e2b87 100644 --- a/src/Api/Mastodon/Instance.php +++ b/src/Api/Entity/Mastodon/Instance.php @@ -1,12 +1,11 @@ $administrator['nickname'], 'self' => true]); - $apcontact = APContact::getByURL($adminContact['url'], false); - $instance->contact_account = Account::create($baseUrl, $adminContact, $apcontact); + $adminContact = DBA::selectFirst('contact', ['id'], ['nick' => $administrator['nickname'], 'self' => true]); + $instance->contact_account = DI::mstdnAccount()->createFromContactId($adminContact['id']); } } diff --git a/src/Api/Entity/Mastodon/Relationship.php b/src/Api/Entity/Mastodon/Relationship.php new file mode 100644 index 0000000000..44a7d70c40 --- /dev/null +++ b/src/Api/Entity/Mastodon/Relationship.php @@ -0,0 +1,60 @@ +id = $userContactId; + $this->following = in_array($userContact['rel'] ?? 0, [Contact::SHARING, Contact::FRIEND]); + $this->followed_by = in_array($userContact['rel'] ?? 0, [Contact::FOLLOWER, Contact::FRIEND]); + $this->blocking = (bool)$userContact['blocked'] ?? false; + $this->muting = (bool)$userContact['readonly'] ?? false; + $this->muting_notifications = (bool)$userContact['readonly'] ?? false; + $this->requested = (bool)$userContact['pending'] ?? false; + $this->domain_blocking = Network::isUrlBlocked($userContact['url'] ?? ''); + + return $this; + } +} diff --git a/src/Api/Mastodon/Stats.php b/src/Api/Entity/Mastodon/Stats.php similarity index 81% rename from src/Api/Mastodon/Stats.php rename to src/Api/Entity/Mastodon/Stats.php index 895a58e060..219587830a 100644 --- a/src/Api/Mastodon/Stats.php +++ b/src/Api/Entity/Mastodon/Stats.php @@ -1,7 +1,8 @@ id = $publicContact['id']; - $account->username = $publicContact['nick']; - $account->acct = - strpos($publicContact['url'], $baseUrl->get() . '/') === 0 ? - $publicContact['nick'] : - $publicContact['addr']; - $account->display_name = $publicContact['name']; - $account->locked = !empty($apcontact['manually-approve']); - $account->created_at = DateTimeFormat::utc($publicContact['created'], DateTimeFormat::ATOM); - $account->followers_count = $apcontact['followers_count'] ?? 0; - $account->following_count = $apcontact['following_count'] ?? 0; - $account->statuses_count = $apcontact['statuses_count'] ?? 0; - $account->note = BBCode::convert($publicContact['about'], false); - $account->url = $publicContact['url']; - $account->avatar = $userContact['avatar'] ?? $publicContact['avatar']; - $account->avatar_static = $userContact['avatar'] ?? $publicContact['avatar']; - // No header picture in Friendica - $account->header = ''; - $account->header_static = ''; - // No custom emojis per account in Friendica - $account->emojis = []; - // No metadata fields in Friendica - $account->fields = []; - $account->bot = ($publicContact['contact-type'] == Contact::TYPE_NEWS); - $account->group = ($publicContact['contact-type'] == Contact::TYPE_COMMUNITY); - $account->discoverable = !$publicContact['unsearchable']; - - $publicContactLastItem = $publicContact['last-item'] ?: DBA::NULL_DATETIME; - $userContactLastItem = $userContact['last-item'] ?? DBA::NULL_DATETIME; - - $lastItem = $userContactLastItem > $publicContactLastItem ? $userContactLastItem : $publicContactLastItem; - $account->last_status_at = $lastItem != DBA::NULL_DATETIME ? DateTimeFormat::utc($lastItem, DateTimeFormat::ATOM) : null; - - return $account; - } -} diff --git a/src/Api/Mastodon/Emoji.php b/src/Api/Mastodon/Emoji.php deleted file mode 100644 index 0fda2d12cb..0000000000 --- a/src/Api/Mastodon/Emoji.php +++ /dev/null @@ -1,20 +0,0 @@ -id = $contact['id']; - $relationship->following = in_array($contact['rel'], [Contact::SHARING, Contact::FRIEND]); - $relationship->followed_by = in_array($contact['rel'], [Contact::FOLLOWER, Contact::FRIEND]); - $relationship->blocking = (bool)$contact['blocked']; - $relationship->muting = (bool)$contact['readonly']; - $relationship->muting_notifications = (bool)$contact['readonly']; - $relationship->requested = (bool)$contact['pending']; - $relationship->domain_blocking = Network::isUrlBlocked($contact['url']); - // Unsupported - $relationship->showing_reblogs = true; - // Unsupported - $relationship->endorsed = false; - - return $relationship; - } -} diff --git a/src/BaseCollection.php b/src/BaseCollection.php new file mode 100644 index 0000000000..8bad3b8d28 --- /dev/null +++ b/src/BaseCollection.php @@ -0,0 +1,38 @@ +models = $models; + $this->totalCount = $totalCount ?? count($models); + } + + /** + * @return int + */ + public function getTotalCount() + { + return $this->totalCount; + } +} diff --git a/src/BaseFactory.php b/src/BaseFactory.php new file mode 100644 index 0000000000..cfd6f9e717 --- /dev/null +++ b/src/BaseFactory.php @@ -0,0 +1,22 @@ +logger = $logger; + } +} diff --git a/src/BaseModel.php b/src/BaseModel.php index f8cef0c13e..055f9c4a19 100644 --- a/src/BaseModel.php +++ b/src/BaseModel.php @@ -7,8 +7,6 @@ use Friendica\Network\HTTPException; use Psr\Log\LoggerInterface; /** - * Class BaseModel - * * The Model classes inheriting from this abstract class are meant to represent a single database record. * The associated table name has to be provided in the child class, and the table is expected to have a unique `id` field. * @@ -16,8 +14,6 @@ use Psr\Log\LoggerInterface; */ abstract class BaseModel { - protected static $table_name; - /** @var Database */ protected $dba; /** @var LoggerInterface */ @@ -32,13 +28,33 @@ abstract class BaseModel */ private $data = []; - public function __construct(Database $dba, LoggerInterface $logger, $data = []) + /** + * @param Database $dba + * @param LoggerInterface $logger + * @param array $data Table row attributes + */ + public function __construct(Database $dba, LoggerInterface $logger, array $data = []) { $this->dba = $dba; $this->logger = $logger; $this->data = $data; } + /** + * Performance-improved model creation in a loop + * + * @param BaseModel $prototype + * @param array $data + * @return BaseModel + */ + public static function createFromPrototype(BaseModel $prototype, array $data) + { + $model = clone $prototype; + $model->data = $data; + + return $model; + } + /** * Magic getter. This allows to retrieve model fields with the following syntax: * - $model->field (outside of class) @@ -62,33 +78,16 @@ abstract class BaseModel } /** - * Fetches a single model record. The condition array is expected to contain a unique index (primary or otherwise). - * - * Chainable. - * - * @param array $condition - * @return BaseModel - * @throws HTTPException\NotFoundException + * @param string $name + * @param mixed $value */ - public function fetch(array $condition) + public function __set($name, $value) { - $data = $this->dba->selectFirst(static::$table_name, [], $condition); - - if (!$data) { - throw new HTTPException\NotFoundException(static::class . ' record not found.'); - } - - return new static($this->dba, $this->logger, $data); + $this->data[$name] = $value; } - /** - * Deletes the model record from the database. - * Prevents further methods from being called by wiping the internal model data. - */ - public function delete() + public function toArray() { - if ($this->dba->delete(static::$table_name, ['id' => $this->id])) { - $this->data = []; - } + return $this->data; } } diff --git a/src/BaseRepository.php b/src/BaseRepository.php new file mode 100644 index 0000000000..9f43d8fe14 --- /dev/null +++ b/src/BaseRepository.php @@ -0,0 +1,200 @@ +dba = $dba; + $this->logger = $logger; + } + + /** + * Fetches a single model record. The condition array is expected to contain a unique index (primary or otherwise). + * + * Chainable. + * + * @param array $condition + * @return BaseModel + * @throws HTTPException\NotFoundException + */ + public function selectFirst(array $condition) + { + $data = $this->dba->selectFirst(static::$table_name, [], $condition); + + if (!$data) { + throw new HTTPException\NotFoundException(static::class . ' record not found.'); + } + + return $this->create($data); + } + + /** + * Populates a Collection according to the condition. + * + * Chainable. + * + * @param array $condition + * @param array $params + * @return BaseCollection + * @throws \Exception + */ + public function select(array $condition = [], array $params = []) + { + $models = $this->selectModels($condition, $params); + + return new static::$collection_class($models); + } + + /** + * Populates the collection according to the condition. Retrieves a limited subset of models depending on the boundaries + * and the limit. The total count of rows matching the condition is stored in the collection. + * + * Chainable. + * + * @param array $condition + * @param array $params + * @param int? $max_id + * @param int? $since_id + * @param int $limit + * @return BaseCollection + * @throws \Exception + */ + public function selectByBoundaries(array $condition = [], array $params = [], int $max_id = null, int $since_id = null, int $limit = self::LIMIT) + { + $condition = DBA::collapseCondition($condition); + + $boundCondition = $condition; + + if (isset($max_id)) { + $boundCondition[0] .= " AND `id` < ?"; + $boundCondition[] = $max_id; + } + + if (isset($since_id)) { + $boundCondition[0] .= " AND `id` > ?"; + $boundCondition[] = $since_id; + } + + $params['limit'] = $limit; + + $models = $this->selectModels($boundCondition, $params); + + $totalCount = DBA::count(static::$table_name, $condition); + + return new static::$collection_class($models, $totalCount); + } + + /** + * This method updates the database row from the model. + * + * @param BaseModel $model + * @return bool + * @throws \Exception + */ + public function update(BaseModel $model) + { + return $this->dba->update(static::$table_name, $model->toArray(), ['id' => $model->id], true); + } + + /** + * This method creates a new database row and returns a model if it was successful. + * + * @param array $fields + * @return BaseModel|bool + * @throws \Exception + */ + public function insert(array $fields) + { + $return = $this->dba->insert(static::$table_name, $fields); + + if ($return) { + $fields['id'] = $this->dba->lastInsertId(); + $return = $this->create($fields); + } + + return $return; + } + + /** + * Deletes the model record from the database. + * + * @param BaseModel $model + * @return bool + * @throws \Exception + */ + public function delete(BaseModel &$model) + { + if ($success = $this->dba->delete(static::$table_name, ['id' => $model->id])) { + $model = null; + } + + return $success; + } + + /** + * Base instantiation method, can be overriden to add specific dependencies + * + * @param array $data + * @return BaseModel + */ + protected function create(array $data) + { + return new static::$model_class($this->dba, $this->logger, $data); + } + + /** + * @param array $condition Query condition + * @param array $params Additional query parameters + * @return BaseModel[] + * @throws \Exception + */ + protected function selectModels(array $condition, array $params = []) + { + $result = $this->dba->select(static::$table_name, [], $condition, $params); + + /** @var BaseModel $prototype */ + $prototype = null; + + $models = []; + + while ($record = $this->dba->fetch($result)) { + if ($prototype === null) { + $prototype = $this->create($record); + $models[] = $prototype; + } else { + $models[] = static::$model_class::createFromPrototype($prototype, $record); + } + } + + return $models; + } +} diff --git a/src/Collection/Introductions.php b/src/Collection/Introductions.php new file mode 100644 index 0000000000..0d4e319cb7 --- /dev/null +++ b/src/Collection/Introductions.php @@ -0,0 +1,14 @@ + Core\Process::class, 'session' => Core\Session\ISession::class, 'dba' => Database\Database::class, + 'mstdnAccount' => Factory\Mastodon\Account::class, + 'mstdnFollowRequest' => Factory\Mastodon\FollowRequest::class, + 'mstdnRelationship' => Factory\Mastodon\Relationship::class, 'cookie' => Model\User\Cookie::class, 'notify' => Model\Notify::class, - 'intro' => Model\Introduction::class, + 'intro' => Repository\Introduction::class, 'activity' => Protocol\Activity::class, 'aclFormatter' => Util\ACLFormatter::class, 'dtFormat' => Util\DateTimeFormat::class, diff --git a/src/Database/DBA.php b/src/Database/DBA.php index 1cf10ba4ad..9c607638b9 100644 --- a/src/Database/DBA.php +++ b/src/Database/DBA.php @@ -529,67 +529,96 @@ class DBA */ public static function buildCondition(array &$condition = []) { + $condition = self::collapseCondition($condition); + $condition_string = ''; if (count($condition) > 0) { - reset($condition); - $first_key = key($condition); - if (is_int($first_key)) { - $condition_string = " WHERE (" . array_shift($condition) . ")"; - } else { - $new_values = []; - $condition_string = ""; - foreach ($condition as $field => $value) { - if ($condition_string != "") { - $condition_string .= " AND "; - } - if (is_array($value)) { - if (count($value)) { - /* Workaround for MySQL Bug #64791. - * Never mix data types inside any IN() condition. - * In case of mixed types, cast all as string. - * Logic needs to be consistent with DBA::p() data types. - */ - $is_int = false; - $is_alpha = false; - foreach ($value as $single_value) { - if (is_int($single_value)) { - $is_int = true; - } else { - $is_alpha = true; - } - } - - if ($is_int && $is_alpha) { - foreach ($value as &$ref) { - if (is_int($ref)) { - $ref = (string)$ref; - } - } - unset($ref); //Prevent accidental re-use. - } - - $new_values = array_merge($new_values, array_values($value)); - $placeholders = substr(str_repeat("?, ", count($value)), 0, -2); - $condition_string .= self::quoteIdentifier($field) . " IN (" . $placeholders . ")"; - } else { - // Empty value array isn't supported by IN and is logically equivalent to no match - $condition_string .= "FALSE"; - } - } elseif (is_null($value)) { - $condition_string .= self::quoteIdentifier($field) . " IS NULL"; - } else { - $new_values[$field] = $value; - $condition_string .= self::quoteIdentifier($field) . " = ?"; - } - } - $condition_string = " WHERE (" . $condition_string . ")"; - $condition = $new_values; - } + $condition_string = " WHERE (" . array_shift($condition) . ")"; } return $condition_string; } + /** + * Collapse an associative array condition into a SQL string + parameters condition array. + * + * ['uid' => 1, 'network' => ['dspr', 'apub']] + * + * gets transformed into + * + * ["`uid` = ? AND `network` IN (?, ?)", 1, 'dspr', 'apub'] + * + * @param array $condition + * @return array + */ + public static function collapseCondition(array $condition) + { + // Ensures an always true condition is returned + if (count($condition) < 1) { + return ['1']; + } + + reset($condition); + $first_key = key($condition); + + if (is_int($first_key)) { + // Already collapsed + return $condition; + } + + $values = []; + $condition_string = ""; + foreach ($condition as $field => $value) { + if ($condition_string != "") { + $condition_string .= " AND "; + } + + if (is_array($value)) { + if (count($value)) { + /* Workaround for MySQL Bug #64791. + * Never mix data types inside any IN() condition. + * In case of mixed types, cast all as string. + * Logic needs to be consistent with DBA::p() data types. + */ + $is_int = false; + $is_alpha = false; + foreach ($value as $single_value) { + if (is_int($single_value)) { + $is_int = true; + } else { + $is_alpha = true; + } + } + + if ($is_int && $is_alpha) { + foreach ($value as &$ref) { + if (is_int($ref)) { + $ref = (string)$ref; + } + } + unset($ref); //Prevent accidental re-use. + } + + $values = array_merge($values, array_values($value)); + $placeholders = substr(str_repeat("?, ", count($value)), 0, -2); + $condition_string .= self::quoteIdentifier($field) . " IN (" . $placeholders . ")"; + } else { + // Empty value array isn't supported by IN and is logically equivalent to no match + $condition_string .= "FALSE"; + } + } elseif (is_null($value)) { + $condition_string .= self::quoteIdentifier($field) . " IS NULL"; + } else { + $values[$field] = $value; + $condition_string .= self::quoteIdentifier($field) . " = ?"; + } + } + + $condition = array_merge([$condition_string], array_values($values)); + + return $condition; + } + /** * @brief Returns the SQL parameter string built from the provided parameter array * diff --git a/src/Database/Database.php b/src/Database/Database.php index 1dd3524ed6..bd295fc9e4 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1327,10 +1327,6 @@ class Database return false; } - $table_string = DBA::buildTableString($table); - - $condition_string = DBA::buildCondition($condition); - if (is_bool($old_fields)) { $do_insert = $old_fields; @@ -1361,13 +1357,16 @@ class Database return true; } + $table_string = DBA::buildTableString($table); + + $condition_string = DBA::buildCondition($condition); + $sql = "UPDATE " . $table_string . " SET " . implode(" = ?, ", array_map([DBA::class, 'quoteIdentifier'], array_keys($fields))) . " = ?" . $condition_string; - $params1 = array_values($fields); - $params2 = array_values($condition); - $params = array_merge_recursive($params1, $params2); + // Combines the updated fields parameter values with the condition parameter values + $params = array_merge(array_values($fields), $condition); return $this->e($sql, $params); } diff --git a/src/Factory/Mastodon/Account.php b/src/Factory/Mastodon/Account.php new file mode 100644 index 0000000000..e38654b295 --- /dev/null +++ b/src/Factory/Mastodon/Account.php @@ -0,0 +1,46 @@ +baseUrl = $baseURL; + } + + /** + * @param int $contactId + * @param int $uid User Id + * @return \Friendica\Api\Entity\Mastodon\Account + * @throws HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public function createFromContactId(int $contactId, $uid = 0) + { + $cdata = Contact::getPublicAndUserContacID($contactId, $uid); + if (!empty($cdata)) { + $publicContact = Contact::getById($cdata['public']); + $userContact = Contact::getById($cdata['user']); + } else { + $publicContact = Contact::getById($contactId); + $userContact = []; + } + + $apcontact = APContact::getByURL($publicContact['url'], false); + + return new \Friendica\Api\Entity\Mastodon\Account($this->baseUrl, $publicContact, $apcontact, $userContact); + } +} diff --git a/src/Factory/Mastodon/FollowRequest.php b/src/Factory/Mastodon/FollowRequest.php new file mode 100644 index 0000000000..bbaa3135c9 --- /dev/null +++ b/src/Factory/Mastodon/FollowRequest.php @@ -0,0 +1,47 @@ +baseUrl = $baseURL; + } + + /** + * @param Introduction $introduction + * @return \Friendica\Api\Entity\Mastodon\FollowRequest + * @throws HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public function createFromIntroduction(Introduction $introduction) + { + $cdata = Contact::getPublicAndUserContacID($introduction->{'contact-id'}, $introduction->uid); + + if (empty($cdata)) { + $this->logger->warning('Wrong introduction data', ['Introduction' => $introduction]); + throw new HTTPException\InternalServerErrorException('Wrong introduction data'); + } + + $publicContact = Contact::getById($cdata['public']); + $userContact = Contact::getById($cdata['user']); + + $apcontact = APContact::getByURL($publicContact['url'], false); + + return new \Friendica\Api\Entity\Mastodon\FollowRequest($this->baseUrl, $introduction->id, $publicContact, $apcontact, $userContact); + } +} diff --git a/src/Factory/Mastodon/Relationship.php b/src/Factory/Mastodon/Relationship.php new file mode 100644 index 0000000000..25c9d4c8ce --- /dev/null +++ b/src/Factory/Mastodon/Relationship.php @@ -0,0 +1,38 @@ +createFromContact(Contact::getById($userContactId)); + } + + /** + * @param array $userContact Full contact row record with uid != 0 + * @return RelationshipEntity + */ + public function createFromContact(array $userContact) + { + return new RelationshipEntity($userContact['id'], $userContact); + } + + /** + * @param int $userContactId Contact row id with uid != 0 + * @return RelationshipEntity + */ + public function createDefaultFromContactId(int $userContactId) + { + return new RelationshipEntity($userContactId); + } +} diff --git a/src/Model/Introduction.php b/src/Model/Introduction.php index 127765c0cb..ee4f0e71dc 100644 --- a/src/Model/Introduction.php +++ b/src/Model/Introduction.php @@ -4,10 +4,13 @@ namespace Friendica\Model; use Friendica\BaseModel; use Friendica\Core\Protocol; +use Friendica\Database\Database; use Friendica\Network\HTTPException; use Friendica\Protocol\ActivityPub; use Friendica\Protocol\Diaspora; +use Friendica\Repository; use Friendica\Util\DateTimeFormat; +use Psr\Log\LoggerInterface; /** * @property int uid @@ -20,33 +23,40 @@ use Friendica\Util\DateTimeFormat; * @property string datetime * @property bool blocked * @property bool ignored - * - * @package Friendica\Model */ final class Introduction extends BaseModel { - static $table_name = 'intro'; + /** @var Repository\Introduction */ + protected $intro; + + public function __construct(Database $dba, LoggerInterface $logger, Repository\Introduction $intro, array $data = []) + { + parent::__construct($dba, $logger, $data); + + $this->intro = $intro; + } /** - * Confirms a follow request and sends a notic to the remote contact. + * Confirms a follow request and sends a notice to the remote contact. * - * @param bool $duplex Is it a follow back? - * @param bool|null $hidden Should this contact be hidden? null = no change + * @param bool $duplex Is it a follow back? + * @param bool|null $hidden Should this contact be hidden? null = no change + * @return bool * @throws HTTPException\InternalServerErrorException - * @throws \ImagickException * @throws HTTPException\NotFoundException + * @throws \ImagickException */ public function confirm(bool $duplex = false, bool $hidden = null) { $this->logger->info('Confirming follower', ['cid' => $this->{'contact-id'}]); - $contact = Contact::selectFirst([], ['id' => $this->{'contact-id'}, 'uid' => $this->uid]); + $contact = Model\Contact::selectFirst([], ['id' => $this->{'contact-id'}, 'uid' => $this->uid]); if (!$contact) { throw new HTTPException\NotFoundException('Contact record not found.'); } - $new_relation = $contact['rel']; + $newRelation = $contact['rel']; $writable = $contact['writable']; if (!empty($contact['protocol'])) { @@ -61,12 +71,12 @@ final class Introduction extends BaseModel if (in_array($protocol, [Protocol::DIASPORA, Protocol::ACTIVITYPUB])) { if ($duplex) { - $new_relation = Contact::FRIEND; + $newRelation = Model\Contact::FRIEND; } else { - $new_relation = Contact::FOLLOWER; + $newRelation = Model\Contact::FOLLOWER; } - if ($new_relation != Contact::FOLLOWER) { + if ($newRelation != Model\Contact::FOLLOWER) { $writable = 1; } } @@ -79,43 +89,42 @@ final class Introduction extends BaseModel 'protocol' => $protocol, 'writable' => $writable, 'hidden' => $hidden ?? $contact['hidden'], - 'rel' => $new_relation, + 'rel' => $newRelation, ]; $this->dba->update('contact', $fields, ['id' => $contact['id']]); array_merge($contact, $fields); - if ($new_relation == Contact::FRIEND) { + if ($newRelation == Model\Contact::FRIEND) { if ($protocol == Protocol::DIASPORA) { - $ret = Diaspora::sendShare(User::getById($contact['uid']), $contact); + $ret = Diaspora::sendShare(Model\Contact::getById($contact['uid']), $contact); $this->logger->info('share returns', ['return' => $ret]); } elseif ($protocol == Protocol::ACTIVITYPUB) { ActivityPub\Transmitter::sendActivity('Follow', $contact['url'], $contact['uid']); } } - $this->delete(); + return $this->intro->delete($this); } /** * Silently ignores the introduction, hides it from notifications and prevents the remote contact from submitting * additional follow requests. * - * Chainable - * - * @return Introduction + * @return bool * @throws \Exception */ public function ignore() { - $this->dba->update('intro', ['ignore' => true], ['id' => $this->id]); + $this->ignored = true; - return $this; + return $this->intro->update($this); } /** * Discards the introduction and sends a rejection message to AP contacts. * + * @return bool * @throws HTTPException\InternalServerErrorException * @throws HTTPException\NotFoundException * @throws \ImagickException @@ -127,15 +136,15 @@ final class Introduction extends BaseModel if (!$this->fid) { // When the contact entry had been created just for that intro, we want to get rid of it now $condition = ['id' => $this->{'contact-id'}, 'uid' => $this->uid, - 'self' => false, 'pending' => true, 'rel' => [0, Contact::FOLLOWER]]; + 'self' => false, 'pending' => true, 'rel' => [0, Model\Contact::FOLLOWER]]; if ($this->dba->exists('contact', $condition)) { - Contact::remove($this->{'contact-id'}); + Model\Contact::remove($this->{'contact-id'}); } else { $this->dba->update('contact', ['pending' => false], ['id' => $this->{'contact-id'}]); } } - $contact = Contact::selectFirst([], ['id' => $this->{'contact-id'}, 'uid' => $this->uid]); + $contact = Model\Contact::selectFirst([], ['id' => $this->{'contact-id'}, 'uid' => $this->uid]); if (!$contact) { throw new HTTPException\NotFoundException('Contact record not found.'); @@ -151,6 +160,6 @@ final class Introduction extends BaseModel ActivityPub\Transmitter::sendContactReject($contact['url'], $contact['hub-verify'], $contact['uid']); } - $this->delete(); + return $this->intro->delete($this); } } diff --git a/src/Module/Api/Mastodon/FollowRequests.php b/src/Module/Api/Mastodon/FollowRequests.php index e31f023cde..fc384f7979 100644 --- a/src/Module/Api/Mastodon/FollowRequests.php +++ b/src/Module/Api/Mastodon/FollowRequests.php @@ -2,18 +2,16 @@ namespace Friendica\Module\Api\Mastodon; -use Friendica\Api\Mastodon; +use Friendica\Api\Entity\Mastodon; +use Friendica\Api\Entity\Mastodon\Relationship; use Friendica\Core\System; -use Friendica\Database\DBA; -use Friendica\Model\APContact; use Friendica\DI; use Friendica\Model\Contact; -use Friendica\Model\Introduction; use Friendica\Module\Base\Api; use Friendica\Network\HTTPException; /** - * @see https://docs.joinmastodon.org/api/rest/follow-requests/ + * @see https://docs.joinmastodon.org/methods/accounts/follow_requests */ class FollowRequests extends Api { @@ -30,8 +28,11 @@ class FollowRequests extends Api * @param array $parameters * @throws HTTPException\BadRequestException * @throws HTTPException\ForbiddenException + * @throws HTTPException\InternalServerErrorException * @throws HTTPException\NotFoundException * @throws HTTPException\UnauthorizedException + * @throws \ImagickException + * * @see https://docs.joinmastodon.org/methods/accounts/follow_requests#accept-follow * @see https://docs.joinmastodon.org/methods/accounts/follow_requests#reject-follow */ @@ -39,23 +40,25 @@ class FollowRequests extends Api { parent::post($parameters); - $Intro = DI::intro()->fetch(['id' => $parameters['id'], 'uid' => self::$current_user_id]); + $introduction = DI::intro()->selectFirst(['id' => $parameters['id'], 'uid' => self::$current_user_id]); - $contactId = $Intro->{'contact-id'}; - - $relationship = new Mastodon\Relationship(); - $relationship->id = $contactId; + $contactId = $introduction->{'contact-id'}; switch ($parameters['action']) { case 'authorize': - $Intro->confirm(); - $relationship = Mastodon\Relationship::createFromContact(Contact::getById($contactId)); + $introduction->confirm(); + + $relationship = DI::mstdnRelationship()->createFromContactId($contactId); break; case 'ignore': - $Intro->ignore(); + $introduction->ignore(); + + $relationship = DI::mstdnRelationship()->createDefaultFromContactId($contactId); break; case 'reject': - $Intro->discard(); + $introduction->discard(); + + $relationship = DI::mstdnRelationship()->createDefaultFromContactId($contactId); break; default: throw new HTTPException\BadRequestException('Unexpected action parameter, expecting "authorize", "ignore" or "reject"'); @@ -78,41 +81,23 @@ class FollowRequests extends Api $baseUrl = DI::baseUrl(); - if (isset($since_id) && isset($max_id)) { - $condition = ['`uid` = ? AND NOT `ignore` AND `id` > ? AND `id` < ?', self::$current_user_id, $since_id, $max_id]; - } elseif (isset($since_id)) { - $condition = ['`uid` = ? AND NOT `ignore` AND `id` > ?', self::$current_user_id, $since_id]; - } elseif (isset($max_id)) { - $condition = ['`uid` = ? AND NOT `ignore` AND `id` < ?', self::$current_user_id, $max_id]; - } else { - $condition = ['`uid` = ? AND NOT `ignore`', self::$current_user_id]; - } - - $count = DBA::count('intro', $condition); - - $intros = DBA::selectToArray( - 'intro', - [], - $condition, - ['order' => ['id' => 'DESC'], 'limit' => $limit] + $introductions = DI::intro()->selectByBoundaries( + ['`uid` = ? AND NOT `ignore`', self::$current_user_id], + ['order' => ['id' => 'DESC']], + $since_id, + $max_id, + $limit ); $return = []; - foreach ($intros as $intro) { - $cdata = Contact::getPublicAndUserContacID($intro['contact-id'], $intro['uid']); - if (empty($cdata['public'])) { - continue; + + foreach ($introductions as $key => $introduction) { + try { + $return[] = DI::mstdnFollowRequest()->createFromIntroduction($introduction); + } catch (HTTPException\InternalServerErrorException $exception) { + DI::intro()->delete($introduction); + unset($introductions[$key]); } - - $publicContact = Contact::getById($cdata['public']); - $userContact = Contact::getById($cdata['user']); - $apcontact = APContact::getByURL($publicContact['url'], false); - $account = Mastodon\Account::create($baseUrl, $publicContact, $apcontact, $userContact); - - // Not ideal, the same "account" can have multiple ids depending on the context - $account->id = $intro['id']; - - $return[] = $account; } $base_query = []; @@ -121,10 +106,10 @@ class FollowRequests extends Api } $links = []; - if ($count > $limit) { - $links[] = '<' . $baseUrl->get() . '/api/v1/follow_requests?' . http_build_query($base_query + ['max_id' => $intros[count($intros) - 1]['id']]) . '>; rel="next"'; + if ($introductions->getTotalCount() > $limit) { + $links[] = '<' . $baseUrl->get() . '/api/v1/follow_requests?' . http_build_query($base_query + ['max_id' => $introductions[count($introductions) - 1]->id]) . '>; rel="next"'; } - $links[] = '<' . $baseUrl->get() . '/api/v1/follow_requests?' . http_build_query($base_query + ['since_id' => $intros[0]['id']]) . '>; rel="prev"'; + $links[] = '<' . $baseUrl->get() . '/api/v1/follow_requests?' . http_build_query($base_query + ['since_id' => $introductions[0]->id]) . '>; rel="prev"'; header('Link: ' . implode(', ', $links)); diff --git a/src/Module/Api/Mastodon/Instance.php b/src/Module/Api/Mastodon/Instance.php index 96d4db3302..cc7639f460 100644 --- a/src/Module/Api/Mastodon/Instance.php +++ b/src/Module/Api/Mastodon/Instance.php @@ -2,7 +2,7 @@ namespace Friendica\Module\Api\Mastodon; -use Friendica\Api\Mastodon\Instance as InstanceEntity; +use Friendica\Api\Entity\Mastodon\Instance as InstanceEntity; use Friendica\Core\System; use Friendica\Module\Base\Api; diff --git a/src/Module/FollowConfirm.php b/src/Module/FollowConfirm.php index e54032c850..2da2685b6d 100644 --- a/src/Module/FollowConfirm.php +++ b/src/Module/FollowConfirm.php @@ -23,11 +23,11 @@ class FollowConfirm extends BaseModule $duplex = intval($_POST['duplex'] ?? 0); $hidden = intval($_POST['hidden'] ?? 0); - $Intro = DI::intro()->fetch(['id' => $intro_id, 'uid' => local_user()]); + $intro = DI::intro()->selectFirst(['id' => $intro_id, 'uid' => local_user()]); - $cid = $Intro->{'contact-id'}; + $cid = $intro->{'contact-id'}; - $Intro->confirm($duplex, $hidden); + $intro->confirm($duplex, $hidden); DI::baseUrl()->redirect('contact/' . intval($cid)); } diff --git a/src/Repository/Introduction.php b/src/Repository/Introduction.php new file mode 100644 index 0000000000..65c2e1ebc7 --- /dev/null +++ b/src/Repository/Introduction.php @@ -0,0 +1,60 @@ +dba, $this->logger, $this, $data); + } + + /** + * @param array $condition + * @return Model\Introduction + * @throws \Friendica\Network\HTTPException\NotFoundException + */ + public function selectFirst(array $condition) + { + return parent::selectFirst($condition); + } + + /** + * @param array $condition + * @param array $params + * @return Collection\Introductions + * @throws \Exception + */ + public function select(array $condition = [], array $params = []) + { + return parent::select($condition, $params); + } + + /** + * @param array $condition + * @param array $params + * @param int|null $max_id + * @param int|null $since_id + * @param int $limit + * @return Collection\Introductions + * @throws \Exception + */ + public function selectByBoundaries(array $condition = [], array $params = [], int $max_id = null, int $since_id = null, int $limit = self::LIMIT) + { + return parent::selectByBoundaries($condition, $params, $max_id, $since_id, $limit); + } +}