¡Hola! Me parece que andas usando un bloqueador de anuncios =( ¿Nos desbloqueas? ¿Por qué?
F13

Funcion 13

Creando un pequeño ORM en PHP

La mayoría de las veces que empezamos un pequeño desarrollo, la cosa suele crecer e ir a más y pronto estaremos lidiando con un montón de código que no teníamos idea que iba a existir.

Los frameworks (como Laravel o CodeIgniter) ayudan a organizar el código desde el principio y tienden a evitar este problema ya que están basados en buenas prácticas para evitar cometer errores que otros programadores ya han cometido.

No obstante, no en todas las situaciones es aplicable un framework y en muchas ocasiones, querremos hacer algo pequeño para lo que, el engorro de usar un framework, no estará del todo justificado.
Y señalo, del todo, porque en mi experiencia, las aplicaciones tienden a crecer más rápido de lo que siempre imaginamos.

En mi trabajo, comencé una aplicación como una webapp para extraer datos de una tabla y ha acabado en algo con más de 30 tablas y un porrón de vistas en menos de 1 año.

He reescrito el código de la aplicación en varias ocasiones, al completo, y ahora me estoy planteando el migrarlo a Laravel.

En ese proyecto, lo primero que necesité fue una capa de acceso a la base de datos, algo que automatizara las tareas y algo que fuera extensible a varios protocolos, ya que tengo que tratar con datos de varios tipos de bases de datos.

Para esto hay librerías como Doctrine o Eloquent que añaden bastante funcionalidad pero aquí pretendemos hacer algo sencillo.

El contenido de este artículo, pretende sentar las bases para el código que usaré en otros artículos, como la serie de Localizador de tiendas con PHP, jQuery Mobile y MySQL.

¿Qué demonios es un ORM?

ORM son las siglas de Object-relational mapping, que en la lengua de Cervantes viene a ser algo como mapeo de objetos relacional.

¿Vale, pero qué significa?

El ORM es una técnica de programación que nos permite valernos de la orientación a objetos de un lenguaje, para representar las relaciones entre tablas de una base de datos.

En la práctica, si tenemos una tabla usuarios que contenga… usuarios, tendremos una clase Usuarios en nuestra aplicación que contendrá el esqueleto de la tabla. Si nos ajustamos al Modelo/Vista/Controlador, esa clase no debe contener lógica.

Una tabla así:

CREATE TABLE IF NOT EXISTS `users`  
( 
  `id`       INT(11) NOT NULL auto_increment, 
  `username` VARCHAR(8) NOT NULL DEFAULT '00000000', 
  `password` VARCHAR(50) NOT NULL, 
  `name`     VARCHAR(50) NOT NULL, 
  `surname`  VARCHAR(50) NOT NULL, 
  `email`    VARCHAR(100) DEFAULT NULL, 
  PRIMARY KEY (`id`), 
  KEY `username` (`username`), 
) engine=innodb DEFAULT charset=utf8;

Nos llevaría a una clase así:

class User {  
    public $id;
    public $username;
    public $password;
    public $name;
    public $surname;
    public $email;
}

¿Y qué ventajas tiene?

Es probable que te cueste verlas, especialmente si estás empezando pero esto te permite abstraerte de mucho código. Es algo que desarrollas una vez y que, raramente tendrás que volver a tocar, salvo para hacer algunos ajustes y/o ampliarlo.

Por ejemplo, al final del artículo (si me he explicado bien) serás capaz de crear cualquier clase y hacer algo así:

//Obteniendo usuario con id = 1 
$user = User::get(1); echo $user->name; // Antonio

¡Sin escribir ni una sola línea de SQL!

Unas cuantas aclaraciones

Antes de continuar, me gustaría hacer algunas aclaraciones para evitar confusiones.

El código de este artículo está en Inglés. No es por fastidiar, y me gusta ofrecer el código en castellano. Pero este código lo vengo arrastrando y mejorando desde hace mucho tiempo y se me antoja difícil “traducirlo” sin cometer errores.

En este artículo intento transmitir la forma en la que yo hago las cosas después de varias iteraciones e intentando ajustarme a las buenas prácticas que conozco, pero no por ello es la mejor forma de hacer las cosas. Con esto quiero decir, que este código se ajusta a mis necesidades y creo que se puede ajustar a las de más gente pero no por ello es la mejor idea y estoy seguro de que podría ser mejorado de múltiples formas, pero no he encontrado la forma, el momento, o la necesidad de hacerlo hasta ahora.

Si la programación orientada a objetos es aun un misterio, un galimatías que no puedes descifrar y no sabes lo que significan palabras como static o public, no dejes de echar un vistazo al artículo sobre la programación orientada a objetos en PHP para principiantes.

El conector de la Base de Datos

Lo primero que necesitamos en nuestro ORM es un gestor de la base de datos que se encargue de hacer muchas de las tareas repetitivas que acostumbramos a hacer con cada consulta.

Este gestor tendrá un esqueleto “base” y un adaptador para MySQL, que es el motor más común para usar con PHP. Como comentaba, de manera interna, he creado conectores para Oracle y MS SQL, pero queda fuera del alcance de este artículo.

Este esqueleto es una versión “mejorada” de la que publicó Iván Guardado en ontuts hace ya… 4 años.

El esqueleto del proveedor

Para que podamos abstraernos del motor de base de datos que vayamos a utilizar, necesitamos crear una clase que nos sirva de base para cada proveedor. De esta forma, si vamos a ejecutar una consulta, no nos tenemos que preocupar de llamar a mysqli_query o a mssql_query, llamamos a un método query que se encargue de ponerle luego el nombre que le corresponda.

Para ellos usaremos una clase abstract, que no implica tipo de lógica alguna, solo propiedades, métodos y parámetros que reciben éstos.

abstract class DatabaseProvider {  
    protected $resource;
    public abstract function connect($host, $user, $pass, $dbname);
    public abstract function disconnect();
    public abstract function getErrorNo();
    public abstract function getError();
    public abstract function query($q);
    public abstract function numRows($resource);
    public abstract function fetchArray($resource);
    public abstract function isConnected();
    public abstract function escape($var);
    public abstract function getInsertedID();
    public abstract function changeDB($database);
    public abstract function setCharset($charset);
}

En $resource es donde guardaremos el recurso de la conexión que nos permite interactuar con los métodos del motor de la base de datos. El resto de métodos los veremos enseguida, cuando los implementemos en el motor MySQL.

El proveedor MySQL

require_once ('DatabaseProvider.php');  
class MySqlProvider extends DatabaseProvider {  
    public function connect($host, $user, $pass, $dbname) {
        $this->resource = new mysqli($host, $user, $pass, $dbname);
        if ($this->resource->connect_errno) {
            // Connection fails
            error_log($this->resource->connect_error);
        }
        return $this->resource;
    }
    public function disconnect() {
        return $this->resource->close();
    }
    public function getErrorNo() {
        return $this->resource->errno;
    }
    public function getError() {
        return $this->resource->error;
    }
    public function query($q) {
        return $this->resource->query($q);
    }
    public function numRows($resource) {
        $num_rows = 0;
        if ($resource) {
            $num_rows = $resource->num_rows;
        }
        return $num_rows;
    }
    public function fetchArray($result) {
        return $result->fetch_assoc();
    }
    public function isConnected() {
        return !is_null($this->resource);
    }
    public function escape($var) {
        return $this->resource->real_escape_string($var);
    }
    public function getInsertedID() {
        return $this->resource->insert_id;
    }
    public function changeDB($database) {
        return $this->resource->select_db($database);
    }
    public function setCharset($charset) {
        return $this->resource->set_charset($charset);
    }
}

Como veis, la clase MySQLProvider extiende a la clase DatabaseProvider que acabamos de crear. En este caso estamos usando los métodos orientados a objetos de MySQL aunque sería equivalente el uso de funciones normales.

Veamos la lista

  • connect : Esta es la función que se conecta a la base de datos, en caso de existir un error en la conexión, lo almacena en el log de errores de PHP.
  • disconnect : Esta función se desconecta de la base de datos.
  • getErrorNo : Esta función nos devuelve el número de error (en caso de haberlo) al haberse ejecutado una consulta o procedimiento.
  • getError : Esta función nos devuelve el mensaje de error (sin el número).
  • query : Esta función se encarga de realizar una consulta a la base de datos.
  • numRows : Esta función nos devuelve el número de filas seleccionadas, actualizadas, insertadas o eliminadas.

Ahora que ya tenemos todo listo, podemos pasar a la última clase, la que se encarga de realizar todas las acciones, con la que realmente interactuaremos directamente.

La capa de comunicación

class Database {  
    private $provider;
    private $params;
    private static $_con;
    public function __construct($provider, $host, $user, $pass, $db) {
        if (!class_exists($provider)) {
            throw new Exception("The provider doesn't exists or it wasn't implemented");
        }
        $this->provider = new $provider;
        $this->provider->connect($host, $user, $pass, $db);
        if (!$this->provider->isConnected()) {
            throw new Exception("We couldn't connect to the database >> $db");
        } else {
            $this->provider->setCharset('utf-8');
        }
    }
    public static function getConnection($provider, $host, $user, $pass, $db) {
        if (self::$_con) {
            return self::$_con;
        } else {
            $class = __CLASS__;
            self::$_con = new $class($provider, $host, $user, $pass, $db);
            return self::$_con;
        }
    }
    private function replaceParams() {
        $b = current($this->params);
        next($this->params);
        return $b;
    }
    private function prepare($sql, $params) {
        $escaped = '';
        if ($params) {
            foreach ($params as $key => $value) {
                if (is_bool($value)) {
                    $value = $value ? 1 : 0;
                } elseif (is_double($value)) {
                    $value = str_replace(',', '.', $value);
                } elseif (is_numeric($value)) {
                    if (is_string($value)) {
                        $value = "'" . $this->provider->escape($value) . "'";
                    } else {
                        $value = $this->provider->escape($value);
                    }
                } elseif (is_null($value)) {
                    $value = "NULL";
                } else {
                    $value = "'" . $this->provider->escape($value) . "'";
                }
                $escaped[] = $value;
            }
        }
        $this->params = $escaped;
        $q = preg_replace_callback("/(\?)/i", array($this, "replaceParams"), $sql);
        return $q;
    }
    private function sendQuery($q, $params) {
        $query = $this->prepare($q, $params);
        $result = $this->provider->query($query);
        if ($this->provider->getErrorNo()) {
            error_log($this->provider->getError());
        }
        return $result;
    }
    public function executeScalar($q, $params = null) {
        $result = $this->sendQuery($q, $params);
        if (!is_null($result)) {
            if (!is_object($result)) {
                return $result;
            } else {
                $row = $this->provider->fetchArray($result);
                return $row[0];
            }
        }
        return null;
    }
    public function execute($q, $array_index = null, $params = null) {
        $result = $this->sendQuery($q, $params);
        if ((is_object($result) || $this->provider->numRows($result) || $result) && ($result !== true && $result !== false)) {
            $arr = array();
            while ($row = $this->provider->fetchArray($result)) {
                if ($array_index) {
                    $arr[$row[$array_index]] = $row;
                } else {
                    $arr[] = $row;
                }
            }
            return $arr;
        }
        return $this->provider->getErrorNo() ? false : true;
    }
    public function changeDB($database) {
        $this->provider->changeDB($database);
    }
    public function getInsertedID() {
        return $this->provider->getInsertedID();
    }
    public function getError() {
        return $this->provider->getError();
    }
    public function __destruct() {
        $this->provider->disconnect();
    }
}

Vamos paso a paso.

El constructor intenta saber si el proveedor que le estamos pasando, existe. En caso de que no sea así, lanzaremos una excepción, que podrá ser controlada posteriormente. Si todo va bien, crearemos un nuevo proveedor e intentaremos conectarnos a la base de datos. En caso de no poder, tendremos otra excepción.

En mi caso, y especialmente con MySQL, cambio la codificación a UTF-8 para que todos los nombres y apellidos que vayan con tildes, sean bien tratados por el código, esto no es necesario.

La función getConnection es la encargada de obtener la conexión con la base de datos, siguiendo el patrón Singleton, en caso de que ya haya una conexión establecida, la devuelve, para ahorrar recursos, y en caso de que no, crea una nueva conexión.

replaceParams es llamada por la función que prepara la consulta y se encarga de devolver el parámetro que pasemos en cada interrogante, valiéndose de la función next() que va moviendo el cursor de la matriz por cada coincidencia.  Esto nos convierte una consulta de este tipo:

SELECT * FROM usuarios WHERE nombre = ?  

Con esta matriz

array ('Eutanasio');  

En esto:

SELECT * FROM usuarios WHERE nombre = 'Eutanasio'  

La función prepare es quizá la más importante ya que se encarga de limpiar los parámetros y de colocarlos en su lugar, es la función que hace uso de replaceParams, la cual ya hemos definido, con el uso de preg_replace_callback(). Esta función escapa todos los parámetros para evitar los ataques de inyección SQL, añade las comillas a las cadenas, etc.

sendQuery es el método encargado de ejecutar las consultas y comprobar los errores que se pudieran cometer, en mi caso, los envío al log de errores de PHP, se pueden lanzar excepciones o realizar cualquier otra acción. Esta función es usada por los dos métodos que veremos a continuación.

execute y executeScalar son los dos métodos que se encargan de procesar los datos obtenidos de las consultas y son los únicos que son públicos. Los métodos solo se diferencian en que execute, se encarga de cargar los datos en una matriz, mientras que el segundo devuelve solo la primera columna de la primera fila.

¡Todo a punto!

Ahora que ya lo tenemos todo a punto, ya tenemos todas las herramientas para comenzar a crear nuestro ORM. No obstante, vamos a ver cómo usarlo.

$db = DatabaseLayer:getConnection("MySqlProvider");
$resultados = $db->execute('SELECT * FROM users WHERE name LIKE ?', null, array('Antonio'));
// Tendríamos una matriz con todos los datos de los usuarios cuyo nombre fuera como Antonio

$resultado =  $db->executeScalar('SELECT COUNT(id) FROM users WHERE name LIKE ?', null, array('Antonio')); // Tenemos un valor numérico en el que sabemos el número de usuarios que se llaman Antonio

Creando las clases del ORM

Ahora que ya tenemos nuestro conector con la base de datos, vamos a crear la clase ORM, que extenderán el resto de clases. El ORM se apoyará totalmente en la clase que acabamos de crear.

Veamos el código

class ORM {  
    private static $database;
    protected static $table;
    function __construct() {
        self::getConnection();
    }
    private static function getConnection() {
        require_once ('Database.php');
        self::$database = Database::getConnection(DB_PROVIDER, DB_HOST, DB_USER, DB_PASSWORD, DB_DB);
    }
    public static function find($id) {
        $results = self::where('id', $id);
        return $results[0];
    }
    public static function where($field, $value) {
        $obj = null;
        self::getConnection();
        $query = "SELECT * FROM " . static ::$table . " WHERE " . $field . " = ?";
        $results = self::$database->execute($query, null, array($value));
        if ($results) {
            $class = get_called_class();
            for ($i = 0;$i < sizeof($results);$i++) {
                $obj[] = new $class($results[$i]);
            }
        }
        return $obj;
    }
    public static function all($order = null) {
        $objs = null;
        self::getConnection();
        $query = "SELECT * FROM " . static ::$table;
        if ($order) {
            $query.= $order;
        }
        $results = self::$database->execute($query, null, null);
        if ($results) {
            $class = get_called_class();
            foreach ($results as $index => $obj) {
                $objs[] = new $class($obj);
            }
        }
        return $objs;
    }
    public function save() {
        $values = get_object_vars($this);
        $filtered = null;
        foreach ($values as $key => $value) {
            if ($value !== null && $value !== '' && strpos($key, 'obj_') === false && $key !== 'id') {
                if ($value === false) {
                    $value = 0;
                }
                $filtered[$key] = $value;
            }
        }
        $columns = array_keys($filtered);
        if ($this->id) {
            $columns = join(" = ?, ", $columns);
            $columns.= ' = ?';
            $query = "UPDATE " . static ::$table . " SET $columns WHERE id =" . $this->id;
        } else {
            $params = join(", ", array_fill(0, count($columns), "?"));
            $columns = join(", ", $columns);
            $query = "INSERT INTO " . static ::$table . " ($columns) VALUES ($params)";
        }
        $result = self::$database->execute($query, null, $filtered);
        if ($result) {
            $result = array('error' => false, 'message' => self::$database->getInsertedID());
        } else {
            $result = array('error' => true, 'message' => self::$database->getError());
        }
        return $result;
    }
}

Lo primero que tenemos, son dos propiedades que son obligatorias para el resto de clases. El objeto que tiene la base de datos, y la tabla del modelo. En el caso de que estuviéramos hablando de una tabla de tiendas, ese valor tendría el valor tiendas por ejemplo. La clase, usa métodos de PHP 5.3.

Al construir la clase, lo que hace es obtener la conexión. Recordemos que gracias a que hicimos uso del patrón Singleton, varios objetos reutilizarán la misma clase.

Ahora tenemos dos métodos estáticos, esto es, que no es necesario crear una instancia para poder usarlos. El primero de ellos es find, que lo que hace es encontrar un elemento por su id. Devuelve un objeto de la misma clase, con todos los campos rellenos.

El siguiente método es all, que nos permite traernos todos los elementos de una tabla, y le pasamos un parámetro opcional de ordenación a la consulta. Devuelve una matriz de objetos.

El siguiente método, el método save, ya no es público. Este método está encargado de crear o actualizar un objeto en la base de datos.

¿Qué nos queda?

¡Ahh sí! ¡Implementar una clase!

Veamos por ejemplo una clase del siguiente artículo de nuestro localizador de tiendas.

La clase es Provincia, que es la más sencilla de todas:

class Provincia extends ORM {  
    public $id, $nombre;
    protected static $table = 'provincias';
    public function __construct($data) {
        parent::__construct();
        if ($data && sizeof($data)) {
            $this->populateFromRow($data);
        }
    }
    public function populateFromRow($data) {
        $this->id = isset($data['id']) ? intval($data['id']) : null;
        $this->nombre = isset($data['nombre']) ? $data['nombre'] : null;
    }
}

Como ves, la table es realmente sencilla. El constructor recibe los datos en una matriz. Llama al constructor padre, el ORM, y rellena los datos realizando validaciones sencillas.

De esta forma, podríamos hacer algo así:

$provincias = Provincia::all(' ORDER BY nombre');

Y obtendríamos algo como esto:

Array  
(
    [0] => Provincia Object
        (
            [id] => 27
            [nombre] => Álava
        )

    [1] => Provincia Object
        (
            [id] => 12
            [nombre] => Albacete
        )

    [2] => Provincia Object
        (
            [id] => 20
            [nombre] => Alicante
        )

    [3] => Provincia Object
        (
            [id] => 49
            [nombre] => Almería
        )
...

Así pues, esto es todo por ahora. Ahora ya podemos ponernos manos a la obra con la siguiente parte del tutorial y esto nos servirá de base en otros artículos.

Si el artículo te pareció interesante, útil o incluso equivocado, por favor considera el dejar un comentario. ¡Lo apreciaré mucho!

Programador Front-end en First + Third y Potato. Trabajando con JavaScript y HTML5 desde el corazón de Sevilla.

Comentarios ¡Únete a la conversación!