From ccd51bba476102eb3b3a100aad4bbab365036621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9F=D0=BE=D0=BF=D0=BE=D0=B2?= Date: Mon, 13 Apr 2026 13:05:11 +0300 Subject: [PATCH 1/7] db connection --- .../Connection/DatabaseQueryExecutor.php | 41 +++++++++++++++++++ .../Database/Connection/PDOWrapper.php | 32 +++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 src/Infrastructure/Database/Connection/DatabaseQueryExecutor.php create mode 100644 src/Infrastructure/Database/Connection/PDOWrapper.php diff --git a/src/Infrastructure/Database/Connection/DatabaseQueryExecutor.php b/src/Infrastructure/Database/Connection/DatabaseQueryExecutor.php new file mode 100644 index 00000000..8a598795 --- /dev/null +++ b/src/Infrastructure/Database/Connection/DatabaseQueryExecutor.php @@ -0,0 +1,41 @@ +dbh = PDOWrapper::getHandler(); + $this->dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + } + + public function queryRows(string $sql, array $params = []): array + { + $sth = $this->dbh->prepare($sql); + $sth->execute($params); + + return $sth->fetchAll(\PDO::FETCH_ASSOC); + } + + public function queryRow(string $sql, array $params = []): array + { + $rows = $this->queryRows($sql, $params); + + return !empty($rows) ? $rows[0] : []; + } + + public function execute(string $sql, array $params = []): bool + { + $sth = $this->dbh->prepare($sql); + + return $sth->execute($params); + } + + public function getLastInsertId(string $sequenceName): int + { + return (int) $this->dbh->lastInsertId($sequenceName); + } +} diff --git a/src/Infrastructure/Database/Connection/PDOWrapper.php b/src/Infrastructure/Database/Connection/PDOWrapper.php new file mode 100644 index 00000000..bacfb03f --- /dev/null +++ b/src/Infrastructure/Database/Connection/PDOWrapper.php @@ -0,0 +1,32 @@ +getEnv('POSTGRES_HOST'); + $dbName = $dotEnvLoader->getEnv('POSTGRES_DB'); + $user = $dotEnvLoader->getEnv('POSTGRES_USER'); + $password = $dotEnvLoader->getEnv('POSTGRES_PASSWORD'); + + self::$dbh = new \PDO("pgsql:host=$host;dbname=$dbName", $user, $password); + } + + public static function getHandler(): \PDO + { + if (self::$dbInstance === null) { + self::$dbInstance = new self(); + } + + return self::$dbInstance::$dbh; + } +} From 8383c853cbd773236e337f97dd5de57e6a3219d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9F=D0=BE=D0=BF=D0=BE=D0=B2?= Date: Mon, 13 Apr 2026 15:57:12 +0300 Subject: [PATCH 2/7] create users table command --- .../Command/CreateUsersTableCommand.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/Controller/Command/CreateUsersTableCommand.php diff --git a/src/Controller/Command/CreateUsersTableCommand.php b/src/Controller/Command/CreateUsersTableCommand.php new file mode 100644 index 00000000..9975b4bd --- /dev/null +++ b/src/Controller/Command/CreateUsersTableCommand.php @@ -0,0 +1,31 @@ +prepare($sql)->execute(); + + $output->writeln('Таблица пользователей успешно создана.'); + + return Command::SUCCESS; + } +} From e53573a683f8d1bb47380e272110965c443644f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9F=D0=BE=D0=BF=D0=BE=D0=B2?= Date: Mon, 13 Apr 2026 16:07:01 +0300 Subject: [PATCH 3/7] user entity --- src/Domain/Entity/AbstractEntity.php | 25 +++++++++++ src/Domain/Entity/EntityInterface.php | 10 +++++ src/Domain/Entity/User.php | 61 +++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 src/Domain/Entity/AbstractEntity.php create mode 100644 src/Domain/Entity/EntityInterface.php create mode 100644 src/Domain/Entity/User.php diff --git a/src/Domain/Entity/AbstractEntity.php b/src/Domain/Entity/AbstractEntity.php new file mode 100644 index 00000000..9078bf23 --- /dev/null +++ b/src/Domain/Entity/AbstractEntity.php @@ -0,0 +1,25 @@ +id; + } + + public function toArray(): array + { + $reflectionProperties = new \ReflectionObject($this)->getProperties(); + + $properties = []; + foreach ($reflectionProperties as $reflectionProperty) { + $properties[$reflectionProperty->getName()] = $reflectionProperty->getValue($this); + } + + return array_replace(['id' => $this->getId()], $properties); + } +} diff --git a/src/Domain/Entity/EntityInterface.php b/src/Domain/Entity/EntityInterface.php new file mode 100644 index 00000000..004d7822 --- /dev/null +++ b/src/Domain/Entity/EntityInterface.php @@ -0,0 +1,10 @@ +firstName; + } + + public function getLastName(): string + { + return $this->lastName; + } + + public function getEmail(): string + { + return $this->email; + } + + public function setEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + public function getBirthDate(): \DateTimeImmutable + { + return $this->birthDate; + } + + public function getFullName(): string + { + return $this->getFirstName() . ' ' . $this->getLastName(); + } + + public function getAge(): int + { + return new \DateTime()->diff($this->getBirthDate())->y; + } + + public function toArray(): array + { + $output = parent::toArray(); + $output['fullName'] = $this->getFullName(); + $output['birthDate'] = $this->getBirthDate()->format('d.m.Y'); + $output['age'] = $this->getAge(); + + return $output; + } +} From 3e9f71da4721e81c3275362b5bb91308960a80bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9F=D0=BE=D0=BF=D0=BE=D0=B2?= Date: Mon, 13 Apr 2026 16:08:49 +0300 Subject: [PATCH 4/7] data mapper --- .../Database/DataMapper/AbstractMapper.php | 20 +++++++++ .../DataMapper/DataMapperInterface.php | 14 +++++++ .../Database/DataMapper/UserMapper.php | 41 +++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 src/Infrastructure/Database/DataMapper/AbstractMapper.php create mode 100644 src/Infrastructure/Database/DataMapper/DataMapperInterface.php create mode 100644 src/Infrastructure/Database/DataMapper/UserMapper.php diff --git a/src/Infrastructure/Database/DataMapper/AbstractMapper.php b/src/Infrastructure/Database/DataMapper/AbstractMapper.php new file mode 100644 index 00000000..8a9745dd --- /dev/null +++ b/src/Infrastructure/Database/DataMapper/AbstractMapper.php @@ -0,0 +1,20 @@ +getProperty('id'); + if ($entityIdReflectionProperty->getValue($entity) === null) { + $entityIdReflectionProperty->setValue($entity, $id); + } + } catch (\ReflectionException $e) { + throw new \RuntimeException($e->getMessage()); + } + } +} diff --git a/src/Infrastructure/Database/DataMapper/DataMapperInterface.php b/src/Infrastructure/Database/DataMapper/DataMapperInterface.php new file mode 100644 index 00000000..7641929f --- /dev/null +++ b/src/Infrastructure/Database/DataMapper/DataMapperInterface.php @@ -0,0 +1,14 @@ +hydrateId($user, $row['id']); + + return $user; + } + + /** + * @param User $entity + */ + public function mapEntityToRow(EntityInterface $entity): array + { + if (!$entity instanceof User) { + throw new \RuntimeException('Ожидается объект класса ' . User::class); + } + + return [ + 'id' => $entity->getId(), + 'first_name' => $entity->getFirstName(), + 'last_name' => $entity->getLastName(), + 'email' => $entity->getEmail(), + 'birth_date' => $entity->getBirthDate()->format('Y-m-d'), + ]; + } +} From fc12045bd2c9627ffaabbba3df7e43b15350f6f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9F=D0=BE=D0=BF=D0=BE=D0=B2?= Date: Mon, 13 Apr 2026 16:10:31 +0300 Subject: [PATCH 5/7] user collection --- src/Domain/Collection/AbstractCollection.php | 37 ++++++++++++++++++++ src/Domain/Collection/UserCollection.php | 21 +++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/Domain/Collection/AbstractCollection.php create mode 100644 src/Domain/Collection/UserCollection.php diff --git a/src/Domain/Collection/AbstractCollection.php b/src/Domain/Collection/AbstractCollection.php new file mode 100644 index 00000000..aba3bbda --- /dev/null +++ b/src/Domain/Collection/AbstractCollection.php @@ -0,0 +1,37 @@ +items); + } + + public function count(): int + { + return count($this->items); + } + + public function isEmpty(): bool + { + return empty($this->items); + } + + public function toArray(): array + { + return $this->items; + } + + protected function addEntity(EntityInterface $entity): void + { + $this->items[] = $entity; + } +} diff --git a/src/Domain/Collection/UserCollection.php b/src/Domain/Collection/UserCollection.php new file mode 100644 index 00000000..8ebd2da3 --- /dev/null +++ b/src/Domain/Collection/UserCollection.php @@ -0,0 +1,21 @@ +addEntity($entity); + } +} From 160dd49d1d3a4edbad8ca70a08e62b34964fe171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9F=D0=BE=D0=BF=D0=BE=D0=B2?= Date: Mon, 13 Apr 2026 16:17:57 +0300 Subject: [PATCH 6/7] CRUD for users --- config/routes.php | 5 + http/users/create_user.http | 13 ++ http/users/delete_user.http | 4 + http/users/get_user.http | 4 + http/users/get_users.http | 4 + http/users/update_user_email.http | 8 ++ src/Controller/Web/User/CreateUserDTO.php | 31 +++++ src/Controller/Web/User/UserController.php | 130 ++++++++++++++++++ src/Domain/Model/CreateUserModel.php | 103 ++++++++++++++ src/Domain/Model/UpdateUserEmailModel.php | 43 ++++++ src/Domain/Service/UserService.php | 61 ++++++++ .../Repository/AbstractRepository.php | 121 ++++++++++++++++ .../Database/Repository/UserRepository.php | 29 ++++ 13 files changed, 556 insertions(+) create mode 100644 http/users/create_user.http create mode 100644 http/users/delete_user.http create mode 100644 http/users/get_user.http create mode 100644 http/users/get_users.http create mode 100644 http/users/update_user_email.http create mode 100644 src/Controller/Web/User/CreateUserDTO.php create mode 100644 src/Controller/Web/User/UserController.php create mode 100644 src/Domain/Model/CreateUserModel.php create mode 100644 src/Domain/Model/UpdateUserEmailModel.php create mode 100644 src/Domain/Service/UserService.php create mode 100644 src/Infrastructure/Database/Repository/AbstractRepository.php create mode 100644 src/Infrastructure/Database/Repository/UserRepository.php diff --git a/config/routes.php b/config/routes.php index 0bd98744..4b606b73 100644 --- a/config/routes.php +++ b/config/routes.php @@ -9,4 +9,9 @@ ['POST', '/add-event', \App\Controller\Web\EventController::class, 'addEvent'], ['GET', '/get-event', \App\Controller\Web\EventController::class, 'getEvent'], ['DELETE', '/delete-events', \App\Controller\Web\EventController::class, 'deleteEvents'], + ['GET', '/get-user', \App\Controller\Web\User\UserController::class, 'getUser'], + ['GET', '/get-users', \App\Controller\Web\User\UserController::class, 'getUsers'], + ['POST', '/create-user', \App\Controller\Web\User\UserController::class, 'createUser'], + ['PATCH', '/update-user-email', \App\Controller\Web\User\UserController::class, 'updateUserEmail'], + ['DELETE', '/delete-user', \App\Controller\Web\User\UserController::class, 'deleteUser'], ]; diff --git a/http/users/create_user.http b/http/users/create_user.http new file mode 100644 index 00000000..cdb99225 --- /dev/null +++ b/http/users/create_user.http @@ -0,0 +1,13 @@ +POST http://localhost:{{nginxPublishedPort}}/create-user +Content-Type: application/json +Accept: application/json +Cache-Control: no-cache + +{ + "createUser": { + "firstName": "Иван", + "lastName": "Иванов", + "email": "i.ivanov@gmail.com", + "birthDate": "2005-03-05" + } +} diff --git a/http/users/delete_user.http b/http/users/delete_user.http new file mode 100644 index 00000000..1e799bff --- /dev/null +++ b/http/users/delete_user.http @@ -0,0 +1,4 @@ +DELETE http://localhost:{{nginxPublishedPort}}/delete-user?id=1 +Content-Type: application/json +Accept: application/json +Cache-Control: no-cache diff --git a/http/users/get_user.http b/http/users/get_user.http new file mode 100644 index 00000000..8f3242ee --- /dev/null +++ b/http/users/get_user.http @@ -0,0 +1,4 @@ +GET http://localhost:{{nginxPublishedPort}}/get-user?id=1 +Content-Type: application/json +Accept: application/json +Cache-Control: no-cache diff --git a/http/users/get_users.http b/http/users/get_users.http new file mode 100644 index 00000000..aa634223 --- /dev/null +++ b/http/users/get_users.http @@ -0,0 +1,4 @@ +GET http://localhost:{{nginxPublishedPort}}/get-users +Content-Type: application/json +Accept: application/json +Cache-Control: no-cache diff --git a/http/users/update_user_email.http b/http/users/update_user_email.http new file mode 100644 index 00000000..181443a1 --- /dev/null +++ b/http/users/update_user_email.http @@ -0,0 +1,8 @@ +PATCH http://localhost:{{nginxPublishedPort}}/update-user-email?id=1 +Content-Type: application/json +Accept: application/json +Cache-Control: no-cache + +{ + "newEmail": "i.ivanov@mail.ru" +} diff --git a/src/Controller/Web/User/CreateUserDTO.php b/src/Controller/Web/User/CreateUserDTO.php new file mode 100644 index 00000000..b94f7931 --- /dev/null +++ b/src/Controller/Web/User/CreateUserDTO.php @@ -0,0 +1,31 @@ +userService = new UserService(); + parent::__construct(); + } + + public function createUser(): Response + { + try { + $payload = $this->request->getPayload(); + $createUser = CreateUserDTO::fromArray($payload); + $createdUser = $this->userService->createUser( + new CreateUserModel( + firstName: $createUser->firstName, + lastName: $createUser->lastName, + email: $createUser->email, + birthDate: $createUser->birthDate, + ), + ); + $data['success'] = true; + $data['user'] = $createdUser->toArray(); + $httpCode = 200; + } catch (MultiException $validationErrors) { + $data['success'] = false; + $errors = []; + foreach ($validationErrors as $error) { + $errors[] = $error->getMessage(); + } + $data['message'] = $validationErrors->getMessage(); + $data['details'] = $errors; + $httpCode = $validationErrors->getCode(); + } catch (\Throwable $e) { + $data['success'] = false; + $data['message'] = $e->getMessage(); + $httpCode = $e->getCode(); + } + + return new Response(json_encode($data), $httpCode, ['Content-Type: application/json; charset=utf-8']); + } + + public function getUser(): Response + { + try { + $id = $this->request->getQueryParam('id'); + $id = is_numeric($id) ? (int) $id : throw new \Exception('Пользователь не найден.', 404); + $user = $this->userService->findUser($id); + $data['success'] = true; + $data['user'] = $user->toArray(); + $httpCode = 200; + } catch (\Throwable $e) { + $data['success'] = false; + $data['message'] = $e->getMessage(); + $httpCode = $e->getCode(); + } + + return new Response(json_encode($data), $httpCode, ['Content-Type: application/json; charset=utf-8']); + } + + public function getUsers(): Response + { + $users = array_map( + static fn(User $user) => $user->toArray(), + $this->userService->findUsers(), + ); + + return new Response( + json_encode([ + 'success' => true, + 'users' => $users, + ]), + headers: ['Content-Type: application/json; charset=utf-8'], + ); + } + + public function updateUserEmail(): Response + { + try { + $id = $this->request->getQueryParam('id'); + $id = is_numeric($id) ? (int) $id : throw new \Exception('Пользователь не найден.', 404); + $newEmail = $this->request->getPayload()['newEmail'] + ?? throw new \Exception('Неверное тело запроса.', 400); + $updatedUser = $this->userService->updateUserEmail( + new UpdateUserEmailModel(userId: $id, newEmail: $newEmail) + ); + $data['success'] = true; + $data['user'] = $updatedUser->toArray(); + $httpCode = 200; + } catch (\Throwable $e) { + $data['success'] = false; + $data['message'] = $e->getMessage(); + $httpCode = $e->getCode(); + } + + return new Response(json_encode($data), $httpCode, ['Content-Type: application/json; charset=utf-8']); + } + + public function deleteUser(): Response + { + try { + $id = $this->request->getQueryParam('id'); + $id = is_numeric($id) ? (int) $id : throw new \Exception('Пользователь не найден.', 404); + $this->userService->deleteUser($id); + $data['success'] = true; + $data['message'] = 'Пользователь успешно удалён.'; + $httpCode = 200; + } catch (\Throwable $e) { + $data['success'] = false; + $data['message'] = $e->getMessage(); + $httpCode = $e->getCode(); + } + + return new Response(json_encode($data), $httpCode, ['Content-Type: application/json; charset=utf-8']); + } +} diff --git a/src/Domain/Model/CreateUserModel.php b/src/Domain/Model/CreateUserModel.php new file mode 100644 index 00000000..1d82f798 --- /dev/null +++ b/src/Domain/Model/CreateUserModel.php @@ -0,0 +1,103 @@ +validationErrors = new MultiException('Некорректные данные от пользователя.', 400); + + $this->setFirstName($firstName); + $this->setLastName($lastName); + $this->setEmail($email); + $this->setBirthDate($birthDate); + + if ($this->validationErrors->count() !== 0) { + throw $this->validationErrors; + } + } + + public function getFirstName(): string + { + return $this->firstName; + } + + public function setFirstName(string $firstName): void + { + if (mb_strlen($firstName) < 2) { + $this->validationErrors->add(new \Exception('Имя пользователя должно быть от 2 символов.')); + return; + } + + $this->firstName = $firstName; + } + + public function getLastName(): string + { + return $this->lastName; + } + + public function setLastName(string $lastName): void + { + if (mb_strlen($lastName) < 2) { + $this->validationErrors->add(new \Exception('Фамилия пользователя должна быть от 2 символов.')); + return; + } + + $this->lastName = $lastName; + } + + public function getEmail(): string + { + return $this->email; + } + + public function setEmail(string $email): void + { + $isValid = new EmailValidator( + new EmailFormatValidator(), + new DnsMxRecordValidator(), + )->isValid($email); + + if (!$isValid) { + $this->validationErrors->add(new \Exception('Пользователь указал невалидный email.')); + return; + } + + $this->email = $email; + } + + public function getBirthDate(): \DateTimeImmutable + { + return $this->birthDate; + } + + public function setBirthDate(\DateTimeImmutable $birthDate): void + { + if (new \DateTime('-18 years')->format('Y-m-d') < $birthDate->format('Y-m-d')) { + $this->validationErrors->add(new \Exception('Пользователь должен быть 18+.')); + return; + } + + $this->birthDate = $birthDate; + } +} diff --git a/src/Domain/Model/UpdateUserEmailModel.php b/src/Domain/Model/UpdateUserEmailModel.php new file mode 100644 index 00000000..da062824 --- /dev/null +++ b/src/Domain/Model/UpdateUserEmailModel.php @@ -0,0 +1,43 @@ +setNewEmail($newEmail); + } + + public function getUserId(): int + { + return $this->userId; + } + + public function getNewEmail(): string + { + return $this->newEmail; + } + + public function setNewEmail(string $newEmail): void + { + $isValid = new EmailValidator( + new EmailFormatValidator(), + new DnsMxRecordValidator() + )->isValid($newEmail); + + if (!$isValid) { + throw new \Exception('Пользователь указал невалидный email.'); + } + + $this->newEmail = $newEmail; + } +} diff --git a/src/Domain/Service/UserService.php b/src/Domain/Service/UserService.php new file mode 100644 index 00000000..352b6173 --- /dev/null +++ b/src/Domain/Service/UserService.php @@ -0,0 +1,61 @@ +userRepository = new UserRepository(); + } + + public function createUser(CreateUserModel $createUserModel): User + { + $user = new User( + firstName: $createUserModel->getFirstName(), + lastName: $createUserModel->getLastName(), + email: $createUserModel->getEmail(), + birthDate: $createUserModel->getBirthDate(), + ); + + return $this->userRepository->save($user); + } + + public function findUser(int $id): User + { + $user = $this->userRepository->find($id); + if ($user === null) { + throw new \Exception('Пользователь не найден.', 404); + } + + return $user; + } + + public function findUsers(): array + { + return $this->userRepository->findAll()->toArray(); + } + + public function updateUserEmail(UpdateUserEmailModel $updateUserModel): User + { + $user = $this + ->findUser($updateUserModel->getUserId()) + ->setEmail($updateUserModel->getNewEmail()); + + return $this->userRepository->save($user); + } + + public function deleteUser(int $id): bool + { + $user = $this->findUser($id); + + return $this->userRepository->delete($user); + } +} diff --git a/src/Infrastructure/Database/Repository/AbstractRepository.php b/src/Infrastructure/Database/Repository/AbstractRepository.php new file mode 100644 index 00000000..d3d8b2e1 --- /dev/null +++ b/src/Infrastructure/Database/Repository/AbstractRepository.php @@ -0,0 +1,121 @@ +dbQueryExecutor = new DatabaseQueryExecutor(); + $this->dataMapper = new ($this->getDataMapperClassName()); + } + + public function find(int $id): ?EntityInterface + { + $sql = 'SELECT * FROM ' . $this->getTableName() . ' WHERE id=:id'; + $row = $this->dbQueryExecutor->queryRow($sql, [':id' => $id]); + + return !empty($row) ? $this->createEntityFromRow($row) : null; + } + + public function findAll(): AbstractCollection + { + $sql = 'SELECT * FROM ' . $this->getTableName(); + $rows = $this->dbQueryExecutor->queryRows($sql); + + /** @var AbstractCollection $collection */ + $collection = new ($this->getCollectionClassName()); + foreach ($rows as $row) { + $collection->add($this->createEntityFromRow($row)); + } + + return $collection; + } + + public function save(EntityInterface $entity): EntityInterface + { + return $entity->getId() === null ? $this->insert($entity) : $this->update($entity); + } + + public function delete(EntityInterface $entity): bool + { + $sql = 'DELETE FROM ' . $this->getTableName() . ' WHERE id=:id'; + + return $this->dbQueryExecutor->execute($sql, [':id' => $entity->getId()]); + } + + protected function insert(EntityInterface $entity): EntityInterface + { + $params = []; + $placeholders = []; + foreach ($this->dataMapper->mapEntityToRow($entity) as $column => $value) { + if ($column === 'id') { + continue; + } + $params[':' . $column] = $value; + $placeholders[$column] = ':' . $column; + } + + $sql = sprintf( + 'INSERT INTO %s (%s) VALUES (%s)', + $this->getTableName(), + implode(', ', array_keys($placeholders)), + implode(', ', array_values($placeholders)), + ); + + $result = $this->dbQueryExecutor->execute($sql, $params); + if ($result === false) { + throw new \RuntimeException('Не удалось добавить объект в БД.'); + } + + $id = $this->dbQueryExecutor->getLastInsertId($this->getSequenceName()); + $this->dataMapper->hydrateId($entity, $id); + + return $entity; + } + + protected function update(EntityInterface $entity): EntityInterface + { + $params = []; + $placeholders = []; + foreach ($this->dataMapper->mapEntityToRow($entity) as $column => $value) { + $params[':' . $column] = $value; + $placeholders[$column] = $column . '=:' . $column; + } + + $idPlaceholder = $placeholders['id']; + unset($placeholders['id']); + + $sql = sprintf( + 'UPDATE %s SET %s WHERE %s', + $this->getTableName(), + implode(', ', $placeholders), + $idPlaceholder, + ); + + $result = $this->dbQueryExecutor->execute($sql, $params); + if ($result === false) { + throw new \RuntimeException('Не удалось обновить объект в БД.'); + } + + return $entity; + } + + protected function createEntityFromRow(array $row): EntityInterface + { + return $this->dataMapper->mapRowToEntity($row); + } +} diff --git a/src/Infrastructure/Database/Repository/UserRepository.php b/src/Infrastructure/Database/Repository/UserRepository.php new file mode 100644 index 00000000..0919afd5 --- /dev/null +++ b/src/Infrastructure/Database/Repository/UserRepository.php @@ -0,0 +1,29 @@ + Date: Tue, 14 Apr 2026 18:22:44 +0300 Subject: [PATCH 7/7] added min pagination --- composer.json | 2 +- http/users/get_users.http | 5 ++ src/Controller/Web/User/GetUsersDTO.php | 28 +++++++++++ src/Controller/Web/User/UserController.php | 32 ++++++++----- src/Domain/Collection/AbstractCollection.php | 24 ++++++++++ src/Domain/Model/GetUsersModel.php | 47 +++++++++++++++++++ src/Domain/Service/UserService.php | 9 +++- .../Repository/AbstractRepository.php | 14 ++++++ 8 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 src/Controller/Web/User/GetUsersDTO.php create mode 100644 src/Domain/Model/GetUsersModel.php diff --git a/composer.json b/composer.json index 5b34cd7c..14fc633e 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "type": "project", "license": "proprietary", "require": { - "php": ">=8.4", + "php": ">=8.5", "ext-pdo": "*", "ext-redis": "*", "ext-memcached": "*", diff --git a/http/users/get_users.http b/http/users/get_users.http index aa634223..38e129eb 100644 --- a/http/users/get_users.http +++ b/http/users/get_users.http @@ -2,3 +2,8 @@ GET http://localhost:{{nginxPublishedPort}}/get-users Content-Type: application/json Accept: application/json Cache-Control: no-cache + +{ + "lastId": 0, + "limit": 10 +} diff --git a/src/Controller/Web/User/GetUsersDTO.php b/src/Controller/Web/User/GetUsersDTO.php new file mode 100644 index 00000000..8682af96 --- /dev/null +++ b/src/Controller/Web/User/GetUsersDTO.php @@ -0,0 +1,28 @@ + $user->toArray(), - $this->userService->findUsers(), - ); + try { + $payload = $this->request->getPayload(); + $getUsers = GetUsersDTO::fromArray($payload); + $users = $this->userService->findUsers( + new GetUsersModel( + lastId: $getUsers->lastId, + limit: $getUsers->limit, + ), + ); - return new Response( - json_encode([ - 'success' => true, - 'users' => $users, - ]), - headers: ['Content-Type: application/json; charset=utf-8'], - ); + $data['success'] = true; + $data['users'] = array_map(static fn(User $user) => $user->toArray(), $users->toArray()); + $data['pager'] = ['lastId' => $users->max('id'), 'totalItems' => $users->count()]; + $httpCode = 200; + } catch (\Throwable $e) { + $data['success'] = false; + $data['message'] = $e->getMessage(); + $httpCode = $e->getCode(); + } + + return new Response(json_encode($data), $httpCode, ['Content-Type: application/json; charset=utf-8']); } public function updateUserEmail(): Response diff --git a/src/Domain/Collection/AbstractCollection.php b/src/Domain/Collection/AbstractCollection.php index aba3bbda..c6c5da69 100644 --- a/src/Domain/Collection/AbstractCollection.php +++ b/src/Domain/Collection/AbstractCollection.php @@ -30,6 +30,30 @@ public function toArray(): array return $this->items; } + public function max(string $field): mixed + { + if ($this->isEmpty()) { + return null; + } + + $getter = 'get' . ucfirst($field); + $hasGetter = array_all($this->items, static fn(EntityInterface $entity, $k) => method_exists($entity, $getter)); + if (!$hasGetter) { + return null; + } + + $max = null; + foreach ($this->items as $entity) { + if ($max === null) { + $max = $entity->$getter(); + continue; + } + $max = max($entity->$getter(), $max); + } + + return $max; + } + protected function addEntity(EntityInterface $entity): void { $this->items[] = $entity; diff --git a/src/Domain/Model/GetUsersModel.php b/src/Domain/Model/GetUsersModel.php new file mode 100644 index 00000000..8b9ccd3a --- /dev/null +++ b/src/Domain/Model/GetUsersModel.php @@ -0,0 +1,47 @@ +setLastId($lastId); + $this->setLimit($limit); + } + + public function getLastId(): int + { + return $this->lastId; + } + + public function setLastId(int $lastId): void + { + if ($lastId < 0) { + throw new \Exception('Последний id пользователя не может быть < 0'); + } + + $this->lastId = $lastId; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function setLimit(int $limit): void + { + if ($limit > self::MAX_USERS_REQUEST) { + throw new \Exception('Максимальное количество пользователей в запросе: ' . self::MAX_USERS_REQUEST); + } + + $this->limit = $limit; + } +} diff --git a/src/Domain/Service/UserService.php b/src/Domain/Service/UserService.php index 352b6173..b35a3297 100644 --- a/src/Domain/Service/UserService.php +++ b/src/Domain/Service/UserService.php @@ -2,8 +2,10 @@ namespace App\Domain\Service; +use App\Domain\Collection\UserCollection; use App\Domain\Entity\User; use App\Domain\Model\CreateUserModel; +use App\Domain\Model\GetUsersModel; use App\Domain\Model\UpdateUserEmailModel; use App\Infrastructure\Database\Repository\UserRepository; @@ -38,9 +40,12 @@ public function findUser(int $id): User return $user; } - public function findUsers(): array + public function findUsers(GetUsersModel $getUsersModel): UserCollection { - return $this->userRepository->findAll()->toArray(); + return $this->userRepository->findAllPaginatedById( + $getUsersModel->getLastId(), + $getUsersModel->getLimit(), + ); } public function updateUserEmail(UpdateUserEmailModel $updateUserModel): User diff --git a/src/Infrastructure/Database/Repository/AbstractRepository.php b/src/Infrastructure/Database/Repository/AbstractRepository.php index d3d8b2e1..9ff3837e 100644 --- a/src/Infrastructure/Database/Repository/AbstractRepository.php +++ b/src/Infrastructure/Database/Repository/AbstractRepository.php @@ -45,6 +45,20 @@ public function findAll(): AbstractCollection return $collection; } + public function findAllPaginatedById(int $lastId, int $limit): AbstractCollection + { + $sql = 'SELECT * FROM ' . $this->getTableName() . ' WHERE id > :last_id ORDER BY id ASC LIMIT :limit'; + $rows = $this->dbQueryExecutor->queryRows($sql, [':last_id' => $lastId, ':limit' => $limit]); + + /** @var AbstractCollection $collection */ + $collection = new ($this->getCollectionClassName()); + foreach ($rows as $row) { + $collection->add($this->createEntityFromRow($row)); + } + + return $collection; + } + public function save(EntityInterface $entity): EntityInterface { return $entity->getId() === null ? $this->insert($entity) : $this->update($entity);