From 294015309b03240c6a9a9aea69118a24d7f737ac Mon Sep 17 00:00:00 2001 From: Ernie Miller Date: Wed, 30 Mar 2011 20:31:39 -0400 Subject: [PATCH] Initial commit. --- .gitignore | 4 + Gemfile | 11 + LICENSE | 20 ++ README.rdoc | 5 + Rakefile | 19 ++ lib/ransack.rb | 24 ++ lib/ransack/adapters/active_record.rb | 2 + lib/ransack/adapters/active_record/base.rb | 17 ++ lib/ransack/adapters/active_record/context.rb | 153 +++++++++++++ lib/ransack/configuration.rb | 39 ++++ lib/ransack/constants.rb | 23 ++ lib/ransack/context.rb | 152 +++++++++++++ lib/ransack/helpers.rb | 2 + lib/ransack/helpers/form_builder.rb | 172 ++++++++++++++ lib/ransack/helpers/form_helper.rb | 27 +++ lib/ransack/locale/en.yml | 67 ++++++ lib/ransack/naming.rb | 53 +++++ lib/ransack/nodes.rb | 7 + lib/ransack/nodes/and.rb | 8 + lib/ransack/nodes/attribute.rb | 36 +++ lib/ransack/nodes/condition.rb | 209 ++++++++++++++++++ lib/ransack/nodes/grouping.rb | 207 +++++++++++++++++ lib/ransack/nodes/node.rb | 34 +++ lib/ransack/nodes/or.rb | 8 + lib/ransack/nodes/sort.rb | 39 ++++ lib/ransack/nodes/value.rb | 120 ++++++++++ lib/ransack/predicate.rb | 57 +++++ lib/ransack/search.rb | 114 ++++++++++ lib/ransack/translate.rb | 92 ++++++++ lib/ransack/version.rb | 3 + ransack.gemspec | 29 +++ spec/blueprints/articles.rb | 5 + spec/blueprints/comments.rb | 5 + spec/blueprints/notes.rb | 3 + spec/blueprints/people.rb | 4 + spec/blueprints/tags.rb | 3 + spec/console.rb | 22 ++ spec/helpers/ransack_helper.rb | 2 + spec/playground.rb | 37 ++++ .../adapters/active_record/base_spec.rb | 30 +++ .../adapters/active_record/context_spec.rb | 29 +++ spec/ransack/configuration_spec.rb | 11 + spec/ransack/helpers/form_builder_spec.rb | 39 ++++ spec/ransack/nodes/compound_condition_spec.rb | 0 spec/ransack/nodes/condition_spec.rb | 0 spec/ransack/nodes/grouping_spec.rb | 13 ++ spec/ransack/predicate_spec.rb | 25 +++ spec/ransack/search_spec.rb | 182 +++++++++++++++ spec/spec_helper.rb | 28 +++ spec/support/schema.rb | 102 +++++++++ 50 files changed, 2293 insertions(+) create mode 100644 .gitignore create mode 100644 Gemfile create mode 100644 LICENSE create mode 100644 README.rdoc create mode 100644 Rakefile create mode 100644 lib/ransack.rb create mode 100644 lib/ransack/adapters/active_record.rb create mode 100644 lib/ransack/adapters/active_record/base.rb create mode 100644 lib/ransack/adapters/active_record/context.rb create mode 100644 lib/ransack/configuration.rb create mode 100644 lib/ransack/constants.rb create mode 100644 lib/ransack/context.rb create mode 100644 lib/ransack/helpers.rb create mode 100644 lib/ransack/helpers/form_builder.rb create mode 100644 lib/ransack/helpers/form_helper.rb create mode 100644 lib/ransack/locale/en.yml create mode 100644 lib/ransack/naming.rb create mode 100644 lib/ransack/nodes.rb create mode 100644 lib/ransack/nodes/and.rb create mode 100644 lib/ransack/nodes/attribute.rb create mode 100644 lib/ransack/nodes/condition.rb create mode 100644 lib/ransack/nodes/grouping.rb create mode 100644 lib/ransack/nodes/node.rb create mode 100644 lib/ransack/nodes/or.rb create mode 100644 lib/ransack/nodes/sort.rb create mode 100644 lib/ransack/nodes/value.rb create mode 100644 lib/ransack/predicate.rb create mode 100644 lib/ransack/search.rb create mode 100644 lib/ransack/translate.rb create mode 100644 lib/ransack/version.rb create mode 100644 ransack.gemspec create mode 100644 spec/blueprints/articles.rb create mode 100644 spec/blueprints/comments.rb create mode 100644 spec/blueprints/notes.rb create mode 100644 spec/blueprints/people.rb create mode 100644 spec/blueprints/tags.rb create mode 100644 spec/console.rb create mode 100644 spec/helpers/ransack_helper.rb create mode 100644 spec/playground.rb create mode 100644 spec/ransack/adapters/active_record/base_spec.rb create mode 100644 spec/ransack/adapters/active_record/context_spec.rb create mode 100644 spec/ransack/configuration_spec.rb create mode 100644 spec/ransack/helpers/form_builder_spec.rb create mode 100644 spec/ransack/nodes/compound_condition_spec.rb create mode 100644 spec/ransack/nodes/condition_spec.rb create mode 100644 spec/ransack/nodes/grouping_spec.rb create mode 100644 spec/ransack/predicate_spec.rb create mode 100644 spec/ransack/search_spec.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/support/schema.rb diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4040c6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.gem +.bundle +Gemfile.lock +pkg/* diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..5cd2665 --- /dev/null +++ b/Gemfile @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fea7298 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.rdoc b/README.rdoc new file mode 100644 index 0000000..8091a1e --- /dev/null +++ b/README.rdoc @@ -0,0 +1,5 @@ += Ransack + +Don't use me. + +Seriously, I'm not anywhere close to ready for public consumption, yet. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..46350ff --- /dev/null +++ b/Rakefile @@ -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 \ No newline at end of file diff --git a/lib/ransack.rb b/lib/ransack.rb new file mode 100644 index 0000000..ab554e1 --- /dev/null +++ b/lib/ransack.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/adapters/active_record.rb b/lib/ransack/adapters/active_record.rb new file mode 100644 index 0000000..36e767c --- /dev/null +++ b/lib/ransack/adapters/active_record.rb @@ -0,0 +1,2 @@ +require 'ransack/adapters/active_record/base' +require 'ransack/adapters/active_record/context' \ No newline at end of file diff --git a/lib/ransack/adapters/active_record/base.rb b/lib/ransack/adapters/active_record/base.rb new file mode 100644 index 0000000..4c9b844 --- /dev/null +++ b/lib/ransack/adapters/active_record/base.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/adapters/active_record/context.rb b/lib/ransack/adapters/active_record/context.rb new file mode 100644 index 0000000..c6ab467 --- /dev/null +++ b/lib/ransack/adapters/active_record/context.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/configuration.rb b/lib/ransack/configuration.rb new file mode 100644 index 0000000..4277293 --- /dev/null +++ b/lib/ransack/configuration.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/constants.rb b/lib/ransack/constants.rb new file mode 100644 index 0000000..59e26c0 --- /dev/null +++ b/lib/ransack/constants.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/context.rb b/lib/ransack/context.rb new file mode 100644 index 0000000..64b9519 --- /dev/null +++ b/lib/ransack/context.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/helpers.rb b/lib/ransack/helpers.rb new file mode 100644 index 0000000..6e0c616 --- /dev/null +++ b/lib/ransack/helpers.rb @@ -0,0 +1,2 @@ +require 'ransack/helpers/form_builder' +require 'ransack/helpers/form_helper' \ No newline at end of file diff --git a/lib/ransack/helpers/form_builder.rb b/lib/ransack/helpers/form_builder.rb new file mode 100644 index 0000000..3a00994 --- /dev/null +++ b/lib/ransack/helpers/form_builder.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/helpers/form_helper.rb b/lib/ransack/helpers/form_helper.rb new file mode 100644 index 0000000..ba51ffc --- /dev/null +++ b/lib/ransack/helpers/form_helper.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/locale/en.yml b/lib/ransack/locale/en.yml new file mode 100644 index 0000000..54c6db0 --- /dev/null +++ b/lib/ransack/locale/en.yml @@ -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" \ No newline at end of file diff --git a/lib/ransack/naming.rb b/lib/ransack/naming.rb new file mode 100644 index 0000000..2fd44e5 --- /dev/null +++ b/lib/ransack/naming.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/nodes.rb b/lib/ransack/nodes.rb new file mode 100644 index 0000000..9895b67 --- /dev/null +++ b/lib/ransack/nodes.rb @@ -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' \ No newline at end of file diff --git a/lib/ransack/nodes/and.rb b/lib/ransack/nodes/and.rb new file mode 100644 index 0000000..0203ea1 --- /dev/null +++ b/lib/ransack/nodes/and.rb @@ -0,0 +1,8 @@ +require 'ransack/nodes/grouping' + +module Ransack + module Nodes + class And < Grouping + end + end +end \ No newline at end of file diff --git a/lib/ransack/nodes/attribute.rb b/lib/ransack/nodes/attribute.rb new file mode 100644 index 0000000..bfd68c5 --- /dev/null +++ b/lib/ransack/nodes/attribute.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/nodes/condition.rb b/lib/ransack/nodes/condition.rb new file mode 100644 index 0000000..95d0f22 --- /dev/null +++ b/lib/ransack/nodes/condition.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/nodes/grouping.rb b/lib/ransack/nodes/grouping.rb new file mode 100644 index 0000000..71ead40 --- /dev/null +++ b/lib/ransack/nodes/grouping.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/nodes/node.rb b/lib/ransack/nodes/node.rb new file mode 100644 index 0000000..7c35b4d --- /dev/null +++ b/lib/ransack/nodes/node.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/nodes/or.rb b/lib/ransack/nodes/or.rb new file mode 100644 index 0000000..dc8468f --- /dev/null +++ b/lib/ransack/nodes/or.rb @@ -0,0 +1,8 @@ +require 'ransack/nodes/grouping' + +module Ransack + module Nodes + class Or < Grouping + end + end +end \ No newline at end of file diff --git a/lib/ransack/nodes/sort.rb b/lib/ransack/nodes/sort.rb new file mode 100644 index 0000000..11ec795 --- /dev/null +++ b/lib/ransack/nodes/sort.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/nodes/value.rb b/lib/ransack/nodes/value.rb new file mode 100644 index 0000000..73ff871 --- /dev/null +++ b/lib/ransack/nodes/value.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/predicate.rb b/lib/ransack/predicate.rb new file mode 100644 index 0000000..fa7c02d --- /dev/null +++ b/lib/ransack/predicate.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/search.rb b/lib/ransack/search.rb new file mode 100644 index 0000000..cb62f0b --- /dev/null +++ b/lib/ransack/search.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/translate.rb b/lib/ransack/translate.rb new file mode 100644 index 0000000..ba15c12 --- /dev/null +++ b/lib/ransack/translate.rb @@ -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 \ No newline at end of file diff --git a/lib/ransack/version.rb b/lib/ransack/version.rb new file mode 100644 index 0000000..523f140 --- /dev/null +++ b/lib/ransack/version.rb @@ -0,0 +1,3 @@ +module Ransack + VERSION = "0.1.0" +end diff --git a/ransack.gemspec b/ransack.gemspec new file mode 100644 index 0000000..a78c3f0 --- /dev/null +++ b/ransack.gemspec @@ -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 diff --git a/spec/blueprints/articles.rb b/spec/blueprints/articles.rb new file mode 100644 index 0000000..42e2f14 --- /dev/null +++ b/spec/blueprints/articles.rb @@ -0,0 +1,5 @@ +Article.blueprint do + person + title + body +end \ No newline at end of file diff --git a/spec/blueprints/comments.rb b/spec/blueprints/comments.rb new file mode 100644 index 0000000..1ecc7eb --- /dev/null +++ b/spec/blueprints/comments.rb @@ -0,0 +1,5 @@ +Comment.blueprint do + article + person + body +end \ No newline at end of file diff --git a/spec/blueprints/notes.rb b/spec/blueprints/notes.rb new file mode 100644 index 0000000..f143114 --- /dev/null +++ b/spec/blueprints/notes.rb @@ -0,0 +1,3 @@ +Note.blueprint do + note +end \ No newline at end of file diff --git a/spec/blueprints/people.rb b/spec/blueprints/people.rb new file mode 100644 index 0000000..b7ba882 --- /dev/null +++ b/spec/blueprints/people.rb @@ -0,0 +1,4 @@ +Person.blueprint do + name + salary +end \ No newline at end of file diff --git a/spec/blueprints/tags.rb b/spec/blueprints/tags.rb new file mode 100644 index 0000000..f0fb3d8 --- /dev/null +++ b/spec/blueprints/tags.rb @@ -0,0 +1,3 @@ +Tag.blueprint do + name { Sham.tag_name } +end \ No newline at end of file diff --git a/spec/console.rb b/spec/console.rb new file mode 100644 index 0000000..24677d8 --- /dev/null +++ b/spec/console.rb @@ -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' + diff --git a/spec/helpers/ransack_helper.rb b/spec/helpers/ransack_helper.rb new file mode 100644 index 0000000..b7374aa --- /dev/null +++ b/spec/helpers/ransack_helper.rb @@ -0,0 +1,2 @@ +module RansackHelper +end \ No newline at end of file diff --git a/spec/playground.rb b/spec/playground.rb new file mode 100644 index 0000000..78e429a --- /dev/null +++ b/spec/playground.rb @@ -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.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 +# => # + +p.salary_after_increase # => \ No newline at end of file diff --git a/spec/ransack/adapters/active_record/base_spec.rb b/spec/ransack/adapters/active_record/base_spec.rb new file mode 100644 index 0000000..615bbc2 --- /dev/null +++ b/spec/ransack/adapters/active_record/base_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/ransack/adapters/active_record/context_spec.rb b/spec/ransack/adapters/active_record/context_spec.rb new file mode 100644 index 0000000..17943d4 --- /dev/null +++ b/spec/ransack/adapters/active_record/context_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/ransack/configuration_spec.rb b/spec/ransack/configuration_spec.rb new file mode 100644 index 0000000..669b10f --- /dev/null +++ b/spec/ransack/configuration_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/ransack/helpers/form_builder_spec.rb b/spec/ransack/helpers/form_builder_spec.rb new file mode 100644 index 0000000..e6bff49 --- /dev/null +++ b/spec/ransack/helpers/form_builder_spec.rb @@ -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 /