#215 Advanced Queries in Rails 3
- Download:
- source codeProject Files in Zip (105 KB)
- mp4Full Size H.264 Video (17.5 MB)
- m4vSmaller H.264 Video (10.2 MB)
- webmFull Size VP8 Video (30.1 MB)
- ogvFull Size Theora Video (27.6 MB)
En este episodio trataremos el uso de consultas avanzadas con Rails 3. En el episodio 202 [verlo, leerlo] vimos las novedadades de ActiveRecord en Rails 3; en esta ocasión continuaremos a partir de ahí y veremos algunos usos más avanzados.
Uso de métodos de clase en lugar de ámbitos
En la aplicación de ejemplo que vamos a usar tenemos dos modelos: Product
y Category
, donde un producto pertenece a una categoría. El modelo de producto tiene dos ámbitos con nombre: discontinued
, que devuelve todos aquellos productos cuyo atributo discontinued
tiene el valor true
, y price
, que devuelve todos los productos cuyo precio es inferior a un argumento dado.
class Product < ActiveRecord::Base belongs_to :category scope :discontinued, where(:discontinued => true) scope :cheaper_than, lambda { |price| where("price < ?", price) } end
En el segundo ámbito estamos usando una función lambda. Si algunas vez tenemos que usar una de estas en un ámbito con nombre deberíamos considerar utilizar en su lugar un método de clase, sobre todo si estamos pasando un gran número de parámetros o si el contenido del ámbito es complejo. Nuestro caso es muy sencillo pero de todas formas lo vamos a poner como método de clase:
class Product < ActiveRecord::Base belongs_to :category scope :discontinued, where(:discontinued => true) def self.cheaper_than(price) where("price < ?", price) end end
El comportamiento del método de clase será exactamente el mismo que el del ámbito. Esto también se podía hacer también con Rails 2, pero el comportamiento en Rails 3 es mucho mejor, incluso podemos reutilizar este método en otro ámbito. Por ejemplo si queremos crear un ámbito llamado cheap
que devuelva los productos cuyo precio sea inferior a 5€ podemos escribirlo así:
scope :cheap, cheaper_than(5)
Hay que tener, sin embargo, cuidado con una trampa. Si usamos un método de clase en un ámbito tenemos que declarar el ámbito después de que se haya definido el método de clase lo que quiere decir que tendremos que poner el ámbito un poco más abajo de lo que es habitual en el fichero de la clase.
Podemos ver la consulta SQL que genera nuestro ámbito con la consola de Rails.
ruby-1.8.7-p249 > Product.cheap.to_sql => "SELECT \"products\".* FROM \"products\" WHERE (price < 5)"
Si aplicamos el ámbito veremos el listado de productos baratos.
>Product.cheap => [#<Product id: 1, name: "Top", price: 4.99, discontinued: nil, category_id: 3, created_at: "2010-05-24 21:01:59", updated_at: "2010-05-24 21:01:59">, #<Product id: 2, name: "Milk", price: 2.99, discontinued: nil, category_id: 2, created_at: "2010-05-24 21:02:38", updated_at: "2010-05-24 21:02:38">]
Asociaciones
Aprovechando que estamos en la consola veamos otro truco que tiene que ver con el uso de ámbitos sobre asociaciones. Antes decíamos que en nuestra aplicación un producto pertenece a una categoría. Esto quiere decir que podemos usar joins
para devolver una consulta SQL que devuelve un inner join sobre la tabla de productos y categorías.
ruby-1.8.7-p249 > Category.joins(:products).to_sql => "SELECT \"categories\".* FROM \"categories\" INNER JOIN \"products\" ON \"products\".\"category_id\" = \"categories\".\"id\""
Queremos encontrar todas las categorías con poductos que pertenecen a un cierto ámbito, por ejemplo todas las categorías que contienen productos que cuestan menos de cinco euros. Podemos emplear dos métodos distintos para hacerlo. El primero sería usar merge
de esta manera:
> Category.joins(:products).merge(Product.cheap) => [#<Category id: 3, name: "Games", created_at: "2010-05-24 21:00:57", updated_at: "2010-05-25 18:30:18">, #<Category id: 2, name: "Groceries", created_at: "2010-05-24 21:00:50", updated_at: "2010-05-25 18:30:39">]
O podríamos utilizar el símbolo &
que es un alias de lo mismo y por tanto devolverá los mismos resultados:
> Category.joins(:products) & Product.cheap => [#<Category id: 3, name: "Games", created_at: "2010-05-24 21:00:57", updated_at: "2010-05-25 18:30:18">, #<Category id: 2, name: "Groceries", created_at: "2010-05-24 21:00:50", updated_at: "2010-05-25 18:30:39">]
Con estos métodos podemos encadenar otras consultas incluso aunque no sean sobre los mismo modelos, y esto es lo que nos permitirá encontrar las categorías con productos que cuestan menos de cinco euros. Si llamamos to_sql
en las consultas veremos que se ha hecho un inner join y se ha añadido al final la clásula WHERE
del ámbito.
> (Category.joins(:products) & Product.cheap).to_sql => "SELECT \"categories\".* FROM \"categories\" INNER JOIN \"products\" ON \"products\".\"category_id\" = \"categories\".\"id\" WHERE (price < 5)"
Esta técnica es bastante potente si se utiliza para definir un ámbito. Podemos usarla, por ejemplo, para crear un ámbito en el modelo Category
que encuentre las categorías que tengan productos baratos.
class Category < ActiveRecord::Base has_many :products scope :with_cheap_products, joins(:products) & Product.cheap end
Este nuevo ámbito devolverá las mismas categorías que la consulta que escribimos anteriormente.
> Category.with_cheap_products => [#<Category id: 3, name: "Games", created_at: "2010-05-24 21:00:57", updated_at: "2010-05-25 18:30:18">, #<Category id: 2, name: "Groceries", created_at: "2010-05-24 21:00:50", updated_at: "2010-05-25 18:30:39">]
Cuando trabajemos con estas condiciones que cubren diferentes asociaciones hay que tener en cuenta el nombre de la tabla. Si examinamos el SQL generado al llamar al ámbito veremos que no hay un nombre de tabla en la cláusula WHERE
.
> Category.with_cheap_products.to_sql => "SELECT \"categories\".* FROM \"categories\" INNER JOIN \"products\" ON \"products\".\"category_id\" = \"categories\".\"id\" WHERE (price < 5)"
Esto podría ser un problema si ambas tablas tienen una columna llamada price
porque en tal caso la consulta SQL sería ambigua. Podemos eliminar esta ambiguedad definiendo explícitamente el nombre de la tabla en el modelo Product
.
def self.cheaper_than(price) where("products.price < ?", price) end
Con esto ya no hay peligro de que el nombre de la columna sea ambiguo. Siempre hay que especificar el nombre de las tablas cuando se construyen cadenas SQL en las condiciones de una consulta. Si estamos usando un hash (como en el ámbito) entonces no hay que preocuparse de la tabla porque Rails automáticamente lo pone.
Construcción de registros a través de ámbitos
Lo siguiente que veremos es cómo crear registros con ámbitos. Tenemos un ámbito definido sobre nuestro modelo Product
llamado discontinued
que encuentra todos los productos descatalogados.
> Product.discontinued => [#<Product id: 3, name: "Some DVD", price: 13.49, discontinued: true, category_id: 1, created_at: "2010-05-25 19:45:05", updated_at: "2010-05-25 19:45:05">]
Como el ámbito utiliza un hash podemos invocar el método build
sobre él para construir un nuevo producto, que tendrá el atributo discontinued
puesto a true
.
> p = Product.discontinued.build => #<Product id: nil, name: nil, price: nil, discontinued: true, category_id: nil, created_at: nil, updated_at: nil>
Esto es así porque el atributo discontinued
forma parte de la condición where
, funcionando de forma parecida a una asociación en el sentido de que si se invoca build
sobre una asociación automáticamente se pone la clave foránea que está asociada con dicho registro. En nuestro caso estamos tan sólo asignando valores a atributos que aparecen en la condición where
. Es algo muy útil a tener en cuenta si necesitamos crear registros que cumplan un cierto ámbito.
Arel
Como colofón a este episodio presentaremos Arel. Arel se encarga de generar las consultas de ActiveRecord, será poco frecuente que tengamos que interactuar directamente con Arel pero es bueno tener un conocimiento de cómo funciona y qué puede hacer en caso de que nos haga falta.
Para trabajar con Arel directamente en Rails 3 se puede acceder a la arel_table
de un modelo. Vamos a recuperar la tabla de Arel para el modelo de producto en la consola y se la asignaremos a una varialbe.
> t = Product.arel_table
Se trata de una representación de la tabla de productos. Podemos acceder a las columnas de la tabla así:
>t[:price] => <Attribute price>
Y sobre este objeto vamos a invocar métodos sobre un atributo para obtener condiciones de búsqueda. Por ejemplo, si queremos encontrar todos los productos que cuestan 2.99€ podemos ejecutar
> t[:price].eq(2.99) => #<Arel::Predicates::Equality:0x1040dd9f0 @operand1=<Attribute price>, @operand2=2.99>
Esto devuelve un predicado (que básicamente es la condición de búsqueda) Existen también otros métodos que podríamos llamar, como por ejemplo matches
que ejecutará una consulta LIKE
.A Al igual que con otras consultas podemos ejecutar to_sql
para examinar la sentencia SQL que generará esta consulta.
> t[:name].matches('%lore').to_sql => "\"products\".\"name\" LIKE '%lore'"
Podemos usar el método or
para encadenar predicados, por ejemplo para encontrar los productos que cuestan 2.99€ y cuyo nombre termina en ‘lore’.
t[:price].eq(2.99).or(t[:name].matches('%lore'))
Esto devolverá un predicado combinado, y podemos ver la consulta SQL al igual que antes invocando to_sql
.
> t[:price].eq(2.99).or(t[:name].matches('%lore')).to_sql => "(\"products\".\"price\" = 2.99 OR \"products\".\"name\" LIKE '%lore')"
Podemos pasar estos predicados como argumentos al método where
de ActiveRecord. Esto significa que podemos pasar el predicado que acabamos de crear a Product.where
para recuperar todos los productos cuyo precio sea de 2.99€ o cuyo nombre termine en ‘lore’.
> Product.where(t[:price].eq(2.99).or(t[:name].matches('%lore'))) => [#<Product id: 2, name: "Milk", price: 2.99, discontinued: nil, category_id: 2, created_at: "2010-05-24 21:02:38", updated_at: "2010-05-24 21:02:38">, #<Product id: 4, name: "Knight Lore", price: 2.99, discontinued: nil, category_id: nil, created_at: "2010-05-26 19:36:02", updated_at: "2010-05-26 19:36:02">]
Tan sólo hemos arañado la superficie que nos revela ActiveRecord de Arel, que es capaz de mucho más. Hay varios plugins disponibles que aprovechan la potencia de Arel y le dan una nterfaz más conveniente como MetaWhere con el cual podemos acceder a métodos de Arel como matches
y eq
dentro del hash de condiciones así:
Product.where(:price.eq => 2.99, :name.matches => '%lore')
Esto nos da mucha más flexibilidad a la hora de definir el hash de condiciones, y merece la pena tenerlo en cuenta si vamos a necesitar hacer consultas más complejas sobre nuestros modelos.