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/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..38e129eb --- /dev/null +++ b/http/users/get_users.http @@ -0,0 +1,9 @@ +GET http://localhost:{{nginxPublishedPort}}/get-users +Content-Type: application/json +Accept: application/json +Cache-Control: no-cache + +{ + "lastId": 0, + "limit": 10 +} 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/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; + } +} 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 + { + try { + $payload = $this->request->getPayload(); + $getUsers = GetUsersDTO::fromArray($payload); + $users = $this->userService->findUsers( + new GetUsersModel( + lastId: $getUsers->lastId, + limit: $getUsers->limit, + ), + ); + + $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 + { + 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/Collection/AbstractCollection.php b/src/Domain/Collection/AbstractCollection.php new file mode 100644 index 00000000..c6c5da69 --- /dev/null +++ b/src/Domain/Collection/AbstractCollection.php @@ -0,0 +1,61 @@ +items); + } + + public function count(): int + { + return count($this->items); + } + + public function isEmpty(): bool + { + return empty($this->items); + } + + 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/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); + } +} 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; + } +} 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/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/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..b35a3297 --- /dev/null +++ b/src/Domain/Service/UserService.php @@ -0,0 +1,66 @@ +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(GetUsersModel $getUsersModel): UserCollection + { + return $this->userRepository->findAllPaginatedById( + $getUsersModel->getLastId(), + $getUsersModel->getLimit(), + ); + } + + 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/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; + } +} 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'), + ]; + } +} diff --git a/src/Infrastructure/Database/Repository/AbstractRepository.php b/src/Infrastructure/Database/Repository/AbstractRepository.php new file mode 100644 index 00000000..9ff3837e --- /dev/null +++ b/src/Infrastructure/Database/Repository/AbstractRepository.php @@ -0,0 +1,135 @@ +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 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); + } + + 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 @@ +