GithubHelp home page GithubHelp logo

sexy_scopes's Introduction

WARNING: UNMAINTAINED AT THE MOMENT, SORRY :(

SexyScopes

Gem Version Dependencies Code Climate Build Status Coverage Status

Write beautiful and expressive ActiveRecord scopes without SQL

SexyScopes is a gem that adds syntactic sugar for creating ActiveRecord scopes in Ruby instead of SQL. This allows for more expressive, less error-prone and database independent conditions.

WARNING: This gem requires Ruby >= 2.0.0 and ActiveRecord >= 4.2

Usage & Examples

Let's define a Product model with this schema:

# price     :integer
# category  :string
# draft     :boolean
class Product < ActiveRecord::Base
end

Now take a look at the following scope:

scope :visible, -> { where('category IS NOT NULL AND draft = ? AND price > 0', false) }

Hum, lots of SQL, not very Ruby-esque...

With SexyScopes

scope :visible, -> { where((category != nil) & (draft == false) & (price > 0)) }

Much better! Looks like magic? It's not.

category, draft and price in this context are methods representing your model's columns. They respond to Ruby operators (like <, ==, etc.) and can be combined with logical operators (& and |) to express complex predicates.


Let's take a look at another example with these relations:

# rating:  integer
# body: text
class Post < ActiveRecord::Base
  has_many :comments
end

# post_id:  integer
# rating:   integer
# body: text
class Comment < ActiveRecord::Base
  belongs_to :post
end

Now let's find posts having comments with a rating greater than a given rating in a controller action:

Without SexyScopes

@posts = Post.joins(:comments).where('rating > ?', params[:rating])

This expression, while syntactically valid, raises the following exception:

ActiveRecord::StatementInvalid: ambiguous column name: rating

Because both Post and Comment have a rating column, you have to give the table name explicitly:

@posts = Post.joins(:comments).where('comments.rating > ?', params[:rating])

With SexyScopes

Since Comment.rating represents the rating column of the Comment model, the above can be rewritten as such:

@posts = Post.joins(:comments).where { rating > params[:rating] }

Here you have it, clear as day, still protected from SQL injection.

Installation

Add this line to your application's Gemfile:

gem 'sexy_scopes'

And then execute:

bundle

Or install it yourself as:

gem install sexy_scopes

Then require it in your application code:

require 'sexy_scopes'

How does it work ?

SexyScopes is essentially a wrapper around Arel attribute nodes.

It introduces a ActiveRecord::Base.attribute(name) class method returning an Arel::Attribute object, which represent a table column with the given name, that is extended to support Ruby operators.

For convenience, SexyScopes dynamically resolves methods whose name is an existing table column: i.e. Product.price is a shortcut for Product.attribute(:price).

Please note that this mechanism won't override any of the existing ActiveRecord::Base class methods, so if you have a column named name for instance, you'll have to use Product.attribute(:name) instead of Product.name (which would be in this case the class actual name, "Product").

Here is a complete list of operators, and their Arel::Attribute equivalents:

  • Predicates:

    • ==: eq
    • =~: matches
    • !~: does_not_match
    • >=: gteq
    • > : gt
    • < : lt
    • <=: lteq
    • !=: not_eq
  • Logical operators:

    • &: and
    • |: or
    • ~: not

Block syntax

SexyScopes introduces a new block syntax for the where clause, which can be used in 2 different forms:

  • With no argument, the block is evaluated in the context of the relation
# `price` is `Product.price`
Product.where { price < 500 }

# `body` is `post.comments.body`
post.comments.where { body =~ "%ruby%" }
  • With an argument, block is called with the relation as argument
# `p` is the `Product` relation
Product.where { |p| p.price < 500 }

# `c` is the `post.comments` relation
post.comments.where { |c| c.body =~ "%ruby%" }

These 2 forms are functionally equivalent. The former, while being more concise, is internally implemented using instance_eval, which will prevent you from calling method on the receiver (self).

Tip: Try switching to the later form if you encounter NoMethodError exceptions.

Note that you can also use this syntax with where.not:

Product.where.not { price > 200 }

Regular Expressions

Did you know that most RDBMS come with pretty good support for regular expressions?

One reason they're quite unpopular in Rails applications is that their syntax is really different amongst databases implementations. Let's say you're using SQLite3 in development, and PostgreSQL in testing/production, well that's quite a good reason not to use database-specific code, isn't it?

Once again, SexyScopes comes to the rescue: The =~ and !~ operators when called with a regular expression will generate the SQL you don't want to know about.

predicate = User.username =~ /^john\b(.*\b)?doe$/i

# In development, using SQLite3:
predicate.to_sql
# => "users"."username" REGEXP '^john\b(.*\b)?doe$'

# In testing/production, using PostgreSQL
predicate.to_sql
# => "users"."username" ~* '^john\b(.*\b)?doe$'

Now let's suppose that you want to give your admin a powerful regexp based search upon usernames, here's how you could do it:

class Admin::UsersController
  def index
    query = Regexp.compile(params[:query])
    @users = User.where { username =~ query }
    respond_with @users
  end
end

Let's see what happens in our production logs (SQL included) when they try this new feature:

Started GET "/admin/users?query=bob%7Calice" for xx.xx.xx.xx at 2014-03-31 16:00:50 +0200
  Processing by Admin::UsersController#index as HTML
  Parameters: {"query"=>"bob|alice"}
  User Load (0.1ms)  SELECT "users".* FROM "users"  WHERE ("users"."username" ~ 'bob|alice')

The proper SQL is generated, protected from SQL injection BTW, and from now on you have reusable code for both you development and your production environment.

Advanced Examples

# radius:  integer
class Circle < ActiveRecord::Base
  # Attributes can be coerced in arithmetic operations
  def self.perimeter
    2 * Math::PI * radius
  end

  def self.area
    Math::PI * radius * radius
  end
end

Circle.where { perimeter > 42 }
# SQL: SELECT `circles`.* FROM `circles`  WHERE (6.283185307179586 * `circles`.`radius` > 42)
Circle.where { area < 42 }
# SQL: SELECT `circles`.* FROM `circles`  WHERE (3.141592653589793 * `circles`.`radius` * `circles`.`radius` < 42)

class Product < ActiveRecord::Base
  predicate = (attribute(:name) == nil) & ~category.in(%w( shoes shirts ))
  puts predicate.to_sql
  # `products`.`name` IS NULL AND NOT (`products`.`category` IN ('shoes', 'shirts'))

  where(predicate).all
  # SQL: SELECT `products`.* FROM `products` WHERE `products`.`name` IS NULL AND
  #      NOT (`products`.`category` IN ('shoes', 'shirts'))
end

Contributing

All suggestions, ideas and contributions are very welcome.

If you want to contribute, please follow the steps described in CONTRIBUTING.md

Copyright

SexyScopes is released under the MIT License.

Copyright (c) 2010-2017 Samuel Lebeau, See LICENSE for details.

sexy_scopes's People

Contributors

joshk avatar samleb avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

sexy_scopes's Issues

The gemspec needs to be fixed

It has a dependency on activerecord of "~> 3.0.0.beta". This sometimes causes problems. For example, in a Gemfile I have:

gem 'rails', '~> 3.0.0'
gem 'sexy_scopes'

When I go to do a bundle update, bundler fails with:

Bundler could not find compatible versions for gem "activerecord":
  In Gemfile:
    rails (~> 3.0.0) depends on
      activerecord (= 3.0.9)

    sexy_scopes depends on
      activerecord (3.1.0.rc4)

The gemspec obviously doesn't specify a dependency on 3.1.0.rc4. I wonder if changing the gemspec to say '~> 3.0.0' would fix this?

Incompatible with ActiveRecord::Base.table_name=

Updated to 0.5.0 from 0.2.0 and errors are being thrown, caused by models that use Klass.table_name= method for legacy database table names. A sample error:

$ bundle exec rspec
/ruby-path/gems/activerecord-3.2.9/lib/active_record/connection_adapters/sqlite_adapter.rb:472:in `table_structure': Could not find table 'campaign_batch_jobs' (ActiveRecord::StatementInvalid)
  from /ruby-path/gems/activerecord-3.2.9/lib/active_record/connection_adapters/sqlite_adapter.rb:346:in `columns'
  from /ruby-path/gems/activerecord-3.2.9/lib/active_record/connection_adapters/schema_cache.rb:12:in `block in initialize'
  from /ruby-path/gems/activerecord-3.2.9/lib/active_record/model_schema.rb:228:in `yield'
  from /ruby-path/gems/activerecord-3.2.9/lib/active_record/model_schema.rb:228:in `default'
  from /ruby-path/gems/activerecord-3.2.9/lib/active_record/model_schema.rb:228:in `columns'
  from /ruby-path/gems/activerecord-3.2.9/lib/active_record/model_schema.rb:248:in `column_names'
  from /ruby-path/gems/sexy_scopes-0.5.0/lib/sexy_scopes/active_record.rb:49:in `respond_to?'
  from /ruby-path/gems/activerecord-3.2.9/lib/active_record/scoping/named.rb:194:in `valid_scope_name?'
  from /ruby-path/gems/activerecord-3.2.9/lib/active_record/scoping/named.rb:176:in `scope'
  from /ruby-path/gems/kaminari-0.13.0/lib/kaminari/models/active_record_model_extension.rb:12:in `block in <module:ActiveRecordModelExtension>'
  from /ruby-path/gems/activesupport-3.2.9/lib/active_support/concern.rb:119:in `class_eval'
  from /ruby-path/gems/activesupport-3.2.9/lib/active_support/concern.rb:119:in `append_features'
  from /ruby-path/gems/kaminari-0.13.0/lib/kaminari/models/active_record_extension.rb:11:in `include'
  from /ruby-path/gems/kaminari-0.13.0/lib/kaminari/models/active_record_extension.rb:11:in `inherited_with_kaminari'
  from /my-app-path/app/models/campaign_batch_job.rb:1:in `<top (required)>'
  from /ruby-path/gems/activesupport-3.2.9/lib/active_support/dependencies.rb:251:in `require'
  from /ruby-path/gems/activesupport-3.2.9/lib/active_support/dependencies.rb:251:in `block in require'
  from /ruby-path/gems/activesupport-3.2.9/lib/active_support/dependencies.rb:236:in `load_dependency'
  from /ruby-path/gems/activesupport-3.2.9/lib/active_support/dependencies.rb:251:in `require'
  from /ruby-path/gems/activesupport-3.2.9/lib/active_support/dependencies.rb:359:in `require_or_load'
  from /ruby-path/gems/activesupport-3.2.9/lib/active_support/dependencies.rb:313:in `depend_on'
  from /ruby-path/gems/activesupport-3.2.9/lib/active_support/dependencies.rb:225:in `require_dependency'
  from /ruby-path/gems/railties-3.2.9/lib/rails/engine.rb:439:in `block (2 levels) in eager_load!'
  from /ruby-path/gems/railties-3.2.9/lib/rails/engine.rb:438:in `each'
  from /ruby-path/gems/railties-3.2.9/lib/rails/engine.rb:438:in `block in eager_load!'
  from /ruby-path/gems/railties-3.2.9/lib/rails/engine.rb:436:in `each'
  from /ruby-path/gems/railties-3.2.9/lib/rails/engine.rb:436:in `eager_load!'
  from /ruby-path/gems/railties-3.2.9/lib/rails/application/railties.rb:8:in `each'
  from /ruby-path/gems/railties-3.2.9/lib/rails/application/railties.rb:8:in `all'
  from /ruby-path/gems/railties-3.2.9/lib/rails/engine.rb:434:in `eager_load!'
  from /ruby-path/gems/railties-3.2.9/lib/rails/application/finisher.rb:53:in `block in <module:Finisher>'
  from /ruby-path/gems/railties-3.2.9/lib/rails/initializable.rb:30:in `instance_exec'
  from /ruby-path/gems/railties-3.2.9/lib/rails/initializable.rb:30:in `run'
  from /ruby-path/gems/railties-3.2.9/lib/rails/initializable.rb:55:in `block in run_initializers'
  from /ruby-path/gems/railties-3.2.9/lib/rails/initializable.rb:54:in `each'
  from /ruby-path/gems/railties-3.2.9/lib/rails/initializable.rb:54:in `run_initializers'
  from /ruby-path/gems/railties-3.2.9/lib/rails/application.rb:136:in `initialize!'
  from /ruby-path/gems/railties-3.2.9/lib/rails/railtie/configurable.rb:30:in `method_missing'
  from /my-app-path/spec/dummy/config/environment.rb:5:in `<top (required)>'
  from /my-app-path/spec/spec_helper.rb:4:in `require'
  from /my-app-path/spec/spec_helper.rb:4:in `<top (required)>'
  from /my-app-path/spec/fiddleback_shared_spec.rb:1:in `require'
  from /my-app-path/spec/fiddleback_shared_spec.rb:1:in `<top (required)>'
  from /ruby-path/gems/rspec-core-2.11.1/lib/rspec/core/configuration.rb:780:in `load'
  from /ruby-path/gems/rspec-core-2.11.1/lib/rspec/core/configuration.rb:780:in `block in load_spec_files'
  from /ruby-path/gems/rspec-core-2.11.1/lib/rspec/core/configuration.rb:780:in `map'
  from /ruby-path/gems/rspec-core-2.11.1/lib/rspec/core/configuration.rb:780:in `load_spec_files'
  from /ruby-path/gems/rspec-core-2.11.1/lib/rspec/core/command_line.rb:22:in `run'
  from /ruby-path/gems/rspec-core-2.11.1/lib/rspec/core/runner.rb:69:in `run'
  from /ruby-path/gems/rspec-core-2.11.1/lib/rspec/core/runner.rb:8:in `block in autorun'

0.2.0 works as expected.

How to use sexy_scopes outside the model?

This is more a question than an issue: is there a way to use sexy_scopes outside a model? (in a console, in a controller, ...). I guess not because there is no way to infer the table...

matches_any fails with wrong number arguments

Hi,

I was trying to upgrade my Rails 4.1 app to 4.2 -> which bumps arel from 5.0.1.20140414130214 to 6.0.0 in my case.
I'm using the latest sexy_scopes gem (0.7.0) and Ruby 2.1.4 on OSX 10.9 with rbenv from homebrew.

Now I get the following error when I try to match an attribute against multiple values:
The error happens here:
https://github.com/rails/arel/blob/0cd5fa9671592a16ee34f0718704b15f27911620/lib/arel/predications.rb#L196

And itself points to the map-block which gives me the following:

send(method_id, others.first, *extras)
ArgumentError: wrong number of arguments (2 for 1)
from [...]/sexy_scopes-0.7.0/lib/sexy_scopes/arel/predications.rb:13:in `matches'

How can I resolve this error with Rails 4.2 / Arel 6.0 and sexy_scopes?

edit: it seems to be caused by this arel commit:
rails/arel@fef9ce4

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.