Mostrando entradas con la etiqueta behavior. Mostrar todas las entradas
Mostrando entradas con la etiqueta behavior. Mostrar todas las entradas

martes, 13 de marzo de 2012

Crear Models y Fixtures para Tests

Una de mis frustraciones con CakePHP es que la documentación para crear Test Unitarios no cubre bien todas las posibilidades. Encontrar algunas soluciones concretas requiere bastante investigación en el manual, API, código del core y en blogs dispersos.

Uno de estos puntos oscuros (para mí) es el Test de Behaviors. Los Behaviors, como sabrás, son extensiones a los Models. Normalmente para crear los tests necesitarás un modelo al que asociarlos y el problema es que, por lo general, este modelo debería ser independiente y específicamente creado para la ocasión, o lo bastante genérico como para no introducir factores indeseados en el proceso de test.

¿Cómo crear esos modelos? Pues esto es lo que voy a intentar contar en este artículo. Esto lo he aprendido estudiando el código de tests de Cake y, aunque no tengo claro que sea todo lo completo que desearía, al menos he conseguido que vaya funcionando para mis propósitos.

Models

Para empezar, los Model para Tests extienden la clase CakeTestModel (que a su vez desciende de Model y se ajusta para utilizar la configuración de base de datos de test por defecto),
Pero nuestros Models no deberían acceder a una base de datos "física", a fin de no introducir elementos de distorsión de los resultados del test.

Esto se consigue definiendo el esquema o estructura de datos en el propio modelo y especificando que no se use una tabla. Aquí tienes un ejemplo:

class Basic extends CakeTestModel {
    var $useTable = false;
    var $name = 'Basic';
    var $_schema = array(
        'id'=> array('type' => 'string', 'null' => '', 'default' => '1', 'length' => '36', 'key'=>'primary'),
        'title'=> array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'),
        'key'=> array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '255'),
    );
}


El lenguaje de esquema de CakePHP es bastante sencillo y te permite definir los tipos de campos que necesites. En este caso, necesitaba dos campos de texto, aparte del campo id.

Pero, ¿dónde defino este modelo?

Pues se me ocurren dos ubicaciones. Si no piensas reutilizarlo, podrías definirlo en el mismo archivo que el test.

Pero si crees que puedes utilizarlo en tests de otros elementos de tu aplicación (otros Behaviors, Controllers, etc) es buena idea guardarlo en un archivo separado e incluirlo cuando sea necesario.
En mi caso, he creado un archivo models.php en la carpeta tests de la aplicación. En este archivo colecciono los modelos creados para tests. Para incluirlo, no tengo más que usar este código:

require_once(TESTS . DS . 'models.php');

Y luego crear una instancia del modelo cuando la necesite con los métodos habituales, ya sea mediante new Model o ClassRegistry.


Los datos

En la mayoría de los tests necesitaré datos en los modelos, para lo cual tendré que crear un archivo de fixtures.

En teoría debería bastar con utilizar la propiedad CakeTestFixture->import y especificar que vamos a usar el modelo creado, pero yo no he conseguido hacerlo funcionar así, por lo que he tenido que especificar los campos del modelo en el archivo de fixtures (basta con copiar y pegar el contenido de Model->_schema). Puede que sea debido al hecho de no utilizar una tabla en la base de datos, sin embargo, no deja de ser un engorro. La parte buena es que estos modelos no tienen que cambiar mucho y realmente no da tanto trabajo.

Incluimos también los registros (records) que necesitemos para empezar. Este es un ejemplo de TEST/fixtures/basic_fixture.php


class BasicFixture extends CakeTestFixture {
    var $name = 'Basic';
    var $fields = array(
        'id'=> array('type' => 'string', 'null' => '', 'default' => '1', 'length' => '36', 'key'=>'primary'),
        'title'=> array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'),
        'key'=> array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '255'),
    );
   var $records = array(
        array(
            'id' => 1,
            'title' => 'Lorem ipsum dolor sit amet',
            'key' => 'lorem_ipsum_dolor_sit_amet',
        ),
    );
}
?>


En el test declaramos que vamos a usar como fixtures app.basic y así estamos listos para trabajar. Aquí tienes un ejemplo de un test que utiliza el modelo Basic.

Conclusiones

Finalmente, tener modelos para usarlos específicamente en tests unitarios es bastante fácil. Son autocontenidos, por lo que no necesitas tener una base de datos y los puedes reutilizar dentro de un mismo proyecto o en otros.

martes, 11 de enero de 2011

Custom find en Behaviors

Trabajando en un Behavior me surgió la pregunta de si es posible definir Custom Finds en un Behavior para que sean usados por el modelo.

El caso es que encontré este código de Nick Baker, en el cual se encuentra una técnica para lograrlo. Una vez vista es obvia (pero había que verla, claro). Copio el código de Nick para explicarlo. En este caso, define una búsqueda que será find('range'), para lo cual hay que crear un método _findRange() en el Behavior.

La técnica tiene dos pasos:

El primero es mapear el método del behavior con un método para el modelo, lo cual se hace con la siguiente línea en las propiedades del Behavior:


var $mapMethods = array('/^_findRange$/' => '_findRange');

En el método setup hay que añadir la una clave al array de _findMethods del modelo que se pasa:

$Model->_findMethods['range'] = true;

Finalmente, la signatura del método es un poco distinta, pues hay que considerar dos parámetros extra

public function _findSearch(&$model, $method, $state, $query, $results = array())

Es decir, CakePHP pasa automáticamente los parámetros $model y $method, antes de los parámetros habituales del find.

A partir de ahora podremos usar el método model->find('range') en el modelo al que hayamos asociado el Behavior que lo contiene.

miércoles, 22 de octubre de 2008

Duplicable behavior

Este behavior facilita la tarea de duplicar registros de un modelo. Puedes tomar un modelo guardado como plantilla y aplicar automáticamente algunos cambios a sus campos, así como duplicar los modelos relacionados si los tiene, etc.

Uso básico:

añade una entrada a tu lista de behaviors:

'duplicable' => array(
'cascade' => true; // Duplicar asociados también
'changeFields' => array(), // List of fields to change their content ...
'changeString' => '%s dup.', // ... applying this format string (%s is a container for the original)
'whitelist' => array(), // List of fields to change (empty will duplicate all). Fields not in list will be empty or default
'associations' => array() // List of associations to duplicate (with cascade = true)
)

lunes, 20 de octubre de 2008

AclUpdate Behavior

Un problema de Acl Behavior es que si cambias el "padre" de un modelo (por ejemplo, si cambias el grupo al que pertenece un usuario), el Nodo Acl correspondiente no se actualiza para reflejar la nueva situación.

Este Behavior sirve precisamente para poder superar este problema. Lo he puesto en el bin.cakephp.org

AclUpdateBehavior

Posiblemente se pueda mejorar bastante.

martes, 7 de octubre de 2008

Upload behavior: para subir archivos (CADUCADO)

CADUCADO: La información de este post apesta por lo vieja. Es posible que ya no sea válida con las versiones más recientes de CakePHP. Se mantiene público  para vergüenza y escarnio del autor.

Aunque no está del todo terminado, lo cierto es que este behavior funciona bastante bien. Básicamente sirve para gestionar la subida de archivos por un formulario. Para usarlo, en el modelo tenemos que poner:

var $actsAs = array ('Upload' => array ('imagen' => array ('ruta' => 'test')));

Upload es, por supuesto, el nombre del behavior.

Imagen, en este caso, es el nombre del campo en el que se sube el archivo. Si vamos a poner varios campos para subir archivos, tendríamos que indicarlos como pares "campo" => "opciones". En el ejemplo, la opción ruta nos permite especificar una ruta específica (bajo el directorio base por defecto) para guardar los archivos subidos mediante ese campo.

Las opciones para cada campo son varias y vienen explicadas en el código, en la definición de var $opcionesDefecto y también un poco más abajo.

No tienes que hacer nada más. El behavior se dispara en el beforeSave. CakePHP pone el array de datos de un archivo recién subido en el campo del modelo. Upload behavior mira a ver si hay algún campo del modelo que contenga ese tipo de estructura y en caso afirmativo lo procesa. Si hay otro tipo de información, simplemente lo ignora.

Una cosa importante es que este behavior puede alterar el nombre del archivo. Si éste contiene caracteres que puedan dar problemas según los servidores, los "sanea". De este modo se previenen problemas posteriores de archivos correctamente subidos que luego no se pueden descargar y similares.

Opciones por archivo subido

Que conste que el código parece complicado porque intento poder gestionar muchas cosas:

  • modo: distintos modos de trabajar con el archivo recién subido. Los modos son : url para indicar que se sube el archivo a una ubicación y el campo se trata como una url para acceder a ese archivo (ideal para imágenes o descargas); ruta para indicar que se sube un archivo y el campo se trata como una ruta del sistema de archivos; contenido es para copiar el contenido del archivo en un campo especificado del modelo. En el futuro podría haber soporte para un modo más: bd, que serviría para almacenar la información en una base de datos, pero para eso hay que crear el modelo y algunas cosas más.
  • ruta: especificar una ruta específica para guardar el archivo que hemos subido.
  • mime_permitidos: controlar qué archivos se pueden subir por su tipo mime general o específico (p.ej, podríamos usar image, o bien image/jpeg). Si no se especifica nada, entonces no se controla este tema.
  • ext_permitidas: controlar qué archivos se pueden subir por su extensión. Si no se especifica, se admite cualquiera.
  • sobreescribir: si existe un archivo con el mismo nombre: true lo machaca.
  • crear_ruta: crea las carpetas necesarias.
  • campo_contenido: copia el contenido del archivo al campo especificado.

El código

Para pegar en /app/models/behaviors/upload.php
Bueno, he cambiado el código y lo he puesto aquí, para que sea más fácil leerlo y descargarlo y también más fácil de mantener actualizado.

lunes, 6 de agosto de 2007

Curiosas propiedades de los behaviors

Recién vuelto (casi) de las vacaciones, hoy he retomado el proyecto Cake en el que estoy metido. Sigo dado toques a la fase de autentificación para mis aplicaciones web. Concretamente estoy trabajando en un behavior que me permita usar cualquier modelo para autentificar.

Esto es, en lugar de tener un Model Usuario con los métodos necesarios, lo que quiero es trasladar la funcionalidad a un Behavior de modo que el modelo quede "liberado" de la parte de autentificación y que ésta sea reutilizable. La cuestión es que las diferentes aplicaciones que tengo que poner en marcha o reescribir tienen distintos requisitos en lo que respecta al modelo de usuario.

Eso me ha permitido aprender unas cuantas cosas acerca del uso de Behaviors aparte de lo que ya había escrito anteriormente.

Una de las más importantes es la capacidad de añadir métodos al Model como si fueran propios, es decir, los puedes llamar con model->metodo (), aunque los hayas definido en el behavior.

Hay que tener una precaución según desde donde llames al método. Si es desde el propio modelo o behavior no ocurre nada especial. Pero si lo llamas desde un controlador, por ejemplo, CakePHP automágicamente añade una referencia al propio modelo como parámetro.

Por otra parte, la capacidad de integración de los behaviors con los models a mí me sigue pareciendo asombrosa. Así, aunque los behaviors no contemplan el callback beforeValidate, es posible, por ejemplo, invalidar campos de beforeSave, para realizar validaciones personalizadas.

El resultado puede parecer un poquito extraño ya que los formularios se validan entonces en dos etapas (la "normal" y la del "behavior") pero ciertamente funciona como debe. Es decir, la aplicación protesta primero por unos campos y luego por otros. Supongo que se puede mejorar esto, pero aún no he llegado tan lejos.

En cualquier caso, el resultado de este "subproyecto" me ha permitido sacar a un behavior todo el código relacionado con la autentificación, incluyendo el procesado de elementos "extraños", como todos los tejemanejes de contraseñas hasheadas procedentes del formulario pero que no son realmente parte del modelo.

Me explico. Al crear un usuario, por ejemplo, la contraseña es "hasheada" en javascript para que viaje encriptada desde del cliente al servidor. El campo hash contiene esta encriptación, mientras que el campo de la contraseña vuelve vacío. Si no hay javascript la contraseña viene desprotegida en su campo El modelo debe entonces controlar esta situación antes de guardar los datos y asegurarse de que el modelo los contiene tal y como los espera la base de datos. Y eso es justamente lo que hace el behavior en beforeSave.

Claro que el behavior forma parte de un paquete que incluye un helper y un component. La idea final es que este paquete sea una "caja negra" y que el Model no tenga que saber nada sobre ella, ocupándose sólo de sus cosas.

Este es el código que habría que poner en models/helpers/autentificacion.php.


<?php
/**
* autentificacion
*
* Created by Frankie on 2007-07-19.
* Copyright (c) 2007 Macintec. All rights reserved.
* 2007-08-06. Added abstraction to valid() method. The behavior now is model independant
* 2007-08-06. Added beforeSave() method to preprocess incoming data
**/

/**
* This behavioir adds Authentitfication abilities to a model, you can assign fields
* to act as user and password fields...
*
* @package autentificacion
* @author Frankie
**/
class AutentificacionBehavior extends ModelBehavior {
var $default_settings = array (
'user' => 'user',
'password' => 'password'
);
var $settings;
var $model;

function beforeSave (&$model) {
/**
* Si el campo hash contiene valores, significa que esa es la contraseña hasheada
* y por lo tanto, debemos poner ese valor en el campo de contraseña para guardar.
* El Javascript habrá borrado la contraseña en clave, por lo que este campo viene
* vaío.
* Por otro lado, si hash no contiene valores, entonces es que el cliente no tiene
* javascript y la contraseña viene abierta en el campo clave. Por último, en caso
* de que estemos editando el usuario, la clave puede venir vacía. En ese caso,
* se elimina el campo clave para no borrar la clave hash en la base de datos.
* El campo Hash se elimna de todas maneras porque no pertenece al modelo.
* TO DO: Establecer como preferencia que se permitan o no claves en abierto
*
* @author Frankie
*/
if (empty ($model->data[$model->name][$this->settings['password']]) && empty ($model->data[$model->name]['hash']) && empty ($model->id)) {
$model->invalidate ($this->settings['password'], __('Please, you must provide a password', true));
return false;
}

if (!empty ($model->data[$model->name]['hash'])) {
$model->data[$model->name]['clave'] = $model->data[$model->name]['hash'];
} elseif (!empty ($model->data[$model->name]['clave'])) {
$model->data[$model->name]['clave'] = sha1 ($model->data[$model->name]['clave']);
}
if (empty ($model->data[$model->name]['clave']) && !empty ($model->id)) {
unset ($model->data[$model->name]['clave']);
}
unset ($model->data[$model->name]['hash']);
return true;
}

function setup (&$model, $settings) {
// TODO: verificar que los campos pertenecen al modelo ???
$this->settings = am ($this->default_settings, $settings);
$this->model = $model;
}

/**
* Returns user record if the user is a registered user
*
* @return boolean true if user and password exists
* @author Frankie
**/
function valid (&$model, $userData, $salt = false) {
$user = $userData[$model->name][$this->settings['user']];
$password = $userData[$model->name][$this->settings['password']];
if ($salt) {
// User validation with salt
$conditions = "WHERE {$this->settings['user']} = '$user' AND sha1(concat({$this->settings['password']},'$salt')) = '$password'";
} else {
// User validation without salt, insecure login
$conditions = "WHERE {$this->settings['user']} = '$user' AND {$this->settings['password']} = '$password'";
}
if ($userFound = $this->model->find ($conditions)) {
return $userFound;
}
return false;
}


} // END class AutentifiacionBehavior extends ModelBehavior


?>




Y esta es la forma de usarlo en un modelo cualquiera. El modelo tiene los campos id, usuario, clave, email.

Al asociar el behavior indicamos que utilice usuario como campo user y clave como campo password.


<?php
/**
* usuario
*
* Created by Frankie on 2007-07-04.
* Copyright (c) 2007 Macintec. All rights reserved.
**/

/**
* Usuario
*
* @package /Applications/MAMP/htdocs/micake
* @author Fran Iglesias
* @copyright Copyright 2007 Fran Iglesias
* @version Revision: 0.1
* @modifiedby Fran Iglesias
* @lastmodified 2007-07-04
* @license http://www.opensource.org/licenses/mit-license.php The MIT License
**/

class Usuario extends AppModel
{
var $name = "Usuario";
var $validate = array (
'usuario' => array (
'esUnico' => array (
'rule' => 'usuarioEsUnico',
'message' => 'User name exists. Please, choose another.'
),
'usuarioNoVacio' => array (
'rule' => VALID_NOT_EMPTY,
'message' => 'User name empty. Please, give it a name.'
)
),
'email' => array (
'emailValido' => array (
'rule' => VALID_EMAIL,
'message' => 'Please, type a valid email.'
)
)
);

var $actsAs = array ('Autentificacion' => array ('user' => 'usuario', 'password' => 'clave'));

/**
* Verifica que el nombre de usuario aportado no existe en la bd
*
* @return boolean true if unique
* @author Frankie
**/
function usuarioEsUnico ($nombreUsuario) {
$esValido = false;
$condiciones['usuario'] = $nombreUsuario;
if ($this->id) {
$condiciones['id'] = '<> '.$this->id;
}
$esValido = $this->isUnique ($condiciones, false);
return $esValido;
}

}

// END class Usuario
?>

jueves, 7 de junio de 2007

FlatTable Behavior, unifica los resultados de un modelo y sus belongsTo

Bueno, de Behavior a Helper y, de nuevo, Behavior.

Este Behavior sirve básicamente para fusionar los resultados de asociaciones belongsTo de un modelo para que "parezca" que son parte del mismo modelo.

La idea es poder pasar esos resultados a la vista y generar una tabla simple (tengo un helper para eso). Un ejemplo lo aclara:

Tengo un modelo Recurso, que guarda información de una serie de aparatos disponibles en el servicio de Nuevas Tecnologías del colegio en que trabajo. Los Recursos se organizan en Categorías, de modo que cada Recurso pertenece (belongsTo) a una Categoría.

Lógicamente los usuarios querrían ven la lista de recursos con su Categoría (no su id), por lo que a la hora de generar la vista tendría que combinar los datos. La misión de este Behavior es proporcionarme un método general para hacerlo que no me complique mucho las vistas.

El Behavior me permite, entonces, obtener la lista de Recursos y fundir los campos de Categoria de modo que se presenten en una tabla simple.

Posibles mejoras

El behavior tendría que comprobar algunos puntos que ahora mismo quedan al buen criterio a la hora de llamar.

Estoy valorando la posibilidad de que siempre haya que activarlo explícitamente mediante el flag Model::useFlatBehavior, haciendo que este se ponga en false nada más utilizarlo. De este modo me aseguro de no obtener resultados inconsistentes en según qué situaciones.

A lo mejor hay que ponerlo en otro Behavior, pero se me ocurre que otra "fusión" útil sería fundir campos de modelos asociados mediante hasMany para que se muestren como un sólo campo del modelo principal.

Instrucciones
  1. Copia el código que está más abajo y ponlo en /app/models/behaviors/flat_table.php
  2. En el modelo, especifica Model::actsAs para que incluya FlatTable, puedes pasarle un array con los modelos y campos que quieres fusionar.
  3. Establece una variable del modelo Model::useFlatTable = false; para especificar cuándo quieres usar o no el behavior. Yo la inicializo a false para tener que activarla expresamente.
  4. Antes de solicitar los datos en el Controlador, activa el flag anterior mediante $this->Model->useFlatTable = true; sustituye Modelo por el nombre de tu modelo, claro.
La forma de especificar en Model::actAs es la siguiente:

array ('FlatTable' => array ('Modelo 1 para fusionar' => array ('campos', 'del', 'modelo 1')

Si no especificas campos, se fusionarán todos. Puedes especificar solo un campo con la estructura:

array ('Modelo 1' => 'campo del modelo')

O sólo un modelo:

array ('FlatTable' =>'Modelo 1 para fusionar')

Un ejemplo de uso en el modelo:

class Recurso extends AppModel
{
var $name = 'Recurso';

var $actsAs = array (
'Upload' => array ('imagen' => array ('ruta' => 'test')),
'FlatTable' => array ('Categoria' => 'categoria')
);
var $useFlatTable = false;
var $belongsTo = array ('Categoria');

...
} // END class Recurso



FlatTable

Pon esto en:

/app/models/behaviors/flat_table.php


/**
* Behavior para unir los datos de una relación belongsTo de modo que se devuelvan
* como una sola tabla. Básicamente se pretende facilitar el uso de tablas sencillas
* cuando realmente no se necesita más.
*
* This Behavior joins data from belongsTo associations returning the array as it was a single model.
* The point here is to pass the data to a simple table view. foreign key deleted from the
* resulting array
*
* @package default
* @author Fran Iglesias
* @version 0.1
**/
class FlatTableBehavior extends ModelBehavior {
// Array que especifica modelos/campos para unir
var $queUnir = array ();

/**
* Parse settings, a list of associated models, and fields that must be merged with primary
* Cargar e interpretar los ajustes, una lista de modelos asociados y campos que deben mezclarse con el primario
*
* @return void
* @author Frankie
**/

function setup (&$model, $settings = false) {
// Si no se pasan settings, no usar el Behavior
// Don't use behavior if no settings passed
if (!$settings) {
$model->useFlatTable = false;
return;
}
// Convertir la cadena en array si es necesario, se asume que se pasa un modelo
// String to Array conversion if needed, $settings is a Model
if (!is_array ($settings)) {
$settings = array ($settings => array ());
}
// Parse settings to ensure right format
// Revisar settings para asegurarse de que el formato es correcto
foreach ($settings as $modelo => $campos) {
if (is_numeric ($modelo)) {
$modelo = $campos;
$campos = array ();
}
if (!is_array ($campos)) {
$campos = array ($campos);
}
$this->queUnir[$modelo] = $campos;
}
}

/**
* Modifica la estructura del array de datos de modo que simula que los datos
* entregados tras una relación belongsTo son de una única tabla
*
* Flatten array as it was a proper Model array
*
* @return void
* @author Frankie
**/

function afterFind (&$model, $results, $primary) {

// Comprobar el flag useFatTable
// Check model::useFatTable
if (empty ($model->useFlatTable) ) {
return true;
}
// No hacer nada si los datos vuelven vacíos
if (empty ($results)) {
return true;
}
// No hacer nada si el modelo primaria no está
if (!isset ($results[0][$model->name])) {
return true;
}
// Obtener los modelos que podría unir con este sistema
// What models can we join?
$belongsTo = array_keys ($model->belongsTo );
$modelosEnResultado = array_keys ($results[0]);
$unir = array_intersect ($belongsTo, $modelosEnResultado);
//
$recordSet = array ();
foreach ($results as $clave => $fila) {
$record = $fila[$model->name];
foreach ($unir as $modelo) {
// Quitar las foreignKeys, strip foreignkeys (¿Settings for this?)
$foreignKey = strtolower ($modelo).'_'.'id';
unset ($record[$foreignKey]);
if (!empty ($this->queUnir[$modelo])) {
foreach ($this->queUnir[$modelo] as $campo) {
$record[$campo] = $fila[$modelo][$campo];
}
} else {
// Strip associated model id key
unset ($fila[$modelo]['id']);
$record = array_merge ($record, $fila[$modelo]);
}
}
$recordSet[][$model->name] = $record;
}
return $recordSet;
}

} // END class FlatTableBehavior extends Behavior

?>