Реализация класса для работы с базой данных в PHP: от простого к продвинутому
Класс для работы с базой данных в PHP: обзор решений
При разработке на PHP часто возникает задача инкапсулировать работу с базой данных в отдельный класс. Это улучшает поддержку кода, безопасность и переиспользование. Ниже рассмотрены несколько подходов с примерами и типичными ошибками.
Как создать простой класс для PDO?
Наиболее современный и универсальный вариант - использование расширения PDO, которое поддерживает множество СУБД. Класс оборачивает PDO и добавляет удобные методы.
class Database {
private $pdo;
public function __construct($dsn, $user, $pass) {
try {
$this->pdo = new PDO($dsn, $user, $pass);
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
die('Connection failed: ' . $e->getMessage());
}
}
public function select($sql, $params = []) {
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function insert($table, $data) {
$keys = implode(',', array_keys($data));
$placeholders = ':' . implode(',:', array_keys($data));
$sql = "INSERT INTO $table ($keys) VALUES ($placeholders)";
$this->pdo->prepare($sql)->execute($data);
return $this->pdo->lastInsertId();
}
public function update($table, $data, $where, $whereParams = []) {
$set = '';
foreach ($data as $key => $value) {
$set .= "$key = :$key,";
}
$set = rtrim($set, ',');
$sql = "UPDATE $table SET $set WHERE $where";
$stmt = $this->pdo->prepare($sql);
return $stmt->execute(array_merge($data, $whereParams));
}
public function delete($table, $where, $params = []) {
$sql = "DELETE FROM $table WHERE $where";
$stmt = $this->pdo->prepare($sql);
return $stmt->execute($params);
}
}
Php db class (класс для работы с базой данных в php)
Пример использования:
$db = new Database('mysql:host=localhost;dbname=test', 'root', '');
$users = $db->select('SELECT * FROM users WHERE age > :age', ['age' => 18]);
Типичные проблемы:
- Неправильный DSN - соединение не установится; проверяйте синтаксис.
- Отсутствие обработки ошибок - используйте try-catch или ATTR_ERRMODE_EXCEPTION.
- SQL-инъекция при конкатенации строк - всегда используйте подготовленные запросы.
Как реализовать класс для mysqli объектно?
Если проект жестко привязан к MySQL, можно использовать расширение mysqli. Его объектный стиль похож на PDO, но специфичен для MySQL.
class MySQL {
private $mysqli;
public function __construct($host, $user, $pass, $db) {
$this->mysqli = new mysqli($host, $user, $pass, $db);
if ($this->mysqli->connect_error) {
die('Connect Error: ' . $this->mysqli->connect_error);
}
$this->mysqli->set_charset('utf8');
}
public function query($sql) {
$result = $this->mysqli->query($sql);
if (!$result) {
throw new Exception('Query failed: ' . $this->mysqli->error);
}
return $result->fetch_all(MYSQLI_ASSOC);
}
public function prepareAndExecute($sql, $params) {
$stmt = $this->mysqli->prepare($sql);
if (!$stmt) {
throw new Exception('Prepare failed: ' . $this->mysqli->error);
}
$types = str_repeat('s', count($params)); // упрощённо, все строки
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
}
Проблемы:
- Нет автоматического экранирования при неиспользовании prepare - риск инъекции.
- Типы параметров в bind_param нужно указывать вручную; неверный тип приводит к ошибке.
- mysqli не поддерживает другие СУБД, что снижает гибкость.
Как сделать класс с паттерном Одиночка (Singleton) для соединения?
Одиночка гарантирует единственный экземпляр подключения к БД, что экономит ресурсы при многократных запросах в одном скрипте. Однако это затрудняет тестирование и создание нескольких разных соединений.
class DB {
private static $instance = null;
private $pdo;
private function __construct() {
$dsn = 'mysql:host=localhost;dbname=test';
$this->pdo = new PDO($dsn, 'root', '');
}
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getConnection() {
return $this->pdo;
}
}
Использование:
$pdo = DB::getInstance()->getConnection();
Недостатки:
- Глобальное состояние - усложняет юнит-тестирование (нельзя подменить соединение).
- Невозможно работать с несколькими разными базами одновременно.
- Скрытая зависимость - класс, использующий Singleton, неявно привязан к нему.
Основное эффективное решение: гибридный класс с PDO, Query Builder и безопасностью
Рекомендуемый подход - класс, который использует PDO, реализует удобный Query Builder (без внешних зависимостей) и строгую типизацию. Ниже приведена базовая реализация.
class DatabaseManager {
private PDO $pdo;
public function __construct(string $dsn, string $user, string $pass, array $options = []) {
$defaultOptions = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$this->pdo = new PDO($dsn, $user, $pass, array_merge($defaultOptions, $options));
}
public function select(string $table, array $columns = ['*'], array $where = [], string $extra = ''): array {
$cols = implode(',', $columns);
$sql = "SELECT $cols FROM $table";
$params = [];
if (!empty($where)) {
$conditions = [];
foreach ($where as $col => $val) {
$conditions[] = "$col = :$col";
$params[$col] = $val;
}
$sql .= ' WHERE ' . implode(' AND ', $conditions);
}
if ($extra) {
$sql .= ' ' . $extra;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
public function insert(string $table, array $data): int {
$columns = implode(',', array_keys($data));
$placeholders = ':' . implode(',:', array_keys($data));
$sql = "INSERT INTO $table ($columns) VALUES ($placeholders)";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($data);
return (int) $this->pdo->lastInsertId();
}
public function update(string $table, array $data, array $where): int {
$setParts = [];
foreach ($data as $col => $val) {
$setParts[] = "$col = :set_$col";
}
$whereParts = [];
foreach ($where as $col => $val) {
$whereParts[] = "$col = :where_$col";
}
$sql = "UPDATE $table SET " . implode(',', $setParts)
. ' WHERE ' . implode(' AND ', $whereParts);
$params = [];
foreach ($data as $col => $val) {
$params["set_$col"] = $val;
}
foreach ($where as $col => $val) {
$params["where_$col"] = $val;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount();
}
public function delete(string $table, array $where): int {
$conditions = [];
foreach ($where as $col => $val) {
$conditions[] = "$col = :$col";
}
$sql = "DELETE FROM $table WHERE " . implode(' AND ', $conditions);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($where);
return $stmt->rowCount();
}
public function rawQuery(string $sql, array $params = []): PDOStatement {
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
}
Примеры работы:
$db = new DatabaseManager('mysql:host=localhost;dbname=test', 'root', '');
$users = $db->select('users', ['id', 'name'], ['status' => 'active'], 'ORDER BY name');
$newId = $db->insert('users', ['name' => 'Иван', 'email' => 'ivan@example.com']);
$updatedRows = $db->update('users', ['name' => 'Петр'], ['id' => 1]);
$deletedRows = $db->delete('users', ['id' => 10]);
Возможные проблемы и их решения:
- Проблема: Имена таблиц и столбцов не экранируются (риск инъекции через имена). Решение: не передавать пользовательские данные в имена таблиц/столбцов, либо использовать белые списки.
- Проблема: Большое количество запросов в цикле. Решение: использовать массовые операции (bulk insert) или подготовить один запрос и менять параметры.
- Проблема: Долгое выполнение сложных запросов. Решение: добавить кэширование результатов или использовать EXPLAIN для оптимизации.
Как построить класс Repository для отделения логики?
Repository - это паттерн, который изолирует логику работы с данными. Класс репозитория принимает объект DatabaseManager и предоставляет специфичные методы, например, getUserById() или saveUser(). Это улучшает организацию кода, но увеличивает количество классов.
class UserRepository {
private DatabaseManager $db;
public function __construct(DatabaseManager $db) {
$this->db = $db;
}
public function findById(int $id): ?array {
$result = $this->db->select('users', ['*'], ['id' => $id]);
return $result[0] ?? null;
}
public function findAllActive(): array {
return $this->db->select('users', ['*'], ['status' => 'active']);
}
public function save(array $user): int {
if (isset($user['id'])) {
$this->db->update('users', $user, ['id' => $user['id']]);
return $user['id'];
} else {
return $this->db->insert('users', $user);
}
}
}
Типичные ошибки:
- Дублирование кода запросов в разных репозиториях - выносить общие методы в базовый класс.
- Игнорирование транзакций при сохранении связанных сущностей - использовать транзакции на уровне репозитория или сервисного слоя.
- Отсутствие валидации данных перед записью - проверять данные на уровне репозитория или в отдельном классе.
Расширенные примеры для класса работы с БД
Пакетная вставка (Bulk Insert)
При вставке множества записей выгоднее использовать один INSERT с несколькими строками значений, а не цикл. Реализуем метод в классе.
public function bulkInsert(string $table, array $rows): int {
if (empty($rows)) return 0;
$columns = implode(',', array_keys($rows[0]));
$placeholders = [];
$values = [];
$i = 0;
foreach ($rows as $row) {
$rowPlaceholders = [];
foreach ($row as $col => $val) {
$param = ":{$col}_{$i}";
$rowPlaceholders[] = $param;
$values[$param] = $val;
}
$placeholders[] = '(' . implode(',', $rowPlaceholders) . ')';
$i++;
}
$sql = "INSERT INTO $table ($columns) VALUES " . implode(',', $placeholders);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($values);
return $stmt->rowCount();
}
Использование:
$rows = [
['name' => 'Alice', 'age' => 25],
['name' => 'Bob', 'age' => 30],
];
$inserted = $db->bulkInsert('users', $rows);
echo "Добавлено строк: $inserted";
Добавлено строк: 2
Транзакции с передачей замыкания
Удобно обернуть несколько операций в транзакцию, передав замыкание.
public function transaction(callable $callback) {
$this->pdo->beginTransaction();
try {
$callback($this);
$this->pdo->commit();
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
Пример:
$db->transaction(function ($db) {
$userId = $db->insert('users', ['name' => 'Vasya']);
$db->insert('logs', ['action' => 'created_user', 'user_id' => $userId]);
});
Работа с JSON полями (MySQL 5.7+)
Используем JSON функции SQL через PDO.
$data = ['skills' => ['PHP', 'MySQL']];
$sql = "UPDATE users SET metadata = JSON_SET(metadata, '$.skills', :skills) WHERE id = :id";
$db->rawQuery($sql, ['skills' => json_encode($data['skills']), 'id' => 1]);
Получение данных:
$result = $db->select('users', ['id', 'metadata->"$.skills" as skills'], ['id' => 1]);
$skills = json_decode($result[0]['skills'], true);
print_r($skills);
Array
(
[0] => PHP
[1] => MySQL
)
Кэширование результатов запросов
Сохраняем результат в статический массив внутри метода select для повторного использования в рамках одного запроса.
private static array $cache = [];
public function selectWithCache(string $sql, array $params = [], int $ttl = 0): array {
$key = md5($sql . serialize($params));
if (isset(self::$cache[$key]) && ($ttl === 0 || time() - self::$cache[$key]['time'] < $ttl)) {
return self::$cache[$key]['data'];
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$data = $stmt->fetchAll();
self::$cache[$key] = ['data' => $data, 'time' => time()];
return $data;
}
Пример вызова:
$users = $db->selectWithCache('SELECT * FROM users WHERE role = :role', ['role' => 'admin'], 60);
// При повторном вызове в течение 60 секунд вернёт закешированные данные (если скрипт не завершён).
Постраничный вывод (пагинация) через SQL LIMIT и OFFSET
public function paginate(string $table, int $page, int $perPage = 10, array $where = []): array {
$offset = ($page - 1) * $perPage;
$data = $this->select($table, ['*'], $where, "LIMIT $perPage OFFSET $offset");
// Общее количество записей (упрощённо)
$countSql = "SELECT COUNT(*) FROM $table";
if (!empty($where)) {
$conditions = [];
foreach ($where as $col => $val) {
$conditions[] = "$col = :$col";
}
$countSql .= ' WHERE ' . implode(' AND ', $conditions);
}
$total = $this->rawQuery($countSql, $where)->fetchColumn();
return [
'data' => $data,
'total' => (int) $total,
'page' => $page,
'perPage' => $perPage,
'lastPage' => ceil($total / $perPage),
];
}
Использование:
$pageData = $db->paginate('users', 2, 20);
echo "Страница {$pageData['page']} из {$pageData['lastPage']}";