Merge pull request #1 from activerecord-hackery/master

rebase ....
This commit is contained in:
Andreas Philippi 2014-09-22 15:15:23 +03:00
commit 9f284cb98a
4 changed files with 183 additions and 87 deletions

View File

@ -4,10 +4,12 @@ All notable changes to this project from August 2014 on will be documented here.
## Unreleased
### Added
* Add `not_true` and `not_false` predicates and update the "Basic Searching"
wiki.
* Add `ro.yml` Romanian translation file.
Fixes #123, #353.
*Andreas Philippi*
* Add `not_true` and `not_false` predicates and update the "Basic Searching"
wiki. Fixes #123, #353.
*Pedro Chambino*
@ -17,16 +19,12 @@ All notable changes to this project from August 2014 on will be documented here.
*Jon Atack*
### Changed
* Rewrite/improve much of the README doc, including the Associations section
code examples and the Authorizations section showing how to whitelist
attributes, associations, sorts and scopes.
*Jon Atack*
### Fixed
* Fix attribute translations when using ActiveRecord with STI.
*Andreas Philippi*
* Fix the params hash being modified by `Search.new` and the Ransack scope.
*Daniel Rikowski*
@ -44,6 +42,16 @@ All notable changes to this project from August 2014 on will be documented here.
*Jon Atack*
### Changed
* Refactor Ransack::Translate.
* Rewrite much of the README doc, including the Associations section
code examples and the Authorizations section showing how to whitelist
attributes, associations, sorts and scopes.
*Jon Atack*
## Version 1.3.0 - 2014-08-23
### Added

View File

@ -90,16 +90,18 @@ If you're coming from MetaSearch, things to note:
3. Common ActiveRecord::Relation methods are no longer delegated by the
search object. Instead, you will get your search results (an
ActiveRecord::Relation in the case of the ActiveRecord adapter) via a call to
`Search#result`. If passed `distinct: true`, `result` will generate a `SELECT
DISTINCT` to avoid returning duplicate rows, even if conditions on a join
would otherwise result in some.
`Search#result`.
4. If passed `distinct: true`, `result` will generate a `SELECT DISTINCT` to
avoid returning duplicate rows, even if conditions on a join would otherwise
result in some.
Please note that for many databases, a sort on an associated table's columns
will result in invalid SQL with `distinct: true` -- in those cases, you're on
may result in invalid SQL with `distinct: true` -- in those cases, you're on
your own, and will need to modify the result as needed to allow these queries
to work. Thankfully, 9 times out of 10, sort against the search's base is
sufficient, though, as that's generally what's being displayed on your
results page.
to work. If `distinct: true` is causing you problems, another way to remove
duplicates is to call `#to_a.uniq` on your collection instead (see the next
section below).
####In your controller
@ -116,6 +118,8 @@ this example, with preloading each Person's Articles and pagination):
def index
@q = Person.search(params[:q])
@people = @q.result.includes(:articles).page(params[:page])
# or use `to_a.uniq` to remove duplicates (can also be done in the view):
@people = @q.result.includes(:articles).page(params[:page]).to_a.uniq
end
```
@ -295,7 +299,9 @@ Feel free to contribute working `ransacker` code examples to the wiki!
### Authorization (whitelisting/blacklisting)
By default, searching and sorting are authorized on any column of your model.
By default, searching and sorting are authorized on any column of your model
and no class methods/scopes are whitelisted.
Ransack adds four methods to `ActiveRecord::Base` that you can redefine as
class methods in your models to apply selective authorization:
`ransackable_attributes`, `ransackable_associations`, `ransackable_scopes` and
@ -305,27 +311,32 @@ Here is how these four methods are implemented in Ransack:
```ruby
def ransackable_attributes(auth_object = nil)
# Returns the string names of all columns and any defined ransackers.
# By default returns all column names and any defined ransackers as an array
# of strings. For overriding with a whitelist array of strings.
column_names + _ransackers.keys
end
def ransackable_associations(auth_object = nil)
# Returns the names of all associations.
# By default returns the names of all associations as an array of strings.
# For overriding with a whitelist array of strings.
reflect_on_all_associations.map { |a| a.name.to_s }
end
def ransackable_scopes(auth_object = nil)
# For overriding with a whitelist of symbols.
[]
end
def ransortable_attributes(auth_object = nil)
# Here so users can overwrite the attributes that show up in the sort_select.
# By default returns the names of all attributes for sorting as an array of
# strings. For overriding with a whitelist array of strings.
ransackable_attributes(auth_object)
end
def ransackable_scopes(auth_object = nil)
# By default returns an empty array, i.e. no class methods/scopes
# are authorized. For overriding with a whitelist array of *symbols*.
[]
end
```
Any values not returned from these methods will be ignored by Ransack.
Any values not returned from these methods will be ignored by Ransack, i.e.
they are not authorized.
All four methods can receive a single optional parameter, `auth_object`. When
you call the search or ransack method on your model, you can provide a value
@ -393,14 +404,38 @@ Trying it out in `rails console`:
```
That's it! Now you know how to whitelist/blacklist various elements in Ransack.
### Scopes
### Using Scopes/Class Methods
Continuing on from the preceding section, searching by scope requires defining
a whitelist of `ransackable_scopes` on the model class. By default, all class
methods (e.g. scopes) are ignored. Scopes will be applied for matching `true`
values, or for given values if the scope accepts a value:
Continuing on from the preceding section, searching by scopes requires defining
a whitelist of `ransackable_scopes` on the model class. The whitelist should be
an array of *symbols*. By default, all class methods (e.g. scopes) are ignored.
Scopes will be applied for matching `true` values, or for given values if the
scope accepts a value:
```ruby
class Employee < ActiveRecord::Base
scope :active, ->(boolean = true) { (where active: boolean) }
scope :salary_gt, ->(amount) { where('salary > ?', amount) }
# Scopes are just syntactical sugar for class methods, which may also be used:
def self.hired_since(date)
where('start_date >= ?', date)
end
private
def self.ransackable_scopes(auth_object = nil)
if auth_object.try(:admin?)
# allow admin users access to all three methods
%i(active hired_since salary_gt)
else
# allow other users to search on active and hired_since only
%i(active hired_since)
end
end
end
Employee.search({ active: true, hired_since: '2013-01-01' })
Employee.search({ salary_gt: 100_000 }, { auth_object: current_user })

View File

@ -20,21 +20,34 @@ module Ransack
.new(self, name, opts, &block)
end
# Ransackable_attributes, by default, returns all column names
# and any defined ransackers as an array of strings.
# For overriding with a whitelist array of strings.
#
def ransackable_attributes(auth_object = nil)
column_names + _ransackers.keys
end
def ransortable_attributes(auth_object = nil)
# Here so users can overwrite the attributes
# that show up in the sort_select
ransackable_attributes(auth_object)
end
# Ransackable_associations, by default, returns the names
# of all associations as an array of strings.
# For overriding with a whitelist array of strings.
#
def ransackable_associations(auth_object = nil)
reflect_on_all_associations.map { |a| a.name.to_s }
end
# For overriding with a whitelist of symbols
# Ransortable_attributes, by default, returns the names
# of all attributes available for sorting as an array of strings.
# For overriding with a whitelist array of strings.
#
def ransortable_attributes(auth_object = nil)
ransackable_attributes(auth_object)
end
# Ransackable_scopes, by default, returns an empty array
# i.e. no class methods/scopes are authorized.
# For overriding with a whitelist array of *symbols*.
#
def ransackable_scopes(auth_object = nil)
[]
end

View File

@ -27,23 +27,22 @@ module Ransack
attribute_names = attributes_str.split(/_and_|_or_/)
combinator = attributes_str.match(/_and_/) ? :and : :or
defaults = base_ancestors.map do |klass|
:"ransack.attributes.#{i18n_key(klass)}.#{original_name}"
"ransack.attributes.#{i18n_key(klass)}.#{original_name}".to_sym
end
translated_names = attribute_names.map do |attr|
attribute_name(context, attr, options[:include_associations])
translated_names = attribute_names.map do |name|
attribute_name(context, name, options[:include_associations])
end
interpolations = {}
interpolations[:attributes] = translated_names.join(
" #{Translate.word(combinator)} "
)
interpolations = {
:attributes => translated_names.join(" #{Translate.word(combinator)} ")
}
if predicate
defaults << "%{attributes} %{predicate}"
defaults << "%{attributes} %{predicate}".freeze
interpolations[:predicate] = Translate.predicate(predicate)
else
defaults << "%{attributes}"
defaults << "%{attributes}".freeze
end
defaults << options.delete(:default) if options[:default]
@ -56,55 +55,96 @@ module Ransack
raise ArgumentError, "A context is required to translate associations"
end
defaults = key.blank? ?
[:"#{context.klass.i18n_scope}.models.#{i18n_key(context.klass)}"] :
[:"ransack.associations.#{i18n_key(context.klass)}.#{key}"]
defaults =
if key.blank?
[:"#{context.klass.i18n_scope}.models.#{i18n_key(context.klass)}"]
else
[:"ransack.associations.#{i18n_key(context.klass)}.#{key}"]
end
defaults << context.traverse(key).model_name.human
options = { :count => 1, :default => defaults }
I18n.translate(defaults.shift, options)
end
private
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(
:"ransack.attributes.#{
i18n_key(associated_class || context.klass)
}.#{attr_name}",
:default => [
(
if associated_class
:"#{associated_class.i18n_scope}.attributes.#{
i18n_key(associated_class)}.#{attr_name}"
else
context.klass.ancestors.select do |k|
k.respond_to?(:model_name)
end.collect do |k|
:"#{k.i18n_scope}.attributes.#{i18n_key(k)}.#{attr_name}"
end
end
),
:".attributes.#{attr_name}",
attr_name.humanize
].flatten
)
defaults = [:"ransack.attributes.#{i18n_key(context.klass)}.#{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
@context, @name = context, name
@assoc_path = context.association_path(name)
@attr_name = @name.sub(/^#{@assoc_path}_/, '')
associated_class = @context.traverse(@assoc_path) if @assoc_path.present?
@include_associated = include_associations && associated_class
defaults = default_attribute_name << fallback_args
options = { :count => 1, :default => defaults }
interpolations = build_interpolations(associated_class)
I18n.translate(defaults.shift, options.merge(interpolations))
end
def self.default_attribute_name
["ransack.attributes.#{i18n_key(@context.klass)}.#{@name}".to_sym]
end
def self.fallback_args
if @include_associated
'%{association_name} %{attr_fallback_name}'.freeze
else
'%{attr_fallback_name}'.freeze
end
end
def self.build_interpolations(associated_class)
{
:attr_fallback_name => attr_fallback_name(associated_class),
:association_name => association_name
}
.reject! { |_, value| value.nil? }
end
def self.attr_fallback_name(associated_class)
I18n.t(
:"ransack.attributes.#{fallback_class(associated_class)}.#{@attr_name}",
:default => default_interpolation(associated_class)
)
end
def self.fallback_class(associated_class)
i18n_key(associated_class || @context.klass)
end
def self.association_name
association(@assoc_path, :context => @context) if @include_associated
end
def self.default_interpolation(associated_class)
[
associated_attribute(associated_class),
".attributes.#{@attr_name}".to_sym,
@attr_name.humanize
]
.flatten
end
def self.associated_attribute(associated_class)
if associated_class
translated_attribute(associated_class)
else
translated_ancestor_attributes
end
end
def self.translated_attribute(associated_class)
"#{associated_class.i18n_scope}.attributes.#{
i18n_key(associated_class)}.#{@attr_name}".to_sym
end
def self.translated_ancestor_attributes
@context.klass.ancestors
.select { |ancestor| ancestor.respond_to?(:model_name) }
.map { |ancestor| translated_attribute(ancestor) }
end
def self.i18n_key(klass)
if ActiveRecord::VERSION::MAJOR == 3 && ActiveRecord::VERSION::MINOR == 0
klass.model_name.i18n_key.to_s.tr('.', '/')