====== Práctica 3 ======
** 6 pts**
En esta práctica vamos a añadir conexión con una base de datos, y empezaremos a crear un pequeño [[https://es.wikipedia.org/wiki/Asignaci%C3%B3n_objeto-relacional|ORM]].
Lo primero será crea una nueva carpeta en //src// llamada //db//, donde estarán nuestros archivos necesarios para conectarnos con la bbdd y ejecutar sentencias SQL. Dentro de esa carpeta, tendremos una interfaz llamada //IPDOConnection// con el método //connect()//, que devolverá un objeto //PDO//, la carpeta donde implementaremos dicha interfaz y otra carpeta llamada //orm// donde crearemos nuestro pequeño ORM:
/src
...
/db
/impl
MysqlPDO.php
/orm
DB.php
QueryBuilder.php
IPDOConnection.php
...
===== MysqlPDO =====
Por ahora, solo crearemos una implementación de //IPDOConnection// llamada //MysqlPDO//, que conectará con una bbdd //mariadb// (también puede ser //mysql//). El método //connect()// conectará con la bbdd y devolverá una excepción con el código 500 si no puede conectarse por cualquier motivo.
===== ORM =====
==== DB ====
Vamos a empezar por crear una clase (//DB//) que nos permita ejecutar sentencias SQL de forma sencilla. Por ahora, crearemos 3 métodos (más adelante iremos ampliando los métodos): //execute()//, que recibirá un //string// con el SQL que queramos ejecutar y los parámetros de la SQL (vamos a utilizar sentencias preparadas de la librería //PDO//). El método devolverá el resultado de una sentencia //SELECT//:
private static function execute(string $sql, ?array $params = null):array {
$pdo = DBFactory::getConnection()::connect();
$ps = $pdo->prepare($sql);
$ps->execute($params);
return $ps->fetchAll(\PDO::FETCH_ASSOC);
}
Nuestro segundo método será //select()//, que usaremos para ejecutar sentencias SELECT que devuelvan varios resultados (los devolveremos en formato //array// de objetos de la clase [[https://www.php.net/manual/es/reserved.classes.php|stdClass]]. Para ello, simplemente deberemos convertir el //array// que nos devuelva el método //execute()// anterior mediante //(object)//:
public static function select(string $sql, ?array $params = null):array {
$result = array();
$data = self::execute($sql, $params);
foreach ($data as $record) {
$result[] = ((object) $record);
}
return $result;
}
Nuestro último método (//selectOne()//), lo usaremos para devolver un objeto (en lugar de un //array// de objetos como en el método anterior). Nos será útil para ejecutar sentencias SELECT que van a devolvernos un solo resultado:
public static function selectOne(string $sql, ?array $params = null): \stdClass {
$data = self::execute($sql, $params);
if(count($data) > 0) {
return (object) $data[0];
}
throw new \Exception("Recurso no encontrado", 404);
}
Para probar el funcionamiento de la clase //DB//, simplemente tenemos que crear un nuevo //DAO// (MoviesDBDAO) y usar sus métodos en //read()// y //findById// (Recuerda que tendrás que cambiar el //DAO// que vamos a utilizar en el //Factory// correspondiente):
class MoviesDBDAO implements IMoviesDAO {
static function create(MovieDTO $movie): bool {
return false;
}
static function read(): array {
$result = array();
$sql = "SELECT * FROM peliculas";
$db_data = DB::select($sql);
foreach ($db_data as $movie) {
$result[] = new MovieDTO(
$movie->id,
$movie->titulo,
$movie->anyo,
$movie->duracion
);
}
return $result;
}
static function findById(int $id): MovieDTO {
$params = [
"id" => $id
];
$sql = "SELECT * FROM peliculas WHERE id = :id LIMIT 1";
$db_data = DB::selectOne($sql, $params);
$result = new MovieDTO(
$db_data->id,
$db_data->titulo,
$db_data->anyo,
$db_data->duracion
);
return $result;
}
static function update(int $id, MovieDTO $movie): bool {
return false;
}
static function delete(int $id): bool {
return false;
}
}
==== QueryBuilder ====
Vamos a añadir un poco más de abstracción a nuestras operaciones con la bbdd. Para ello, crearemos la clase //QueryBuilder//, que se encargará de montar las sentencias SQL simples por nosotros. Esta clase tendrá 4 propiedades: //$fields//, que serán los campos de la tabla que queremos recuperar en una sentencia SELECT, //$where// que tendrá la condición de una //SELECT//, //$params//, con los parámetros que vamos a usar en las sentencias preparadas y //$sql//, para mostrar la sentencia que vamos a ejecutar (esto último nos servirá para depurar la aplicación mientras la vamos montando). Además, tendrá una última propiedad (//$table//) que le pasaremos al constructor donde almacenaremos la tabla sobre la que hacer la consulta SQL:
lass QueryBuilder {
private string $fields = '*';
private string $where = "";
private ?array $params = null;
private string $sql;
function __construct(private string $table) {
$this->table = $table;
}
Si te fijas, la propiedad //$fields// tiene valor por defecto *, lo que quiere decir que, a no ser que el usuario indique lo contrario, devolveremos todos los campos de la tabla.
Para usar nuestra clase, crearemos un nuevo método **en la clase //DB//** llamado //table()// donde le pasaremos el nombre de la tabla sobre la que queremos ejecutar nuestras consultas y nos devolverá un nuevo objeto de la clase //QueryBuilder//:
public static function table(string $table):QueryBuilder {
return new QueryBuilder($table);
}
Vamos a crear ahora dos métodos para indicar los campos que queremos devolver (//select()//) y la condición de la sentencia SQL (//where()//). El primer método es muy sencillo, ya que solo modificará la propiedad //$fields// de nuestra clase:
public function select(?string $fields = null) {
$this->fields = (is_null($fields))? '*': $fields;
return $this;
}
Lo interesante aquí es lo que devuelve el método: //$this// (el propio objeto). Esto nos será útil para poder concatenar métodos y así ir montando nuestras sentencias SQL poco a poco:
DB::Table->select('titulo', 'duracion')->where('anyo', '>', 1980)....
El método //where()// recibirá 3 parámetros: el campo de la tabla, la condición y el valor:
public function where(string $field, string $condition, ?string $value) {
if (is_null($value)) {
$value = $condition;
$condition = '=';
}
$this->where = "WHERE $field $condition :$field";
$this->params[":$field"] = $value;
return $this;
}
Lo hemos preparado para que si se le pasan solo dos parámetros, la condición por defecto sea //=//.
Los siguientes métodos serán //get()//, que devolverá un //array// de resultados de una sentencia SELECT, y //getOne()//, que utilizaremos para los casos donde solo queremos devolver un resultado. Estos métodos montarán la SQL y ejecutarán los métodos correspondientes de la clase //DB// (//select()// o //selectOne()//):
public function get():array {
$this->sql = "SELECT $this->fields FROM $this->table $this->where";
return DB::select($this->sql, $this->params);
}
public function getOne():stdClass {
$this->sql = "SELECT $this->fields FROM $this->table $this->where LIMIT 1";
return DB::selectOne($this->sql, $this->params);
}
Vamos a implementar otro método que nos será útil cuando queramos buscar en una tabla por su clave primaria:
public function find(int $id) {
$this->where('id', '=', $id);
return $this->getOne();
}
Por último, creamos el método //toSQL()// que nos servirá para mostrar la sentencia que queramos ejecutar (lo podemos usar como ayuda para depurar nuestra aplicación):
private function toSql() {
dd($this->sql);
}
Listo, ahora si cambiamos los métodos //read()// y //findById()// en //MoviesDBDAO// usando la nueva clase, debería mostrarnos los resultados correspondientes:
static function read(): array {
$result = array();
$db_data = DB::table('peliculas')->select('*')->get();
foreach ($db_data as $movie) {
$result[] = new MovieDTO(
$movie->id,
$movie->titulo,
$movie->anyo,
$movie->duracion
);
}
return $result;
}
static function findById(int $id): MovieDTO {
$db_data = DB::table('peliculas')->find($id);
$result = new MovieDTO(
$db_data->id,
$db_data->titulo,
$db_data->anyo,
$db_data->duracion
);
return $result;
}
===== Inserciones =====
Para permitir las inserciones en nuestra bbdd, vamos a crear un para de métodos más en la clase //DB//:
public static function insert(string $sql, array $params): int {
return self::executeNoResult($sql, $params);
}
private static function executeNoResult(string $sql, array $params):int {
$pdo = DBFactory::getConnection()::connect();
try {
$ps = $pdo->prepare($sql);
return $ps->execute($params);
} catch (\Throwable $th){
//throw $th;
throw new \Exception("Error al insertar el recurso", 400);
}
}
Los métodos son muy sencillo. El segundo (//executeNoResult()//) lo usamos para ejecutar sentencias SQL que no devuelven resultados, y el primero (//insert()//), ejecutará la sentencia INSERT que le pasemos con los parámetros correspondientes.
Nuestra clase //DB// ya está lista para ejecutar sentencias INSERT (puedes probarla en el método //create()// de //MoviesDBDAO// para asegurarte que funciona). Como antes, vamos a simplificar el uso de sentencias INSERT creando el correspondiente método en la clase //QueryBuilder//:
public function insert(array $data):int {
$fieldsParams = "";
foreach ($data as $key => $value) {
$fieldsParams .= ":$key,";
$this->params[":$key"] = $value;
}
$fieldsParams = rtrim($fieldsParams, ',');
$fieldsName = implode(",", array_keys($data));
$this->sql = "INSERT INTO $this->table($fieldsName) VALUES ($fieldsParams)";
return DB::insert($this->sql, $this->params);
}
La única //complicación// en este método es montar la sentencia para usar sentencias con parámetros (... VALUES (:id, :titulo....), aunque el código es sencillo de entender. Una función que nos es muy útil es [[https://www.php.net/manual/es/function.implode.php|implode]], que nos convierte un //array// en un //string// con la separación entre elementos que queramos (en nuestro caso, una coma). Para usar nuestro método, simplemente tendremos que pasarle un //array// asociativo (con las claves del array igual al nombre de los campos de la bbdd) con los valores a insertar:
class MoviesDBDAO {
....
static function create(MovieDTO $movie): bool {
return DB::table('peliculas')->insert(['titulo' => $movie->titulo(), 'anyo' => $movie->anyo(), 'duracion' => $movie->duracion()]);
}
....
Solo nos queda añadir la ruta correspondiente en nuestro archivo de rutas y un nuevo método en el controlador para poder insertar registros en nuestra bbdd:
$router->post('/peliculas', 'controllers\MoviesController@insert');
public function insert() {
try {
$data = json_decode(file_get_contents('php://input'), true);
$movie = new MovieDTO(null, $data['titulo'], $data['anyo'], $data['duracion']);
MoviesFactory::getService()::insert($movie);
HTTPResponse::json(201, "Recurso creado");
} catch (\Exception $e) {
HTTPResponse::json($e->getCode(), $e->getMessage());
}
}
===== Ampliaciones =====
**Ampliación 1 (2 pts)**
Crea los métodos necesarios para poder hacer actualizaciones y borrados en la bbdd mediante las clases //DB// y //QueryBuilder//.
**Ampliación 2 (1 pt)**
Averigua qué hacer para poder tener los parámetros de conexión a nuestra bbdd en un archivo //.env// de configuración.
**Ampliación 3 (1 pt)**
Piensa alguna manera para poder devolver un mensaje de error con código 400 cuando la petición del usuario a la hora de insertar o actualizar un recurso no sea correcta (campos de la tabla que faltan, mal escritos...). Ten en cuenta la reusabilidad del código que implementes.