Etiquetas

, ,

Voy a comentar un truco muy sencillo, pero que no recuerdo haberlo visto implementado (si no lo provee ya el framework que se use), a pesar de lo que simplifica la programación a la hora de almacenar en base de datos relacionales propiedades arbitrarias de un objeto. El siguiente es parte del código de profile.php de Menéame que almacena la «bio» de un usuario.

if(!empty($_POST['bio']) || $user->bio) {
	...
	if ($bio != $user->bio) $user->bio = $bio;
}

Este otro de libs/user.php, y almacena en la base de datos las actualizaciones del karma de un usuario (y su hora de modificación).

if (! empty($log) && mb_strlen($log) > 5) {
	$this->karma_log .= "$log: $inc, " . _('nuevo karma') . ": $this->karma\n";
	$this->karma_calculated = time();
}

Nota: «.=» es una concatenación, implica dos operaciones, «get» y luego «set».

Las propiedades bio, karma_log y karma_calculated se almacenan en la base de datos, pero no están en la base de datos de usuarios como las demás. Como no son parte del fastpath (no se consultan ni visualizan en las páginas más vistas) estos datos se almacenan por separado, en la tabla genérica annotations.

Sin embargo, a la hora de acceder o almacenar esos datos, no tenemos que preocuparnos de leer e insertar en la base de datos, se hace de forma «automática». Explico cómo hacerlo, es un buen patrón cuando se requieran almacenar y acceder a propiedades arbitrarias de un objeto. Además sirve para cualquier lenguaje orientado a objetos.

La tabla annotations en Menéame emula a varios sistemas de NoSQL del tipo clave-valor (donde en valor se puede almacenar cualquier estructura de datos, en nuestro caso objetos serializados en JSON, tal como hace CouchDB), además tiene unos campos adicionales como «caducidad» (similar a memcached). Esta tabla nos permite tener las ventajas de un «NoSQL», sin la necesidad de tener sistemas y bases de datos diferentes (además el MySQL es muy rápido, e InnoDB optimiza los índices de este tipo manteniendo en memoria índices por hashes).

CREATE TABLE `annotations` (
  `annotation_key` char(64) NOT NULL,
  `annotation_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `annotation_expire` timestamp NULL DEFAULT NULL,
  `annotation_text` text,
  PRIMARY KEY (`annotation_key`),
  KEY `annotation_expire` (`annotation_expire`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

(El código, annotation.php, que gestiona las «anotaciones» de forma genérica -solo clave y valor, ambos strings-)

El objetivo de esta tabla es general, almacenar datos que no son accedidos frecuentemente, y que no se requieran para establecer relaciones, no hacer uniones (o JOINS). En los dos ejemplos citados, se almacena la «bio» de cada usuario (visible en su perfil), y los registros del karma (que lo puede consultar cualquier usuario). Además aprovecha los métodos __get() y __set() del PHP (similar a __getattr__ () y __setattr__() de Python) que se ejecutan al acceder o asignar a una propiedad no definida.

El objetivo lo que comentaré es almacenar y leer «automáticamente» de la tabla annotations un conjunto de propiedades arbitrarias de un objeto. Para ellos usaremos un diccionario (o array asociativo) $meta para almacenar el nombre y valor de esas propiedades. También usamos $meta_modified para saber si ha sido modificada y hay que almacenarla en la base de datos:

class User {
	protected $meta = false;
	protected $meta_modified = false;

Ahora veamos el método __get(), que se llama siempre que se hace referencia (acceder al valor) de una propiedad indefinida:

function __get($property) {
	if (! User::meta_valid($property) ) return false;
	if ($this->meta === false && ! $this->meta_read() ) {
		return false;
	}
	if (isset($this->meta[$property])) {
		return $this->meta[$property];
	}
	return false;
}

El primer if es una comprobación de que la propiedad sea una de las que nos interese (no queremos interferir con otras variables, ni almacenarlas a todas las que se puedan usar en el código).

El segundo if verifica que $meta esté inicializada, o que se puede leer de la base de datos (de la tabla annotations), si no es así, retorna falso.

El tercer if verifica que exista la propiedad en el diccionario $meta, si es así devuelve su valor, si no, falso.

Esta es la función que verifica que la propiedad sea una de las que nos interese, sólo para asegurar que no se interfieren con otras variables (i.e. evitar efectos colaterales adversos), y almacenar sólo aquellas que nos interesen. Pero no es obligatorio, y se puede implementar con otros métodos, como verificar en otro diccionario, o que su nombre comience con algunos caracteres especiales, por ejemplo «__propiedad»:

// Variables that are accepted as "meta" (to avoid storing all
static function meta_valid($property) {
	switch ($property) {
		case 'bio':
		case 'karma_log':
		case 'karma_calculated':
			return true;
		default:
			return false;
	}
}

Esta es el método meta_read() que se llama desde __get() si el diccionario no está leído.

function meta_read() {
	$m = new Annotation("user_meta-$this->id");
	if (! $m->read() || ! ($this->meta = json_decode($m->text, true)) ) {
		$this->meta = array();
		return false;
	}
	return true;
}

La clave es user_meta más el id del usuario, que asegura su unicidad. En el valor (o text) está almacenado el diccionario $meta codificado en json, para leerlo se decodifica y retorna como array (es el argumento true en json_decode()). Si el diccionario no estaba previamente almacenado (es decir, no se puede leer de annotations) inicializa $meta a un array vacío.

Ahora la implementación del método __set():

function __set($property, $value) {
	if (! User::meta_valid($property) ) {
		$this->$property = $value;
		return;
	}
	if ($this->meta === false) {
		$this->meta_read();
	}
	$this->meta[$property] = $value;
	$this->meta_modified = true;
}

El primer if controla que sea una de las variables que nos interesa almacenar, si no es así «crea» esa propiedad como una más, sin almacenarla en el diccionario. Si la propiedad es «prop», es equivalente a «$this->prop = $value». La asignación a esa propiedad no existente es perfectamente válida, como el nombre (en $property) es igual al nombre de la propiedad, no se vuelve a llamar a __set(), por lo que se evitan bucles).

El segundo if llama a meta_read() si $meta no ha sido inicializada como un array.

Las siguientes líneas almacenan el valor de la propiedad en $meta (donde el nombre de la propiedad es la clave) e indica que ha sido modificado.

La siguiente es el método para almacenar $meta en annotations:

function meta_store() {
	if (! is_array($this->meta)) return;
	$m = new Annotation("user_meta-$this->id");
	$m->text = json_encode($this->meta);
	$m->store();
	$this->meta_modified = false;
}

Se puede ver claramente que verifica sea un array, si es así codifica en json y almacena en la tabla (usando los métodos de annotation.php enlazado previamente).

Eso es todo, ahora sólo agregamos la llamada a meta_store() en el método original que almacena las propiedades de usuarios en su tabla users:

function store($full_save = true) {
	...
	if ($this->meta_modified) $this->meta_store();
}

Como ejemplo de uso. Así se visualiza el registro del cálculo de mi karma en Menéame:

Así es como están almacenados esos datos en la tabla annotations (junto con la «bio»):

El código que genera el HTML para la llamada AJAX queda así de sencillo y breve (veréis que se accede a $user->karma_log directamente, sin llamadas especiales, a pesar que al crearse el objeto $user no se lee desde la base de datos).