Browse files

Added basic doc

  • Loading branch information...
1 parent 14a8faa commit 4cbce1ebc020da454d69b05891b1510b3215d5e6 @dim dim committed Feb 18, 2011
View
65 README.markdown
@@ -0,0 +1,65 @@
+# Constrainable
+
+Simple filtering for ActiveRecord. Sanitizes simple and readable query parameters -great for building APIs & HTML filters.
+
+## Straight to the point. Examples:
+
+Let's asume we have a model called Post, defined as:
+ Post(id: integer, title: string, body: string, author_id: integer, category: string, created_at: datetime, updated_at: datetime)
+
+In the simplest possible case you can define a few attributes and start filtering:
+
+ class Post < ActiveRecord::Base
+ constrainable do
+ fields :id, :author_id
+ end
+ end
+
+ # Examle request:
+ # GET /posts?where[id][not_eq]=1&where[author_id][eq]=2
+ # Params:
+ # "where" => { "id" => { "not_eq" => "1" }, "author_id" => { "eq" => "2" } }
+
+ Post.constrain(params[:where])
+ # => SELECT posts.* FROM posts WHERE id != 1 AND author_id = 2
+
+By default, only *eq* and *not_eq* operations are enabled, but there are plenty more:
+
+ class Post < ActiveRecord::Base
+ constrainable do
+ fields :id, :author_id, :with => [:in, :not_in, :gt, :gteq, :lt, :lteq]
+ fields :created_at, :with => [:between]
+ end
+ end
+
+ # Example request (various notations are accepted):
+ # GET /posts?
+ # where[id][not_in]=1|2|3|4&
+ # where[author_id][in][]=1&
+ # where[author_id][in][]=2&
+ # where[created_at][between]=2011-01-01...2011-02-01
+
+Want to *alias* a column? Try this:
+
+ class Post < ActiveRecord::Base
+ constrainable do
+ timestamp :created, :using => :created_at, :with => [:lt, :lte, :between]
+ end
+ end
+ # Example request:
+ # GET /posts?where[created][lt]=2011-01-01
+
+What about associations?
+
+ class Post < ActiveRecord::Base
+ belongs_to :author
+ constrainable do
+ string :author_name, :using => lambda { Author.scoped.table[:name] }, :with => [:matches], :scope => lambda { includes(:author) }
+ end
+ end
+ # Example request:
+ # GET /posts?where[author][matches]=%tom%
+
+ Post.constrain(params[:where])
+ # => SELECT posts.* FROM posts LEFT OUTER JOIN authors ON authors.id = posts.author_id WHERE authors.name LIKE '%tom%'
+
View
2 lib/bsm/constrainable/field.rb
@@ -7,12 +7,14 @@ module Bsm::Constrainable::Field
autoload :Decimal, 'bsm/constrainable/field/common'
autoload :String, 'bsm/constrainable/field/common'
autoload :Timestamp,'bsm/constrainable/field/common'
+ autoload :Datetime, 'bsm/constrainable/field/common'
autoload :Date, 'bsm/constrainable/field/common'
register self::Number
register self::Integer
register self::Decimal
register self::String
register self::Timestamp
+ register self::Datetime
register self::Date
end
View
9 lib/bsm/constrainable/field/base.rb
@@ -3,7 +3,7 @@ class Bsm::Constrainable::Field::Base
class_inheritable_accessor :operators, :defaults, :instance_reader => false, :instance_writer => false
self.operators = DEFAULT_OPERATORS.dup
- self.defaults = [:eq]
+ self.defaults = [:eq, :not_eq]
def self.kind
@kind ||= name.demodulize.underscore.to_sym
@@ -22,11 +22,10 @@ def initialize(name, options = {})
def merge(relation, params)
params.slice(*operators).each do |operator, value|
operation = Bsm::Constrainable::Operation.new(operator, value, relation, self)
- clause = operation.clause
- next if clause.nil?
+ next if operation.clause.nil?
- relation = relation.instance_eval(&:scope) if scope
- relation = relation.where(clause)
+ relation = relation.instance_eval(&scope) if scope
+ relation = relation.where(operation.clause)
end
relation
end
View
3 lib/bsm/constrainable/field/common.rb
@@ -33,6 +33,9 @@ def _convert(v)
end
end
+ class Datetime < Timestamp
+ end
+
class Date < Base
protected
View
2 lib/bsm/constrainable/model.rb
@@ -10,7 +10,7 @@ module ClassMethods
def constrainable(name = nil, &block)
name = name.present? ? name.to_sym : :default
- _constrainable[name] ||= Bsm::Constrainable::Schema.new
+ _constrainable[name] ||= Bsm::Constrainable::Schema.new(self)
_constrainable[name.to_sym].instance_eval(&block) if block
_constrainable[name]
end
View
2 lib/bsm/constrainable/operation.rb
@@ -9,6 +9,7 @@ module Bsm::Constrainable::Operation
autoload :Lt, 'bsm/constrainable/operation/common'
autoload :Gteq, 'bsm/constrainable/operation/common'
autoload :Lteq, 'bsm/constrainable/operation/common'
+ autoload :Matches, 'bsm/constrainable/operation/common'
autoload :In, 'bsm/constrainable/operation/in'
autoload :NotIn, 'bsm/constrainable/operation/not_in'
autoload :Between, 'bsm/constrainable/operation/between'
@@ -21,5 +22,6 @@ module Bsm::Constrainable::Operation
register self::Lt
register self::Gteq
register self::Lteq
+ register self::Matches
register self::Between
end
View
1 lib/bsm/constrainable/operation/base.rb
@@ -29,6 +29,7 @@ def invalid?
def clause
valid? ? _clause : nil
end
+ memoize :clause
protected
View
2 lib/bsm/constrainable/operation/common.rb
@@ -11,4 +11,6 @@ class Lt < Base
end
class Lteq < Base
end
+ class Matches < Base
+ end
end
View
13 lib/bsm/constrainable/schema.rb
@@ -2,10 +2,21 @@ class Bsm::Constrainable::Schema < Hash
include ::Bsm::Constrainable::Util
Field = ::Bsm::Constrainable::Field
- def initialize
+ def initialize(klass)
+ @klass = klass
super()
end
+ def fields(*names)
+ options = names.extract_options!
+ names.map(&:to_s).each do |name|
+ column = @klass.columns_hash[name]
+ raise ArgumentError, "Invalid field #{name}" unless column
+ raise ArgumentError, "Invalid field type #{column.type}" unless Field.registered?(column.type)
+ match name, options.merge(:as => column.type)
+ end
+ end
+
def match(*names)
options = names.extract_options!
kind = options.delete(:as)
View
25 spec/bsm/constrainable/field/base_spec.rb
@@ -1,6 +1,7 @@
require "spec_helper"
describe Bsm::Constrainable::Field::Base do
+ fixtures :all
let(:subject) do
described_class.new("any")
@@ -10,6 +11,14 @@ def integer(opts = {})
Bsm::Constrainable::Field::Integer.new("some", opts)
end
+ def field(name)
+ Post._constrainable[:default][name].first
+ end
+
+ def merge(name, params = {})
+ field(name).merge(Post.scoped, params)
+ end
+
it { described_class.should have(7).operators }
it 'should have a kind' do
@@ -18,7 +27,7 @@ def integer(opts = {})
end
it 'should allow setting operators' do
- integer.operators.should == Set.new([:eq])
+ integer.operators.should == Set.new([:eq, :not_eq])
integer(:with => [:eq, :in, :gt]).operators.should == Set.new([:eq, :in, :gt])
end
@@ -33,5 +42,19 @@ def integer(opts = {})
integer.convert([1, 'a', 2]).should == [1, nil, 2]
end
+ it 'should parse params and merge valid scopes' do
+ merge("id", :in => [1, 2, 3]).where_sql.clean_sql.should == "WHERE posts.id IN (1, 2, 3)"
+ merge("author_id", :in => [1, 3]).where_sql.clean_sql.should == "WHERE posts.author_id IN (1, 3)"
+ merge("created", :between => "2010-01-01..2010-02-01", :gt => "2010-01-01").where_values.map(&:to_sql).map(&:clean_sql).
+ should =~ ["posts.created_at >= '2010-01-01 00:00:00' AND posts.created_at <= '2010-02-01 00:00:00'", "posts.created_at > '2010-01-01 00:00:00'"]
+ end
+
+ it 'should include custom scopes' do
+ rel = merge("author_name", :eq => "Alice")
+ rel.includes_values.should == [:author]
+ rel.where_sql.clean_sql.should == "WHERE authors.name = 'Alice'"
+ rel.first.should == posts(:article)
+ end
+
end
View
4 spec/bsm/constrainable/field/common_spec.rb
@@ -41,6 +41,10 @@ def subject(value = "")
it { subject.convert("2011-11-11 11:11").should == Time.utc(2011, 11, 11, 11, 11) }
end
+ describe Bsm::Constrainable::Field::Datetime do
+ it { subject.class.should have(7).operators }
+ end
+
describe Bsm::Constrainable::Field::Date do
it { subject.class.should have(7).operators }
it { subject.convert("a").should == nil }
View
2 spec/bsm/constrainable/field_spec.rb
@@ -5,7 +5,7 @@
it { should be_a(Bsm::Constrainable::Registry) }
it 'should have a registry' do
- described_class.registry.should have(6).items
+ described_class.registry.should have(7).items
end
end
View
15 spec/bsm/constrainable/operation/base_spec.rb
@@ -2,16 +2,16 @@
describe Bsm::Constrainable::Operation::Base do
- def field
- Post._constrainable[:default]["id"].first
+ def field(name)
+ Post._constrainable[:default][name].first
end
def subject
- described_class.new(123, Post.scoped, field)
+ described_class.new(123, Post.scoped, field('id'))
end
- def new_op(kind, value)
- Bsm::Constrainable::Operation.new(kind, value, Post.scoped, field)
+ def new_op(kind, value, f = 'id')
+ Bsm::Constrainable::Operation.new(kind, value, Post.scoped, field(f))
end
it 'should have a value' do
@@ -36,5 +36,10 @@ def new_op(kind, value)
new_op(:between, "2..a").clause.should be_nil
end
+ it 'should use field definitions for clauses' do
+ new_op(:gt, '2011-01-01', "created").clause.to_sql.clean_sql.should == "posts.created_at > '2011-01-01 00:00:00'"
+ new_op(:eq, "alice", "author_name").clause.to_sql.clean_sql.should == "authors.name = 'alice'"
+ end
+
end
View
17 spec/bsm/constrainable/operation/matches_spec.rb
@@ -0,0 +1,17 @@
+require "spec_helper"
+
+describe Bsm::Constrainable::Operation::Matches do
+
+ def subject
+ Bsm::Constrainable::Operation.new :matches, "%bob%", Post.scoped, Post._constrainable[:default]["author_name"].first
+ end
+
+ it { should be_instance_of(described_class) }
+ it { should be_a(Bsm::Constrainable::Operation::Base) }
+
+ it 'should generate correct clauses' do
+ subject.clause.to_sql.clean_sql.should == "authors.name LIKE '%bob%'"
+ end
+
+end
+
View
2 spec/bsm/constrainable/operation_spec.rb
@@ -5,7 +5,7 @@
it { should be_a(Bsm::Constrainable::Registry) }
it 'should have a registry' do
- described_class.registry.should have(9).items
+ described_class.registry.should have(10).items
end
end
View
6 spec/bsm/constrainable/schema_spec.rb
@@ -12,6 +12,12 @@
subject.keys.should =~ ["author_id", "author_name", "category", "created", "id"]
end
+ it 'should have quick constraints' do
+ subject.fields :id, :updated_at, :with => [:gt]
+ subject.should have(6).items
+ lambda { subject.fields(:cat) }.should raise_error(ArgumentError)
+ end
+
it 'should allow to append constraints' do
subject.should have(5).items
subject.integer :other, :using => :id
View
16 spec/spec_helper.rb
@@ -1,3 +1,5 @@
+ENV["RAILS_ENV"] ||= 'test'
+
$: << File.dirname(__FILE__) + '/../lib'
require 'rubygems'
require 'bundler'
@@ -12,20 +14,20 @@
require 'rspec/rails/fixture_support'
require 'bsm/constrainable'
+SPEC_DATABASE = File.dirname(__FILE__) + '/tmp/test.sqlite3'
+Time.zone_default = Time.__send__(:get_zone, "UTC")
ActiveRecord::Base.time_zone_aware_attributes = true
ActiveRecord::Base.default_timezone = :utc
-Time.zone_default = Time.__send__(:get_zone, "UTC")
-
-SPEC_DATABASE = File.dirname(__FILE__) + '/tmp/test.sqlite3'
+ActiveRecord::Base.configurations["test"] = { 'adapter' => 'sqlite3', 'database' => SPEC_DATABASE }
RSpec.configure do |c|
c.fixture_path = File.dirname(__FILE__) + '/fixtures'
c.before(:all) do
FileUtils.mkdir_p File.dirname(SPEC_DATABASE)
base = ActiveRecord::Base
- base.establish_connection('adapter' => 'sqlite3', 'database' => SPEC_DATABASE)
+ base.establish_connection(:test)
base.connection.create_table :posts do |t|
- t.string :subject
+ t.string :title
t.string :body
t.integer :author_id
t.string :category
@@ -53,11 +55,15 @@ class Author < ActiveRecord::Base
end
class Post < ActiveRecord::Base
+ belongs_to :author
+
constrainable do
integer :id, :author_id, :with => [:in, :not_in]
timestamp :created, :using => :created_at, :with => [:gt, :lt, :between]
match :author_name, :as => :string, :using => lambda { Author.scoped.table[:name] }, :scope => lambda { includes(:author) }
string :category
end
+
scope :articles, lambda { where(:category => "article") }
end
+

0 comments on commit 4cbce1e

Please sign in to comment.