Initial commit.

This commit is contained in:
Ernie Miller 2011-03-30 20:31:39 -04:00
commit 294015309b
50 changed files with 2293 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*.gem
.bundle
Gemfile.lock
pkg/*

11
Gemfile Normal file
View File

@ -0,0 +1,11 @@
source "http://rubygems.org"
gemspec
gem 'arel', :git => 'git://github.com/rails/arel.git'
gem 'rack', :git => 'git://github.com/rack/rack.git'
git 'git://github.com/rails/rails.git' do
gem 'activesupport'
gem 'activerecord'
gem 'actionpack'
end

20
LICENSE Normal file
View File

@ -0,0 +1,20 @@
Copyright (c) 2010 Ernie Miller
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

5
README.rdoc Normal file
View File

@ -0,0 +1,5 @@
= Ransack
Don't use me.
Seriously, I'm not anywhere close to ready for public consumption, yet.

19
Rakefile Normal file
View File

@ -0,0 +1,19 @@
require 'bundler'
require 'rspec/core/rake_task'
Bundler::GemHelper.install_tasks
RSpec::Core::RakeTask.new(:spec) do |rspec|
rspec.rspec_opts = ['--backtrace']
end
task :default => :spec
desc "Open an irb session with Ransack and the sample data used in specs"
task :console do
require 'irb'
require 'irb/completion'
require 'console'
ARGV.clear
IRB.start
end

24
lib/ransack.rb Normal file
View File

@ -0,0 +1,24 @@
require 'ransack/configuration'
module Ransack
extend Configuration
end
Ransack.configure do |config|
Ransack::Constants::AREL_PREDICATES.each do |name|
config.add_predicate name, :arel_predicate => name
end
Ransack::Constants::DERIVED_PREDICATES.each do |args|
config.add_predicate *args
end
end
require 'ransack/translate'
require 'ransack/search'
require 'ransack/adapters/active_record'
require 'ransack/helpers'
require 'action_controller'
ActiveRecord::Base.extend Ransack::Adapters::ActiveRecord::Base
ActionController::Base.helper Ransack::Helpers::FormHelper

View File

@ -0,0 +1,2 @@
require 'ransack/adapters/active_record/base'
require 'ransack/adapters/active_record/context'

View File

@ -0,0 +1,17 @@
module Ransack
module Adapters
module ActiveRecord
module Base
def self.extended(base)
alias :search :ransack unless base.method_defined? :search
end
def ransack(params = {})
Search.new(self, params)
end
end
end
end
end

View File

@ -0,0 +1,153 @@
require 'ransack/context'
require 'active_record'
module Ransack
module Adapters
module ActiveRecord
class Context < ::Ransack::Context
# Because the AR::Associations namespace is insane
JoinDependency = ::ActiveRecord::Associations::JoinDependency
JoinPart = JoinDependency::JoinPart
JoinAssociation = JoinDependency::JoinAssociation
def evaluate(search, opts = {})
relation = @object.where(accept(search.base)).order(accept(search.sorts))
opts[:distinct] ? relation.group(@klass.arel_table[@klass.primary_key]) : relation
end
def attribute_method?(str, klass = @klass)
exists = false
if column = get_column(str, klass)
exists = true
elsif (segments = str.split(/_/)).size > 1
remainder = []
found_assoc = nil
while !found_assoc && remainder.unshift(segments.pop) && segments.size > 0 do
if found_assoc = get_association(segments.join('_'), klass)
exists = attribute_method?(remainder.join('_'), found_assoc.klass)
end
end
end
exists
end
def type_for(attr)
return nil unless attr
name = attr.name.to_s
table = attr.relation.table_name
unless @engine.connection_pool.table_exists?(table)
raise "No table named #{table} exists"
end
@engine.connection_pool.columns_hash[table][name].type
end
private
def klassify(obj)
if Class === obj && ::ActiveRecord::Base > obj
obj
elsif obj.respond_to? :klass
obj.klass
elsif obj.respond_to? :active_record
obj.active_record
else
raise ArgumentError, "Don't know how to klassify #{obj}"
end
end
def get_attribute(str, parent = @base)
attribute = nil
if column = get_column(str, parent)
attribute = parent.table[str]
elsif (segments = str.split(/_/)).size > 1
remainder = []
found_assoc = nil
while remainder.unshift(segments.pop) && segments.size > 0 && !found_assoc do
if found_assoc = get_association(segments.join('_'), parent)
join = build_or_find_association(found_assoc.name, parent)
attribute = get_attribute(remainder.join('_'), join)
end
end
end
attribute
end
def get_column(str, parent = @base)
klassify(parent).columns_hash[str]
end
def get_association(str, parent = @base)
klassify(parent).reflect_on_all_associations.detect {|a| a.name.to_s == str}
end
def join_dependency(relation)
if relation.respond_to?(:join_dependency) # MetaWhere will enable this
relation.join_dependency
else
build_join_dependency(relation)
end
end
def build_join_dependency(relation)
buckets = relation.joins_values.group_by do |join|
case join
when String
'string_join'
when Hash, Symbol, Array
'association_join'
when ActiveRecord::Associations::JoinDependency::JoinAssociation
'stashed_join'
when Arel::Nodes::Join
'join_node'
else
raise 'unknown class: %s' % join.class.name
end
end
association_joins = buckets['association_join'] || []
stashed_association_joins = buckets['stashed_join'] || []
join_nodes = buckets['join_node'] || []
string_joins = (buckets['string_join'] || []).map { |x|
x.strip
}.uniq
join_list = relation.send :custom_join_ast, relation.table.from(relation.table), string_joins
join_dependency = JoinDependency.new(
relation.klass,
association_joins,
join_list
)
join_nodes.each do |join|
join_dependency.table_aliases[join.left.name.downcase] = 1
end
join_dependency.graft(*stashed_association_joins)
end
def build_or_find_association(name, parent = @base)
found_association = @join_dependency.join_associations.detect do |assoc|
assoc.reflection.name == name &&
assoc.parent == parent
end
unless found_association
@join_dependency.send(:build, name.to_sym, parent, Arel::Nodes::OuterJoin)
found_association = @join_dependency.join_associations.last
# Leverage the stashed association functionality in AR
@object = @object.joins(found_association)
end
found_association
end
end
end
end
end

View File

@ -0,0 +1,39 @@
require 'ransack/constants'
require 'ransack/predicate'
module Ransack
module Configuration
mattr_accessor :predicates
self.predicates = {}
def self.predicate_keys
predicates.keys.sort {|a,b| b.length <=> a.length}
end
def configure
yield self
end
def add_predicate(name, opts = {})
name = name.to_s
opts[:name] = name
compounds = opts.delete(:compounds)
compounds = true if compounds.nil?
opts[:arel_predicate] = opts[:arel_predicate].to_s
self.predicates[name] = Predicate.new(opts)
['_any', '_all'].each do |suffix|
self.predicates[name + suffix] = Predicate.new(
opts.merge(
:name => name + suffix,
:arel_predicate => opts[:arel_predicate] + suffix,
:compound => true
)
)
end if compounds
end
end
end

23
lib/ransack/constants.rb Normal file
View File

@ -0,0 +1,23 @@
module Ransack
module Constants
TRUE_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE'].to_set
FALSE_VALUES = [false, 0, '0', 'f', 'F', 'false', 'FALSE'].to_set
AREL_PREDICATES = %w(eq not_eq matches does_not_match lt lteq gt gteq in not_in)
DERIVED_PREDICATES = [
['cont', {:arel_predicate => 'matches', :formatter => proc {|v| "%#{v}%"}}],
['not_cont', {:arel_predicate => 'does_not_match', :formatter => proc {|v| "%#{v}%"}}],
['start', {:arel_predicate => 'matches', :formatter => proc {|v| "#{v}%"}}],
['not_start', {:arel_predicate => 'does_not_match', :formatter => proc {|v| "#{v}%"}}],
['end', {:arel_predicate => 'matches', :formatter => proc {|v| "%#{v}"}}],
['not_end', {:arel_predicate => 'does_not_match', :formatter => proc {|v| "%#{v}"}}],
['true', {:arel_predicate => 'eq', :compounds => false, :type => :boolean, :validator => proc {|v| TRUE_VALUES.include?(v)}}],
['false', {:arel_predicate => 'eq', :compounds => false, :type => :boolean, :validator => proc {|v| TRUE_VALUES.include?(v)}, :formatter => proc {|v| !v}}],
['present', {:arel_predicate => 'not_eq_all', :compounds => false, :type => :boolean, :validator => proc {|v| TRUE_VALUES.include?(v)}, :formatter => proc {|v| [nil, '']}}],
['blank', {:arel_predicate => 'eq_any', :compounds => false, :type => :boolean, :validator => proc {|v| TRUE_VALUES.include?(v)}, :formatter => proc {|v| [nil, '']}}],
['null', {:arel_predicate => 'eq', :compounds => false, :type => :boolean, :validator => proc {|v| TRUE_VALUES.include?(v)}, :formatter => proc {|v| nil}}],
['not_null', {:arel_predicate => 'not_eq', :compounds => false, :type => :boolean, :validator => proc {|v| TRUE_VALUES.include?(v)}, :formatter => proc {|v| nil}}]
]
end
end

152
lib/ransack/context.rb Normal file
View File

@ -0,0 +1,152 @@
module Ransack
class Context
attr_reader :search, :object, :klass, :base, :engine, :arel_visitor
class << self
def for(object)
context = Class === object ? for_class(object) : for_object(object)
context or raise ArgumentError, "Don't know what context to use for #{object}"
end
def for_class(klass)
if klass < ActiveRecord::Base
Adapters::ActiveRecord::Context.new(klass)
end
end
def for_object(object)
case object
when ActiveRecord::Relation
Adapters::ActiveRecord::Context.new(object.klass)
end
end
def can_accept?(object)
method_defined? DISPATCH[object.class]
end
end
def initialize(object)
@object = object.scoped
@klass = @object.klass
@join_dependency = join_dependency(@object)
@base = @join_dependency.join_base
@engine = @base.arel_engine
@arel_visitor = Arel::Visitors.visitor_for @engine
@default_table = Arel::Table.new(@base.table_name, :as => @base.aliased_table_name, :engine => @engine)
@attributes = Hash.new do |hash, key|
if attribute = get_attribute(key.to_s)
hash[key] = attribute
end
end
end
# Convert a string representing a chain of associations and an attribute
# into the attribute itself
def contextualize(str)
@attributes[str]
end
def traverse(str, base = @base)
str ||= ''
if (segments = str.split(/_/)).size > 0
association_parts = []
found_assoc = nil
while !found_assoc && segments.size > 0 && association_parts << segments.shift do
if found_assoc = get_association(association_parts.join('_'), base)
base = traverse(segments.join('_'), found_assoc.klass)
end
end
raise ArgumentError, "No association matches #{str}" unless found_assoc
end
klassify(base)
end
def association_path(str, base = @base)
base = klassify(base)
str ||= ''
path = []
segments = str.split(/_/)
association_parts = []
if (segments = str.split(/_/)).size > 0
while segments.size > 0 && !base.columns_hash[segments.join('_')] && association_parts << segments.shift do
if found_assoc = get_association(association_parts.join('_'), base)
path += association_parts
association_parts = []
base = klassify(found_assoc)
end
end
end
path.join('_')
end
def searchable_columns(str = '')
traverse(str).column_names
end
def accept(object)
visit(object)
end
def can_accept?(object)
respond_to? DISPATCH[object.class]
end
def visit_Array(object)
object.map {|o| accept(o)}.compact
end
def visit_Ransack_Nodes_Condition(object)
object.apply_predicate if object.valid?
end
def visit_Ransack_Nodes_And(object)
nodes = object.values.map {|o| accept(o)}.compact
return nil unless nodes.size > 0
if nodes.size > 1
Arel::Nodes::Grouping.new(Arel::Nodes::And.new(nodes))
else
nodes.first
end
end
def visit_Ransack_Nodes_Sort(object)
object.attr.send(object.dir) if object.valid?
end
def visit_Ransack_Nodes_Or(object)
nodes = object.values.map {|o| accept(o)}.compact
return nil unless nodes.size > 0
if nodes.size > 1
nodes.inject(&:or)
else
nodes.first
end
end
def quoted?(object)
case object
when Arel::Nodes::SqlLiteral, Bignum, Fixnum
false
else
true
end
end
def visit(object)
send(DISPATCH[object.class], object)
end
DISPATCH = Hash.new do |hash, klass|
hash[klass] = "visit_#{klass.name.gsub('::', '_')}"
end
end
end

2
lib/ransack/helpers.rb Normal file
View File

@ -0,0 +1,2 @@
require 'ransack/helpers/form_builder'
require 'ransack/helpers/form_helper'

View File

@ -0,0 +1,172 @@
require 'action_view'
module Ransack
module Helpers
class FormBuilder < ::ActionView::Helpers::FormBuilder
def label(method, *args, &block)
options = args.extract_options!
text = args.first
i18n = options[:i18n] || {}
text ||= object.translate(method, i18n.reverse_merge(:include_associations => true)) if object.respond_to? :translate
super(method, text, options, &block)
end
def attribute_select(options = {}, html_options = {})
raise ArgumentError, "attribute_select must be called inside a search FormBuilder!" unless object.respond_to?(:context)
options[:include_blank] = true unless options.has_key?(:include_blank)
bases = [''] + association_array(options[:associations])
if bases.size > 1
collection = bases.map do |base|
[
Translate.association(base, :context => object.context),
object.context.searchable_columns(base).map do |c|
[
attr_from_base_and_column(base, c),
Translate.attribute(attr_from_base_and_column(base, c), :context => object.context)
]
end
]
end
@template.grouped_collection_select(
@object_name, :name, collection, :last, :first, :first, :last,
objectify_options(options), @default_options.merge(html_options)
)
else
collection = object.context.searchable_columns(bases.first).map do |c|
[
attr_from_base_and_column(bases.first, c),
Translate.attribute(attr_from_base_and_column(bases.first, c), :context => object.context)
]
end
@template.collection_select(
@object_name, :name, collection, :first, :last,
objectify_options(options), @default_options.merge(html_options)
)
end
end
def sort_select(options = {}, html_options = {})
raise ArgumentError, "sort_select must be called inside a search FormBuilder!" unless object.respond_to?(:context)
options[:include_blank] = true unless options.has_key?(:include_blank)
bases = [''] + association_array(options[:associations])
if bases.any?
collection = bases.map do |base|
[
Translate.association(base, :context => object.context),
object.context.searchable_columns(base).map do |c|
[
attr_from_base_and_column(base, c),
Translate.attribute(attr_from_base_and_column(base, c), :context => object.context)
]
end
]
end
@template.grouped_collection_select(
@object_name, :name, collection, :last, :first, :first, :last,
objectify_options(options), @default_options.merge(html_options)
) + @template.collection_select(
@object_name, :dir, [['asc', object.translate('asc')], ['desc', object.translate('desc')]], :first, :last,
objectify_options(options), @default_options.merge(html_options)
)
else
collection = object.context.searchable_columns(bases.first).map do |c|
[
attr_from_base_and_column(bases.first, c),
Translate.attribute(attr_from_base_and_column(bases.first, c), :context => object.context)
]
end
@template.collection_select(
@object_name, :name, collection, :first, :last,
objectify_options(options), @default_options.merge(html_options)
) + @template.collection_select(
@object_name, :dir, [['asc', object.translate('asc')], ['desc', object.translate('desc')]], :first, :last,
objectify_options(options), @default_options.merge(html_options)
)
end
end
def sort_fields(*args, &block)
search_fields(:s, args, block)
end
def condition_fields(*args, &block)
search_fields(:c, args, block)
end
def and_fields(*args, &block)
search_fields(:n, args, block)
end
def or_fields(*args, &block)
search_fields(:o, args, block)
end
def attribute_fields(*args, &block)
search_fields(:a, args, block)
end
def predicate_fields(*args, &block)
search_fields(:p, args, block)
end
def value_fields(*args, &block)
search_fields(:v, args, block)
end
def search_fields(name, args, block)
args << {} unless args.last.is_a?(Hash)
args.last[:builder] ||= options[:builder]
args.last[:parent_builder] = self
options = args.extract_options!
objects = args.shift
objects ||= @object.send(name)
objects = [objects] unless Array === objects
name = "#{options[:object_name] || object_name}[#{name}]"
output = ActiveSupport::SafeBuffer.new
objects.each do |child|
output << @template.fields_for("#{name}[#{options[:child_index] || nested_child_index(name)}]", child, options, &block)
end
output
end
def predicate_select(options = {}, html_options = {})
@template.collection_select(
@object_name, :p, Predicate.collection, :first, :last,
objectify_options(options), @default_options.merge(html_options)
)
end
def combinator_select(options = {}, html_options = {})
@template.collection_select(
@object_name, :m, [['or', Translate.word(:or)], ['and', Translate.word(:and)]], :first, :last,
objectify_options(options), @default_options.merge(html_options)
)
end
private
def association_array(obj, prefix = nil)
([prefix] + case obj
when Array
obj
when Hash
obj.map do |key, value|
case value
when Array, Hash
bases_array(value, key.to_s)
else
[key.to_s, [key, value].join('_')]
end
end
else
[obj]
end).compact.flatten.map {|v| [prefix, v].compact.join('_')}
end
def attr_from_base_and_column(base, column)
[base, column].reject {|v| v.blank?}.join('_')
end
end
end
end

View File

@ -0,0 +1,27 @@
module Ransack
module Helpers
module FormHelper
def search_form_for(record, options = {}, &proc)
if record.is_a?(Ransack::Search)
search = record
options[:url] ||= polymorphic_path(search.klass)
elsif record.is_a?(Array) && (search = record.detect {|o| o.is_a?(Ransack::Search)})
options[:url] ||= polymorphic_path(record.map {|o| o.is_a?(Ransack::Search) ? o.klass : o})
else
raise ArgumentError, "No Ransack::Search object was provided to search_form_for!"
end
options[:html] ||= {}
html_options = {
:class => options[:as] ? "#{options[:as]}_search" : "#{search.klass.to_s.underscore}_search",
:id => options[:as] ? "#{options[:as]}_search" : "#{search.klass.to_s.underscore}_search",
:method => :get
}
options[:html].reverse_merge!(html_options)
options[:builder] ||= FormBuilder
form_for(record, options, &proc)
end
end
end
end

67
lib/ransack/locale/en.yml Normal file
View File

@ -0,0 +1,67 @@
en:
ransack:
predicate: "predicate"
and: "and"
or: "or"
combinator: "combinator"
attribute: "attribute"
value: "value"
condition: "condition"
sort: "sort"
asc: "ascending"
desc: "descending"
predicates:
eq: "equals"
eq_any: "equals any"
eq_all: "equals all"
not_eq: "not equal to"
not_eq_any: "not equal to any"
not_eq_all: "not equal to all"
matches: "matches"
matches_any: "matches_any"
matches_all: "matches all"
does_not_match: "doesn't match"
does_not_match_any: "doesn't match any"
does_not_match_all: "doesn't match all"
lt: "less than"
lt_any: "less than any"
lt_all: "less than all"
lteq: "less than or equal to"
lteq_any: "less than or equal to any"
lteq_all: "less than or equal to all"
gt: "greater than"
gt_any: "greater than any"
gt_all: "greater than all"
gteq: "greater than or equal to"
gteq_any: "greater than or equal to any"
gteq_all: "greater than or equal to all"
in: "in"
in_any: "in any"
in_all: "in all"
not_in: "not in"
not_in_any: "not in any"
not_in_all: "not in all"
cont: "contains"
cont_any: "contains any"
cont_all: "contains all"
not_cont: "doesn't contain"
not_cont_any: "doesn't contain any"
not_cont_all: "doesn't contain all"
start: "starts with"
start_any: "starts with any"
start_all: "starts with all"
not_start: "doesn't start with"
not_start_any: "doesn't start with any"
not_start_all: "doesn't start with all"
end: "ends with"
end_any: "ends with any"
end_all: "ends with all"
not_end: "doesn't end with"
not_end_any: "doesn't end with any"
not_end_all: "doesn't end with all"
true: "is true"
false: "is false"
present: "is present"
blank: "is blank"
null: "is null"
not_null: "is not null"

53
lib/ransack/naming.rb Normal file
View File

@ -0,0 +1,53 @@
module Ransack
module Naming
def self.included(base)
base.extend ClassMethods
end
def persisted?
false
end
def to_key
nil
end
def to_param
nil
end
def to_model
self
end
end
class Name < String
attr_reader :singular, :plural, :element, :collection, :partial_path, :human, :param_key, :route_key, :i18n_key
alias_method :cache_key, :collection
def initialize
super("Search")
@singular = "search".freeze
@plural = "searches".freeze
@element = "search".freeze
@human = "Search".freeze
@collection = "ransack/searches".freeze
@partial_path = "#{@collection}/#{@element}".freeze
@param_key = "q".freeze
@route_key = "searches".freeze
@i18n_key = :ransack
end
end
module ClassMethods
def model_name
@_model_name ||= Name.new
end
def i18n_scope
:ransack
end
end
end

7
lib/ransack/nodes.rb Normal file
View File

@ -0,0 +1,7 @@
require 'ransack/nodes/node'
require 'ransack/nodes/attribute'
require 'ransack/nodes/value'
require 'ransack/nodes/condition'
require 'ransack/nodes/sort'
require 'ransack/nodes/and'
require 'ransack/nodes/or'

8
lib/ransack/nodes/and.rb Normal file
View File

@ -0,0 +1,8 @@
require 'ransack/nodes/grouping'
module Ransack
module Nodes
class And < Grouping
end
end
end

View File

@ -0,0 +1,36 @@
module Ransack
module Nodes
class Attribute < Node
attr_reader :name, :attr
delegate :blank?, :==, :to => :name
def initialize(context, name = nil)
super(context)
self.name = name unless name.blank?
end
def name=(name)
@name = name
@attr = contextualize(name) unless name.blank?
end
def valid?
@attr
end
def eql?(other)
self.class == other.class &&
self.name == other.name
end
alias :== :eql?
def hash
self.name.hash
end
def persisted?
false
end
end
end
end

View File

@ -0,0 +1,209 @@
module Ransack
module Nodes
class Condition < Node
i18n_word :attribute, :predicate, :combinator, :value
i18n_alias :a => :attribute, :p => :predicate, :m => :combinator, :v => :value
attr_reader :predicate
class << self
def extract(context, key, values)
attributes, predicate = extract_attributes_and_predicate(key)
if attributes.size > 0
combinator = key.match(/_(or|and)_/) ? $1 : nil
condition = self.new(context)
condition.build(
:a => attributes,
:p => predicate.name,
:m => combinator,
:v => [values]
)
predicate.validate(condition.values) ? condition : nil
end
end
private
def extract_attributes_and_predicate(key)
str = key.dup
name = Ransack::Configuration.predicate_keys.detect {|p| str.sub!(/_#{p}$/, '')}
predicate = Predicate.named(name)
raise ArgumentError, "No valid predicate for #{key}" unless predicate
attributes = str.split(/_and_|_or_/)
[attributes, predicate]
end
end
def valid?
attributes.detect(&:valid?) && predicate && valid_arity? && predicate.validate(values) && valid_combinator?
end
def valid_arity?
values.size <= 1 || predicate.compound || %w(in not_in).include?(predicate.name)
end
def attributes
@attributes ||= []
end
alias :a :attributes
def attributes=(args)
case args
when Array
args.each do |attr|
attr = Attribute.new(@context, attr)
self.attributes << attr if attr.valid?
end
when Hash
args.each do |index, attrs|
attr = Attribute.new(@context, attrs[:name])
self.attributes << attr if attr.valid?
end
else
raise ArgumentError, "Invalid argument (#{args.class}) supplied to attributes="
end
end
alias :a= :attributes=
def values
@values ||= []
end
alias :v :values
def values=(args)
case args
when Array
args.each do |val|
val = Value.new(@context, val, current_type)
self.values << val
end
when Hash
args.each do |index, attrs|
val = Value.new(@context, attrs[:value], current_type)
self.values << val
end
else
raise ArgumentError, "Invalid argument (#{args.class}) supplied to values="
end
end
alias :v= :values=
def combinator
@attributes.size > 1 ? @combinator : nil
end
def combinator=(val)
@combinator = ['and', 'or'].detect {|v| v == val.to_s} || nil
end
alias :m= :combinator=
alias :m :combinator
def build_attribute(name = nil)
Attribute.new(@context, name).tap do |attribute|
self.attributes << attribute
end
end
def build_value(val = nil)
Value.new(@context, val, current_type).tap do |value|
self.values << value
end
end
def value
predicate.compound ? values.map(&:value) : values.first.value
end
def build(params)
params.with_indifferent_access.each do |key, value|
if key.match(/^(a|v|p|m)$/)
self.send("#{key}=", value)
end
end
set_value_types!
self
end
def persisted?
false
end
def key
@key ||= attributes.map(&:name).join("_#{combinator}_") + "_#{predicate.name}"
end
def eql?(other)
self.class == other.class &&
self.attributes == other.attributes &&
self.predicate == other.predicate &&
self.values == other.values &&
self.combinator == other.combinator
end
alias :== :eql?
def hash
[attributes, predicate, values, combinator].hash
end
def predicate_name=(name)
self.predicate = Predicate.named(name)
end
alias :p= :predicate_name=
def predicate=(predicate)
@predicate = predicate
predicate
end
def predicate_name
predicate.name if predicate
end
alias :p :predicate_name
def apply_predicate
attributes = arel_attributes.compact
if attributes.size > 1
case combinator
when 'and'
Arel::Nodes::Grouping.new(Arel::Nodes::And.new(
attributes.map {|a| a.send(predicate.arel_predicate, predicate.format(values))}
))
when 'or'
attributes.inject(attributes.shift.send(predicate.arel_predicate, predicate.format(values))) do |memo, a|
memo.or(a.send(predicate.arel_predicate, predicate.format(values)))
end
end
else
attributes.first.send(predicate.arel_predicate, predicate.format(values))
end
end
private
def set_value_types!
self.values.each {|v| v.type = current_type}
end
def current_type
if predicate && predicate.type
predicate.type
elsif attributes.size > 0
@context.type_for(attributes.first.attr)
end
end
def valid_combinator?
attributes.size < 2 ||
['and', 'or'].include?(combinator)
end
def arel_attributes
attributes.map(&:attr)
end
end
end
end

View File

@ -0,0 +1,207 @@
module Ransack
module Nodes
class Grouping < Node
attr_reader :conditions
i18n_word :condition, :and, :or
i18n_alias :c => :condition, :n => :and, :o => :or
delegate :each, :to => :values
def persisted?
false
end
def translate(key, options = {})
super or Translate.attribute(key.to_s, options.merge(:context => context))
end
def conditions
@conditions ||= []
end
alias :c :conditions
def conditions=(conditions)
case conditions
when Array
conditions.each do |attrs|
condition = Condition.new(@context).build(attrs)
self.conditions << condition if condition.valid?
end
when Hash
conditions.each do |index, attrs|
condition = Condition.new(@context).build(attrs)
self.conditions << condition if condition.valid?
end
end
self.conditions.uniq!
end
alias :c= :conditions=
def [](key)
if condition = conditions.detect {|c| c.key == key.to_s}
condition
else
nil
end
end
def []=(key, value)
conditions.reject! {|c| c.key == key.to_s}
self.conditions << value
end
def values
conditions + ors + ands
end
def respond_to?(method_id)
super or begin
method_name = method_id.to_s
writer = method_name.sub!(/\=$/, '')
attribute_method?(method_name) ? true : false
end
end
def build_condition(opts = {})
new_condition(opts).tap do |condition|
self.conditions << condition
end
end
def new_condition(opts = {})
attrs = opts[:attributes] || 1
vals = opts[:values] || 1
condition = Condition.new(@context)
condition.predicate = Predicate.named('eq')
attrs.times { condition.build_attribute }
vals.times { condition.build_value }
condition
end
def ands
@ands ||= []
end
alias :n :ands
def ands=(ands)
case ands
when Array
ands.each do |attrs|
and_object = And.new(@context).build(attrs)
self.ands << and_object if and_object.values.any?
end
when Hash
ands.each do |index, attrs|
and_object = And.new(@context).build(attrs)
self.ands << and_object if and_object.values.any?
end
else
raise ArgumentError, "Invalid argument (#{ands.class}) supplied to ands="
end
end
alias :n= :ands=
def ors
@ors ||= []
end
alias :o :ors
def ors=(ors)
case ors
when Array
ors.each do |attrs|
or_object = Or.new(@context).build(attrs)
self.ors << or_object if or_object.values.any?
end
when Hash
ors.each do |index, attrs|
or_object = Or.new(@context).build(attrs)
self.ors << or_object if or_object.values.any?
end
else
raise ArgumentError, "Invalid argument (#{ors.class}) supplied to ors="
end
end
alias :o= :ors=
def method_missing(method_id, *args)
method_name = method_id.to_s
writer = method_name.sub!(/\=$/, '')
if attribute_method?(method_name)
writer ? write_attribute(method_name, *args) : read_attribute(method_name)
else
super
end
end
def attribute_method?(name)
name = strip_predicate_and_index(name)
case name
when /^(n|o|c|ands|ors|conditions)=?$/
true
else
name.split(/_and_|_or_/).select {|n| !@context.attribute_method?(n)}.empty?
end
end
def build_and(params = {})
params ||= {}
new_and(params).tap do |new_and|
self.ands << new_and
end
end
def new_and(params = {})
And.new(@context).build(params)
end
def build_or(params = {})
params ||= {}
new_or(params).tap do |new_or|
self.ors << new_or
end
end
def new_or(params = {})
Or.new(@context).build(params)
end
def build(params)
params.with_indifferent_access.each do |key, value|
case key
when /^(n|o|c)$/
self.send("#{key}=", value)
else
write_attribute(key.to_s, value)
end
end
self
end
private
def write_attribute(name, val)
# TODO: Methods
if condition = Condition.extract(@context, name, val)
self[name] = condition
end
end
def read_attribute(name)
if self[name].respond_to?(:value)
self[name].value
else
self[name]
end
end
def strip_predicate_and_index(str)
string = str.split(/\(/).first
Ransack::Configuration.predicate_keys.detect {|p| string.sub!(/_#{p}$/, '')}
string
end
end
end
end

34
lib/ransack/nodes/node.rb Normal file
View File

@ -0,0 +1,34 @@
module Ransack
module Nodes
class Node
attr_reader :context
delegate :contextualize, :to => :context
class_attribute :i18n_words
class_attribute :i18n_aliases
self.i18n_words = []
self.i18n_aliases = {}
class << self
def i18n_word(*args)
self.i18n_words += args.map(&:to_s)
end
def i18n_alias(opts = {})
self.i18n_aliases.merge! Hash[opts.map {|k, v| [k.to_s, v.to_s]}]
end
end
def initialize(context)
@context = context
end
def translate(key, options = {})
key = i18n_aliases[key.to_s] if i18n_aliases.has_key?(key.to_s)
if i18n_words.include?(key.to_s)
Translate.word(key)
end
end
end
end
end

8
lib/ransack/nodes/or.rb Normal file
View File

@ -0,0 +1,8 @@
require 'ransack/nodes/grouping'
module Ransack
module Nodes
class Or < Grouping
end
end
end

39
lib/ransack/nodes/sort.rb Normal file
View File

@ -0,0 +1,39 @@
module Ransack
module Nodes
class Sort < Node
attr_reader :name, :attr, :dir
i18n_word :asc, :desc
class << self
def extract(context, str)
attr, direction = str.split(/\s+/,2)
self.new(context).build(:name => attr, :dir => direction)
end
end
def build(params)
params.with_indifferent_access.each do |key, value|
if key.match(/^(name|dir)$/)
self.send("#{key}=", value)
end
end
self
end
def valid?
@attr
end
def name=(name)
@name = name
@attr = contextualize(name) unless name.blank?
end
def dir=(dir)
@dir = %w(asc desc).include?(dir) ? dir : 'asc'
end
end
end
end

120
lib/ransack/nodes/value.rb Normal file
View File

@ -0,0 +1,120 @@
module Ransack
module Nodes
class Value < Node
attr_reader :value_before_cast, :type
delegate :blank?, :to => :value_before_cast
def initialize(context, value = nil, type = nil)
super(context)
@value_before_cast = value
self.type = type if type
end
def value=(val)
@value_before_cast = value
@value = nil
end
def value
@value ||= cast_to_type(@value_before_cast, @type)
end
def persisted?
false
end
def eql?(other)
self.class == other.class &&
self.value_before_cast == other.value_before_cast
end
alias :== :eql?
def hash
value_before_cast.hash
end
def type=(type)
@value = nil
@type = type
end
def cast_to_type(val, type)
case type
when :date
cast_to_date(val)
when :datetime, :timestamp, :time
cast_to_time(val)
when :boolean
cast_to_boolean(val)
when :integer
cast_to_integer(val)
when :float
cast_to_float(val)
when :decimal
cast_to_decimal(val)
else
cast_to_string(val)
end
end
def cast_to_date(val)
if val.respond_to?(:to_date)
val.to_date rescue nil
else
y, m, d = *[val].flatten
m ||= 1
d ||= 1
Date.new(y,m,d) rescue nil
end
end
# FIXME: doesn't seem to be casting, even with Time.zone.local
def cast_to_time(val)
if val.is_a?(Array)
Time.zone.local(*val) rescue nil
else
unless val.acts_like?(:time)
val = val.is_a?(String) ? Time.zone.parse(val) : val.to_time rescue val
end
val.in_time_zone
end
end
def cast_to_boolean(val)
if val.is_a?(String) && val.blank?
nil
else
Constants::TRUE_VALUES.include?(val)
end
end
def cast_to_string(val)
val.respond_to?(:to_s) ? val.to_s : String.new(val)
end
def cast_to_integer(val)
val.blank? ? nil : val.to_i
end
def cast_to_float(val)
val.blank? ? nil : val.to_f
end
def cast_to_decimal(val)
if val.blank?
nil
elsif val.class == BigDecimal
val
elsif val.respond_to?(:to_d)
val.to_d
else
val.to_s.to_d
end
end
def array_of_arrays?(val)
Array === val && Array === val.first
end
end
end
end

57
lib/ransack/predicate.rb Normal file
View File

@ -0,0 +1,57 @@
module Ransack
class Predicate
attr_reader :name, :arel_predicate, :type, :formatter, :validator, :compound
class << self
def named(name)
Configuration.predicates[name.to_s]
end
def for_attribute_name(attribute_name)
self.named(Configuration.predicate_keys.detect {|p| attribute_name.to_s.match(/_#{p}$/)})
end
def collection
Configuration.predicates.map {|k, v| [k, Translate.predicate(k)]}
end
end
def initialize(opts = {})
@name = opts[:name]
@arel_predicate = opts[:arel_predicate]
@type = opts[:type]
@formatter = opts[:formatter]
@validator = opts[:validator]
@compound = opts[:compound]
end
def format(vals)
if formatter
vals.select {|v| validator ? validator.call(v.value_before_cast) : !v.blank?}.
map {|v| formatter.call(v.value)}
else
vals.select {|v| validator ? validator.call(v.value_before_cast) : !v.blank?}.
map {|v| v.value}
end
end
def eql?(other)
self.class == other.class &&
self.name == other.name
end
alias :== :eql?
def hash
name.hash
end
def validate(vals)
if validator
vals.select {|v| validator.call(v.value_before_cast)}.any?
else
vals.select {|v| !v.blank?}.any?
end
end
end
end

114
lib/ransack/search.rb Normal file
View File

@ -0,0 +1,114 @@
require 'ransack/nodes'
require 'ransack/context'
require 'ransack/naming'
module Ransack
class Search
include Naming
attr_reader :base, :context
delegate :object, :klass, :to => :context
delegate :new_and, :new_or, :new_condition,
:build_and, :build_or, :build_condition,
:translate, :to => :base
def initialize(object, params = {})
params ||= {}
@context = Context.for(object)
@base = Nodes::And.new(@context)
build(params.with_indifferent_access)
end
def result(opts = {})
@result ||= @context.evaluate(self, opts)
end
def build(params)
collapse_multiparameter_attributes!(params).each do |key, value|
case key
when 's', 'sorts'
send("#{key}=", value)
else
base.send("#{key}=", value) if base.attribute_method?(key)
end
end
self
end
def sorts=(args)
case args
when Array
args.each do |sort|
sort = Nodes::Sort.extract(@context, sort)
self.sorts << sort
end
when Hash
args.each do |index, attrs|
sort = Nodes::Sort.new(@context).build(attrs)
self.sorts << sort
end
else
self.sorts = [args]
end
end
alias :s= :sorts=
def sorts
@sorts ||= []
end
alias :s :sorts
def build_sort(opts = {})
new_sort(opts).tap do |sort|
self.sorts << sort
end
end
def new_sort(opts = {})
Nodes::Sort.new(@context).build(opts)
end
def respond_to?(method_id)
super or begin
method_name = method_id.to_s
writer = method_name.sub!(/\=$/, '')
base.attribute_method?(method_name) ? true : false
end
end
def method_missing(method_id, *args)
method_name = method_id.to_s
writer = method_name.sub!(/\=$/, '')
if base.attribute_method?(method_name)
base.send(method_id, *args)
else
super
end
end
private
def collapse_multiparameter_attributes!(attrs)
attrs.keys.each do |k|
if k.include?("(")
real_attribute, position = k.split(/\(|\)/)
cast = %w(a s i).include?(position.last) ? position.last : nil
position = position.to_i - 1
value = attrs.delete(k)
attrs[real_attribute] ||= []
attrs[real_attribute][position] = if cast
(value.blank? && cast == 'i') ? nil : value.send("to_#{cast}")
else
value
end
elsif Hash === attrs[k]
collapse_multiparameter_attributes!(attrs[k])
end
end
attrs
end
end
end

92
lib/ransack/translate.rb Normal file
View File

@ -0,0 +1,92 @@
I18n.load_path += Dir[File.join(File.dirname(__FILE__), 'locale', '*.yml')]
module Ransack
module Translate
def self.word(key, options = {})
I18n.translate(:"ransack.#{key}", :default => key.to_s)
end
def self.predicate(key, options = {})
I18n.translate(:"ransack.predicates.#{key}", :default => key.to_s)
end
def self.attribute(key, options = {})
unless context = options.delete(:context)
raise ArgumentError, "A context is required to translate associations"
end
original_name = key.to_s
base_class = context.klass
base_ancestors = base_class.ancestors.select { |x| x.respond_to?(:model_name) }
predicate = Ransack::Configuration.predicate_keys.detect {|p| original_name.match(/_#{p}$/)}
attributes_str = original_name.sub(/_#{predicate}$/, '')
attribute_names = attributes_str.split(/_and_|_or_/)
combinator = attributes_str.match(/_and_/) ? :and : :or
defaults = base_ancestors.map do |klass|
:"ransack.attributes.#{klass.model_name.underscore}.#{original_name}"
end
translated_names = attribute_names.map do |attr|
attribute_name(context, attr, options[:include_associations])
end
interpolations = {}
interpolations[:attributes] = translated_names.join(" #{Translate.word(combinator)} ")
if predicate
defaults << "%{attributes} %{predicate}"
interpolations[:predicate] = Translate.predicate(predicate)
else
defaults << "%{attributes}"
end
defaults << options.delete(:default) if options[:default]
options.reverse_merge! :count => 1, :default => defaults
I18n.translate(defaults.shift, options.merge(interpolations))
end
def self.association(key, options = {})
unless context = options.delete(:context)
raise ArgumentError, "A context is required to translate associations"
end
defaults = key.blank? ? [:"#{context.klass.i18n_scope}.models.#{context.klass.model_name.underscore}"] : [:"ransack.associations.#{context.klass.model_name.underscore}.#{key}"]
defaults << context.traverse(key).model_name.human
options = {:count => 1, :default => defaults}
I18n.translate(defaults.shift, options)
end
private
def self.attribute_name(context, name, include_associations = nil)
assoc_path = context.association_path(name)
associated_class = context.traverse(assoc_path) if assoc_path.present?
attr_name = name.sub(/^#{assoc_path}_/, '')
interpolations = {}
interpolations[:attr_fallback_name] = I18n.translate(
(associated_class ?
:"ransack.attributes.#{associated_class.model_name.underscore}.#{attr_name}" :
:"ransack.attributes.#{context.klass.model_name.underscore}.#{attr_name}"
),
:default => [
(associated_class ?
:"#{associated_class.i18n_scope}.attributes.#{associated_class.model_name.underscore}.#{attr_name}" :
:"#{context.klass.i18n_scope}.attributes.#{context.klass.model_name.underscore}.#{attr_name}"
),
attr_name.humanize
]
)
defaults = [
:"ransack.attributes.#{context.klass.model_name.underscore}.#{name}"
]
if include_associations && associated_class
defaults << '%{association_name} %{attr_fallback_name}'
interpolations[:association_name] = association(assoc_path, :context => context)
else
defaults << '%{attr_fallback_name}'
end
options = {:count => 1, :default => defaults}
I18n.translate(defaults.shift, options.merge(interpolations))
end
end
end

3
lib/ransack/version.rb Normal file
View File

@ -0,0 +1,3 @@
module Ransack
VERSION = "0.1.0"
end

29
ransack.gemspec Normal file
View File

@ -0,0 +1,29 @@
# -*- encoding: utf-8 -*-
$:.push File.expand_path("../lib", __FILE__)
require "ransack/version"
Gem::Specification.new do |s|
s.name = "ransack"
s.version = Ransack::VERSION
s.platform = Gem::Platform::RUBY
s.authors = ["Ernie Miller"]
s.email = ["ernie@metautonomo.us"]
s.homepage = "http://metautonomo.us/projects/ransack"
s.summary = %q{Object-based searching. Like MetaSearch, but this time, with a better name.}
s.description = %q{Not yet ready for public consumption.}
s.rubyforge_project = "ransack"
s.add_dependency 'activerecord', '~> 3.1.0.alpha'
s.add_dependency 'activesupport', '~> 3.1.0.alpha'
s.add_dependency 'actionpack', '~> 3.1.0.alpha'
s.add_development_dependency 'rspec', '~> 2.5.0'
s.add_development_dependency 'machinist', '~> 1.0.6'
s.add_development_dependency 'faker', '~> 0.9.5'
s.add_development_dependency 'sqlite3', '~> 1.3.3'
s.files = `git ls-files`.split("\n")
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
s.require_paths = ["lib"]
end

View File

@ -0,0 +1,5 @@
Article.blueprint do
person
title
body
end

View File

@ -0,0 +1,5 @@
Comment.blueprint do
article
person
body
end

3
spec/blueprints/notes.rb Normal file
View File

@ -0,0 +1,3 @@
Note.blueprint do
note
end

View File

@ -0,0 +1,4 @@
Person.blueprint do
name
salary
end

3
spec/blueprints/tags.rb Normal file
View File

@ -0,0 +1,3 @@
Tag.blueprint do
name { Sham.tag_name }
end

22
spec/console.rb Normal file
View File

@ -0,0 +1,22 @@
Bundler.setup
require 'machinist/active_record'
require 'sham'
require 'faker'
Dir[File.expand_path('../../spec/{helpers,support,blueprints}/*.rb', __FILE__)].each do |f|
require f
end
Sham.define do
name { Faker::Name.name }
title { Faker::Lorem.sentence }
body { Faker::Lorem.paragraph }
salary {|index| 30000 + (index * 1000)}
tag_name { Faker::Lorem.words(3).join(' ') }
note { Faker::Lorem.words(7).join(' ') }
end
Schema.create
require 'ransack'

View File

@ -0,0 +1,2 @@
module RansackHelper
end

37
spec/playground.rb Normal file
View File

@ -0,0 +1,37 @@
$VERBOSE = false
require 'bundler'
Bundler.setup
require 'machinist/active_record'
require 'sham'
require 'faker'
Dir[File.expand_path('../../spec/{helpers,support,blueprints}/*.rb', __FILE__)].each do |f|
require f
end
Sham.define do
name { Faker::Name.name }
title { Faker::Lorem.sentence }
body { Faker::Lorem.paragraph }
salary {|index| 30000 + (index * 1000)}
tag_name { Faker::Lorem.words(3).join(' ') }
note { Faker::Lorem.words(7).join(' ') }
end
Schema.create
require 'ransack'
Article.joins{person.comments}.where{person.comments.body =~ '%hello%'}.to_sql
# => "SELECT \"articles\".* FROM \"articles\" INNER JOIN \"people\" ON \"people\".\"id\" = \"articles\".\"person_id\" INNER JOIN \"comments\" ON \"comments\".\"person_id\" = \"people\".\"id\" WHERE \"comments\".\"body\" LIKE '%hello%'"
Person.where{(id + 1) == 2}.first
# => #<Person id: 1, parent_id: nil, name: "Aric Smith", salary: 31000>
Person.where{(salary - 40000) < 0}.to_sql
# => "SELECT \"people\".* FROM \"people\" WHERE \"people\".\"salary\" - 40000 < 0"
p = Person.select{[id, name, salary, (salary + 1000).as('salary_after_increase')]}.first
# => #<Person id: 1, name: "Aric Smith", salary: 31000>
p.salary_after_increase # =>

View File

@ -0,0 +1,30 @@
require 'spec_helper'
module Ransack
module Adapters
module ActiveRecord
describe Base do
it 'adds a ransack method to ActiveRecord::Base' do
::ActiveRecord::Base.should respond_to :ransack
end
it 'aliases the method to search if available' do
::ActiveRecord::Base.should respond_to :search
end
describe '#search' do
before do
@s = Person.search
end
it 'creates a search with Relation as its object' do
@s.should be_a Search
@s.object.should be_an ::ActiveRecord::Relation
end
end
end
end
end
end

View File

@ -0,0 +1,29 @@
require 'spec_helper'
module Ransack
module Adapters
module ActiveRecord
describe Context do
before do
@c = Context.new(Person)
end
it 'contextualizes strings to attributes' do
attribute = @c.contextualize 'children_children_parent_name'
attribute.should be_a Arel::Attributes::Attribute
attribute.name.should eq 'name'
attribute.relation.table_alias.should eq 'parents_people'
end
it 'builds new associations if not yet built' do
attribute = @c.contextualize 'children_articles_title'
attribute.should be_a Arel::Attributes::Attribute
attribute.name.should eq 'title'
attribute.relation.name.should eq 'articles'
attribute.relation.table_alias.should be_nil
end
end
end
end
end

View File

@ -0,0 +1,11 @@
require 'spec_helper'
module Ransack
describe Configuration do
it 'yields self on configure' do
Ransack.configure do
self.should eq Ransack::Configuration
end
end
end
end

View File

@ -0,0 +1,39 @@
require 'spec_helper'
module Ransack
module Helpers
describe FormBuilder do
router = ActionDispatch::Routing::RouteSet.new
router.draw do
resources :people
match ':controller(/:action(/:id(.:format)))'
end
include router.url_helpers
# FIXME: figure out a cleaner way to get this behavior
before do
@controller = ActionView::TestCase::TestController.new
@controller.instance_variable_set(:@_routes, router)
@controller.class_eval do
include router.url_helpers
end
@s = Person.search
@controller.view_context.search_form_for @s do |f|
@f = f
end
end
it 'selects previously-entered time values with datetime_select' do
@s.created_at_eq = [2011, 1, 2, 3, 4, 5]
html = @f.datetime_select :created_at_eq
[2011, 1, 2, 3, 4, 5].each do |val|
html.should match /<option selected="selected" value="#{val}">#{val}<\/option>/o
end
end
end
end
end

View File

View File

@ -0,0 +1,13 @@
require 'spec_helper'
module Ransack
module Nodes
describe Grouping do
before do
@g = 1
end
end
end
end

View File

@ -0,0 +1,25 @@
require 'spec_helper'
module Ransack
describe Predicate do
before do
@s = Search.new(Person)
end
describe 'cont' do
it 'generates a LIKE query with value surrounded by %' do
@s.name_cont = 'ric'
@s.result.to_sql.should match /"people"."name" LIKE '%ric%'/
end
end
describe 'not_cont' do
it 'generates a NOT LIKE query with value surrounded by %' do
@s.name_not_cont = 'ric'
@s.result.to_sql.should match /"people"."name" NOT LIKE '%ric%'/
end
end
end
end

182
spec/ransack/search_spec.rb Normal file
View File

@ -0,0 +1,182 @@
require 'spec_helper'
module Ransack
describe Search do
describe '#build' do
it 'creates Conditions for top-level attributes' do
search = Search.new(Person, :name_eq => 'Ernie')
condition = search.base[:name_eq]
condition.should be_a Nodes::Condition
condition.predicate.name.should eq 'eq'
condition.attributes.first.name.should eq 'name'
condition.value.should eq 'Ernie'
end
it 'creates Conditions for association attributes' do
search = Search.new(Person, :children_name_eq => 'Ernie')
condition = search.base[:children_name_eq]
condition.should be_a Nodes::Condition
condition.predicate.name.should eq 'eq'
condition.attributes.first.name.should eq 'children_name'
condition.value.should eq 'Ernie'
end
it 'discards empty conditions' do
search = Search.new(Person, :children_name_eq => '')
condition = search.base[:children_name_eq]
condition.should be_nil
end
it 'accepts arrays of groupings' do
search = Search.new(Person,
:o => [
{:name_eq => 'Ernie', :children_name_eq => 'Ernie'},
{:name_eq => 'Bert', :children_name_eq => 'Bert'},
]
)
ors = search.ors
ors.should have(2).items
or1, or2 = ors
or1.should be_a Nodes::Or
or2.should be_a Nodes::Or
end
it 'accepts "attributes" hashes for groupings' do
search = Search.new(Person,
:o => {
'0' => {:name_eq => 'Ernie', :children_name_eq => 'Ernie'},
'1' => {:name_eq => 'Bert', :children_name_eq => 'Bert'},
}
)
ors = search.ors
ors.should have(2).items
or1, or2 = ors
or1.should be_a Nodes::Or
or2.should be_a Nodes::Or
end
it 'accepts "attributes" hashes for conditions' do
search = Search.new(Person,
:c => {
'0' => {:a => ['name'], :p => 'eq', :v => ['Ernie']},
'1' => {:a => ['children_name', 'parent_name'], :p => 'eq', :v => ['Ernie'], :m => 'or'}
}
)
conditions = search.base.conditions
conditions.should have(2).items
conditions.map {|c| c.class}.should eq [Nodes::Condition, Nodes::Condition]
end
end
describe '#result' do
it 'evaluates conditions contextually' do
search = Search.new(Person, :children_name_eq => 'Ernie')
search.result.should be_an ActiveRecord::Relation
where = search.result.where_values.first
where.to_sql.should match /"children_people"\."name" = 'Ernie'/
end
it 'evaluates compound conditions contextually' do
search = Search.new(Person, :children_name_or_name_eq => 'Ernie')
search.result.should be_an ActiveRecord::Relation
where = search.result.where_values.first
where.to_sql.should match /"children_people"\."name" = 'Ernie' OR "people"\."name" = 'Ernie'/
end
it 'evaluates nested conditions' do
search = Search.new(Person, :children_name_eq => 'Ernie',
:o => [{
:name_eq => 'Ernie',
:children_children_name_eq => 'Ernie'
}]
)
search.result.should be_an ActiveRecord::Relation
where = search.result.where_values.first
where.to_sql.should match /\("children_people"."name" = 'Ernie' AND \("people"."name" = 'Ernie' OR "children_people_2"."name" = 'Ernie'\)\)/
end
it 'evaluates arrays of groupings' do
search = Search.new(Person,
:o => [
{:name_eq => 'Ernie', :children_name_eq => 'Ernie'},
{:name_eq => 'Bert', :children_name_eq => 'Bert'},
]
)
search.result.should be_an ActiveRecord::Relation
where = search.result.where_values.first
where.to_sql.should match /\(\("people"."name" = 'Ernie' OR "children_people"."name" = 'Ernie'\) AND \("people"."name" = 'Bert' OR "children_people"."name" = 'Bert'\)\)/
end
end
describe '#sorts=' do
before do
@s = Search.new(Person)
end
it 'creates sorts based on a single attribute/direction' do
@s.sorts = 'id desc'
@s.sorts.should have(1).item
sort = @s.sorts.first
sort.should be_a Nodes::Sort
sort.name.should eq 'id'
sort.dir.should eq 'desc'
end
it 'creates sorts based on multiple attributes/directions in array format' do
@s.sorts = ['id desc', 'name asc']
@s.sorts.should have(2).items
sort1, sort2 = @s.sorts
sort1.should be_a Nodes::Sort
sort1.name.should eq 'id'
sort1.dir.should eq 'desc'
sort2.should be_a Nodes::Sort
sort2.name.should eq 'name'
sort2.dir.should eq 'asc'
end
it 'creates sorts based on multiple attributes/directions in hash format' do
@s.sorts = {
'0' => {
:name => 'id',
:dir => 'desc'
},
'1' => {
:name => 'name',
:dir => 'asc'
}
}
@s.sorts.should have(2).items
sort1, sort2 = @s.sorts
sort1.should be_a Nodes::Sort
sort1.name.should eq 'id'
sort1.dir.should eq 'desc'
sort2.should be_a Nodes::Sort
sort2.name.should eq 'name'
sort2.dir.should eq 'asc'
end
end
describe '#method_missing' do
before do
@s = Search.new(Person)
end
it 'raises NoMethodError when sent an invalid attribute' do
expect {@s.blah}.to raise_error NoMethodError
end
it 'sets condition attributes when sent valid attributes' do
@s.name_eq = 'Ernie'
@s.name_eq.should eq 'Ernie'
end
it 'allows chaining to access nested conditions' do
@s.ors = [{:name_eq => 'Ernie', :children_name_eq => 'Ernie'}]
@s.ors.first.name_eq.should eq 'Ernie'
@s.ors.first.children_name_eq.should eq 'Ernie'
end
end
end
end

28
spec/spec_helper.rb Normal file
View File

@ -0,0 +1,28 @@
require 'machinist/active_record'
require 'sham'
require 'faker'
Time.zone = 'Eastern Time (US & Canada)'
Dir[File.expand_path('../{helpers,support,blueprints}/*.rb', __FILE__)].each do |f|
require f
end
Sham.define do
name { Faker::Name.name }
title { Faker::Lorem.sentence }
body { Faker::Lorem.paragraph }
salary {|index| 30000 + (index * 1000)}
tag_name { Faker::Lorem.words(3).join(' ') }
note { Faker::Lorem.words(7).join(' ') }
end
RSpec.configure do |config|
config.before(:suite) { Schema.create }
config.before(:all) { Sham.reset(:before_all) }
config.before(:each) { Sham.reset(:before_each) }
config.include RansackHelper
end
require 'ransack'

102
spec/support/schema.rb Normal file
View File

@ -0,0 +1,102 @@
require 'active_record'
ActiveRecord::Base.establish_connection(
:adapter => 'sqlite3',
:database => ':memory:'
)
class Person < ActiveRecord::Base
belongs_to :parent, :class_name => 'Person', :foreign_key => :parent_id
has_many :children, :class_name => 'Person', :foreign_key => :parent_id
has_many :articles
has_many :comments
has_many :authored_article_comments, :through => :articles,
:class_name => 'Comment', :foreign_key => :person_id
has_many :notes, :as => :notable
end
class Article < ActiveRecord::Base
belongs_to :person
has_many :comments
has_and_belongs_to_many :tags
has_many :notes, :as => :notable
end
class Comment < ActiveRecord::Base
belongs_to :article
belongs_to :person
end
class Tag < ActiveRecord::Base
has_and_belongs_to_many :articles
end
class Note < ActiveRecord::Base
belongs_to :notable, :polymorphic => true
end
module Schema
def self.create
ActiveRecord::Base.silence do
ActiveRecord::Migration.verbose = false
ActiveRecord::Schema.define do
create_table :people, :force => true do |t|
t.integer :parent_id
t.string :name
t.integer :salary
t.timestamps
end
create_table :articles, :force => true do |t|
t.integer :person_id
t.string :title
t.text :body
end
create_table :comments, :force => true do |t|
t.integer :article_id
t.integer :person_id
t.text :body
end
create_table :tags, :force => true do |t|
t.string :name
end
create_table :articles_tags, :force => true, :id => false do |t|
t.integer :article_id
t.integer :tag_id
end
create_table :notes, :force => true do |t|
t.integer :notable_id
t.string :notable_type
t.string :note
end
end
end
10.times do
person = Person.make
Note.make(:notable => person)
3.times do
article = Article.make(:person => person)
3.times do
article.tags = [Tag.make, Tag.make, Tag.make]
end
Note.make(:notable => article)
10.times do
Comment.make(:article => article)
end
end
2.times do
Comment.make(:person => person)
end
end
Comment.make(:body => 'First post!', :article => Article.make(:title => 'Hello, world!'))
end
end