Tabla de Contenidos

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 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 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 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.