diff --git a/lib/ajax-datatables-rails.rb b/lib/ajax-datatables-rails.rb index c1b7420d..5eadf0f6 100644 --- a/lib/ajax-datatables-rails.rb +++ b/lib/ajax-datatables-rails.rb @@ -2,6 +2,7 @@ require 'ajax-datatables-rails/config' require 'ajax-datatables-rails/models' require 'ajax-datatables-rails/base' +require 'ajax-datatables-rails/column' require 'ajax-datatables-rails/extensions/simple_paginator' require 'ajax-datatables-rails/extensions/kaminari' require 'ajax-datatables-rails/extensions/will_paginate' diff --git a/lib/ajax-datatables-rails/base.rb b/lib/ajax-datatables-rails/base.rb index ae3fd53f..5a547a4d 100644 --- a/lib/ajax-datatables-rails/base.rb +++ b/lib/ajax-datatables-rails/base.rb @@ -72,11 +72,10 @@ def fetch_records end def sort_records(records) - sort_by = [] - params[:order].each_value do |item| - sort_by << "#{sort_column(item)} #{sort_direction(item)}" + params[:order].values.reduce(records) do |sorted_records, item| + condition = sort_column(item).order_condition(sort_direction(item)) + sorted_records.order(condition) end - records.order(sort_by.join(", ")) end def paginate_records(records) @@ -107,51 +106,23 @@ def composite_search(records) def build_conditions_for(query) search_for = query.split(' ') - criteria = search_for.inject([]) do |criteria, atom| - criteria << searchable_columns.map { |col| search_condition(col, atom) }.reduce(:or) + search_for.inject([]) do |criteria, atom| + criteria << searchable_columns.map do |col| + Column.from_string(col, config.db_adapter).filter_condition(atom) + end.reduce(:or) end.reduce(:and) - criteria - end - - def search_condition(column, value) - if column[0] == column.downcase[0] - ::AjaxDatatablesRails::Base.deprecated '[DEPRECATED] Using table_name.column_name notation is deprecated. Please refer to: https://github.com/antillas21/ajax-datatables-rails#searchable-and-sortable-columns-syntax' - return deprecated_search_condition(column, value) - else - return new_search_condition(column, value) - end - end - - def new_search_condition(column, value) - model, column = column.split('.') - model = model.constantize - casted_column = ::Arel::Nodes::NamedFunction.new('CAST', [model.arel_table[column.to_sym].as(typecast)]) - casted_column.matches("%#{value}%") - end - - def deprecated_search_condition(column, value) - model, column = column.split('.') - model = model.singularize.titleize.gsub( / /, '' ).constantize - - casted_column = ::Arel::Nodes::NamedFunction.new('CAST', [model.arel_table[column.to_sym].as(typecast)]) - casted_column.matches("%#{value}%") end def aggregate_query - conditions = searchable_columns.each_with_index.map do |column, index| - value = params[:columns]["#{index}"][:search][:value] if params[:columns] - search_condition(column, value) unless value.blank? + conditions = if params[:columns] + searchable_columns.each_with_index.map do |column, index| + value = params[:columns]["#{index}"][:search][:value] + Column.from_string(column, config.db_adapter).filter_condition(value) + end + else [] end - conditions.compact.reduce(:and) - end - def typecast - case config.db_adapter - when :oracle then 'VARCHAR2(4000)' - when :pg then 'VARCHAR' - when :mysql2 then 'CHAR' - when :sqlite3 then 'TEXT' - end + conditions.compact.reduce(:and) end def offset @@ -167,24 +138,13 @@ def per_page end def sort_column(item) - new_sort_column(item) - rescue - ::AjaxDatatablesRails::Base.deprecated '[DEPRECATED] Using table_name.column_name notation is deprecated. Please refer to: https://github.com/antillas21/ajax-datatables-rails#searchable-and-sortable-columns-syntax' - deprecated_sort_column(item) - end - - def deprecated_sort_column(item) - sortable_columns[sortable_displayed_columns.index(item[:column])] - end - - def new_sort_column(item) - model, column = sortable_columns[sortable_displayed_columns.index(item[:column])].split('.') - col = [model.constantize.table_name, column].join('.') + column = sortable_columns[sortable_displayed_columns.index(item[:column])] + Column.from_string(column, config.db_adapter) end def sort_direction(item) options = %w(desc asc) - options.include?(item[:dir]) ? item[:dir].upcase : 'ASC' + options.include?(item[:dir]) ? item[:dir].to_sym : :asc end def sortable_displayed_columns diff --git a/lib/ajax-datatables-rails/column.rb b/lib/ajax-datatables-rails/column.rb new file mode 100644 index 00000000..5e4fa65e --- /dev/null +++ b/lib/ajax-datatables-rails/column.rb @@ -0,0 +1,149 @@ +module AjaxDatatablesRails + class Column + + # Abstraction around a column or attribute field on an ActiveRecord object + # + # ==== Attributes + # + # * +model+ - The ActiveRecord model with the column to be filtered + # * +column+ - The column name to filter + # * +db_adapter+ - The database adapter symbol + # + def initialize(model, column, db_adapter) + @model = model + @column = column + @db_adapter = db_adapter + end + + def ==(other) + other.class == self.class && other.state == self.state + end + + # Returns an Arel object for generating a SQL query condition to filter the returned records according + # to the provided value. + # + # ==== Arguments + # + # * +value+ - The value for which to create the filter condition + # + def filter_condition(value) + fail MethodNotImplementedError, 'Must be implemented in subclass.' + end + + # Returns an Arel object for generating a SQL order statement in the specified direction. + # + # ==== Arguments + # + # * +direction+ - The direction the records should be ordered + # + def order_condition(direction) + fail MethodNotImplementedError, 'Must be implemented in subclass.' + end + + # Construct and returns a filter for reducing records via query. If the provided +value+ is blank, then nil is + # returned instead. + # + # ==== Arguments + # + # * +column+ - The String column definition in the nme format as provided to searchable_columns + # * +value+ - The value for which to create the filter condition + # * +db_adapter+ - The database adapter symbol + # + def self.from_string(column, db_adapter) + model, column_name = if column[0] == column.downcase[0] + message = '[DEPRECATED] Using table_name.column_name notation is deprecated. Please refer to: ' + + 'https://github.com/antillas21/ajax-datatables-rails#searchable-and-sortable-columns-syntax' + ::AjaxDatatablesRails::Base.deprecated(message) + + parsed_model, parsed_column = column.split('.') + model_name = parsed_model.singularize.titleize.gsub( / /, '' ) + [model_name, parsed_column] + else + column.split('.') + end + + model_class = model.constantize + if EnumColumn.column_is_enum?(model_class, column_name) + EnumColumn.new(model_class, column_name, db_adapter) + else + StandardColumn.new(model_class, column_name, db_adapter) + end + end + + protected + + def arel_attribute + @model.arel_table[@column.to_sym] + end + + def state + [@model, @column, @db_adapter] + end + end + + # Wrapper specific for ActiveRecord enum columns + class EnumColumn < Column + def filter_condition(value) + if value.blank? then nil + else + # Identify the numeric values to search + db_values = value_map.select { |label, db_value| label =~ /#{Regexp.escape(value)}/i }.values + arel_attribute.in(db_values) + end + end + + def order_condition(direction) + # Determine the relative ordering of the enum fields + ascending_values = value_map.sort_by { |label, db_value| label }.map { |label, db_value| db_value } + raise "unknown sort direction #{direction} provided" unless [:asc, :desc].include?(direction) + + # Construct the custom SQL for the sort + escaped_column_name = "\"#{arel_attribute.relation.name}\".\"#{arel_attribute.name}\"" + order_sql = ascending_values.each_with_index.reduce('CASE ') do |sql, (value, order)| + sql += "WHEN #{escaped_column_name} = #{value} THEN #{order} " + end + order_sql += "ELSE #{escaped_column_name} END #{direction.upcase}" + + Arel::Nodes::SqlLiteral.new(order_sql) + end + + def self.column_is_enum?(model, column) + model.respond_to?(:defined_enums) && model.defined_enums.include?(column.to_s) + end + + private + + def value_map + @model.send(@column.to_s.pluralize) + end + end + + class StandardColumn < Column + def filter_condition(value) + if value.blank? then nil + else + casted_column = ::Arel::Nodes::NamedFunction.new('CAST', [arel_attribute.as(text_typecast)]) + casted_column.matches("%#{value}%") + end + end + + def order_condition(direction) + case direction + when :asc then arel_attribute.asc + when :desc then arel_attribute.desc + else raise "unknown sort direction #{direction} provided" + end + end + + private + + def text_typecast + case @db_adapter + when :oracle then 'VARCHAR2(4000)' + when :pg then 'VARCHAR' + when :mysql2 then 'CHAR' + when :sqlite3 then 'TEXT' + end + end + end +end \ No newline at end of file diff --git a/lib/ajax-datatables-rails/extensions/simple_paginator.rb b/lib/ajax-datatables-rails/extensions/simple_paginator.rb index 399d3452..3bb9f12a 100644 --- a/lib/ajax-datatables-rails/extensions/simple_paginator.rb +++ b/lib/ajax-datatables-rails/extensions/simple_paginator.rb @@ -5,8 +5,8 @@ module SimplePaginator private def paginate_records(records) - records.offset(offset).limit(per_page) + records.offset(offset).limit(per_page).order(:id) end end end -end \ No newline at end of file +end diff --git a/spec/ajax-datatables-rails/ajax_datatables_rails_spec.rb b/spec/ajax-datatables-rails/ajax_datatables_rails_spec.rb index 1a42f6a0..c1f2e8ca 100644 --- a/spec/ajax-datatables-rails/ajax_datatables_rails_spec.rb +++ b/spec/ajax-datatables-rails/ajax_datatables_rails_spec.rb @@ -2,30 +2,32 @@ describe AjaxDatatablesRails::Base do - params = { - :draw => '5', - :columns => { - "0" => { - :data => '0', - :name => '', - :searchable => true, - :orderable => true, - :search => { :value => '', :regex => false } + let(:params) do + { + :draw => '5', + :columns => { + "0" => { + :data => '0', + :name => '', + :searchable => true, + :orderable => true, + :search => { :value => '', :regex => false } + }, + "1" => { + :data => '1', + :name => '', + :searchable => true, + :orderable => true, + :search => { :value => '', :regex => false } + } }, - "1" => { - :data => '1', - :name => '', - :searchable => true, - :orderable => true, - :search => { :value => '', :regex => false } - } - }, - :order => { "0" => { :column => '1', :dir => 'desc' } }, - :start => '0', - :length => '10', - :search => { :value => '', :regex => false }, - '_' => '1403141483098' - } + :order => { "0" => { :column => '1', :dir => 'desc' } }, + :start => '0', + :length => '10', + :search => { :value => '', :regex => false }, + '_' => '1403141483098' + } + end let(:view) { double('view', :params => params) } describe 'an instance' do @@ -84,7 +86,9 @@ allow(datatable).to receive(:sortable_displayed_columns) { ["0", "1"] } allow(datatable).to receive(:sortable_columns) { ['User.foo', 'User.bar', 'User.baz'] } - expect(datatable.send(:sort_column, sort_view.params[:order]["0"])).to eq('users.bar') + column = datatable.send(:sort_column, sort_view.params[:order]["0"]) + expectedColunm = AjaxDatatablesRails::StandardColumn.new(User, 'bar', :pg) + expect(column).to eq(expectedColunm) end end @@ -99,7 +103,7 @@ } ) datatable = AjaxDatatablesRails::Base.new(sorting_view) - expect(datatable.send(:sort_direction, sorting_view.params[:order]["0"])).to eq('DESC') + expect(datatable.send(:sort_direction, sorting_view.params[:order]["0"])).to eq(:desc) end it 'can only be one option from ASC or DESC' do @@ -112,70 +116,7 @@ } ) datatable = AjaxDatatablesRails::Base.new(sorting_view) - expect(datatable.send(:sort_direction, sorting_view.params[:order]["0"])).to eq('ASC') - end - end - - describe "#configure" do - let(:datatable) do - class FooDatatable < AjaxDatatablesRails::Base - end - - FooDatatable.new view - end - - context "when model class name is regular" do - it "should successfully get right model class" do - expect( - datatable.send(:search_condition, 'User.bar', 'bar') - ).to be_a(Arel::Nodes::Matches) - end - end - - context "when custom named model class" do - it "should successfully get right model class" do - expect( - datatable.send(:search_condition, 'Statistics::Request.bar', 'bar') - ).to be_a(Arel::Nodes::Matches) - end - end - - - context "when model class name camelcased" do - it "should successfully get right model class" do - expect( - datatable.send(:search_condition, 'PurchasedOrder.bar', 'bar') - ).to be_a(Arel::Nodes::Matches) - end - end - - context "when model class name is namespaced" do - it "should successfully get right model class" do - expect( - datatable.send(:search_condition, 'Statistics::Session.bar', 'bar') - ).to be_a(Arel::Nodes::Matches) - end - end - - context "when model class defined but not found" do - it "raise 'uninitialized constant'" do - expect { - datatable.send(:search_condition, 'UnexistentModel.bar', 'bar') - }.to raise_error(NameError, /uninitialized constant/) - end - end - - context 'when using deprecated notation' do - it 'should successfully get right model class if exists' do - expect( - datatable.send(:search_condition, 'users.bar', 'bar') - ).to be_a(Arel::Nodes::Matches) - end - - it 'should display a deprecated message' do - expect(AjaxDatatablesRails::Base).to receive(:deprecated) - datatable.send(:search_condition, 'users.bar', 'bar') - end + expect(datatable.send(:sort_direction, sorting_view.params[:order]["0"])).to eq(:asc) end end @@ -222,28 +163,43 @@ class FooDatatable < AjaxDatatablesRails::Base describe '#filter_records' do let(:records) { double('User', :where => []) } - let(:search_view) { double('view', :params => params) } it 'applies search like functionality on a collection' do + params[:search][:value] = 'term' + search_view = double('view', :params => params) + datatable = AjaxDatatablesRails::Base.new(search_view) - allow(datatable).to receive(:searchable_columns) { ['users.foo'] } + allow(datatable).to receive(:searchable_columns) { %w{User.foo User.bar} } expect(records).to receive(:where) - records.where + datatable.send(:filter_records, records) + end + + it 'applies search like functionality to an enum field' do + params[:search][:value] = 'active' + search_view = double('view', :params => params) + + datatable = AjaxDatatablesRails::Base.new(search_view) + allow(datatable).to receive(:searchable_columns) { %w{User.status} } + + expect(records).to receive(:where).with(User.arel_table[:status].in([1])) datatable.send(:filter_records, records) end end describe '#filter_records with multi word model' do - let(:records) { double('UserData', :where => []) } - let(:search_view) { double('view', :params => params) } + let(:records) { double('User', :where => []) } + let(:search_view) do + params[:columns]['0'][:search][:value] = 'term1' + params[:columns]['1'][:search][:value] = 'term2' + double('view', :params => params) + end it 'applies search like functionality on a collection' do datatable = AjaxDatatablesRails::Base.new(search_view) - allow(datatable).to receive(:searchable_columns) { ['user_datas.bar'] } + allow(datatable).to receive(:searchable_columns) { %w{User.foo User.bar} } expect(records).to receive(:where) - records.where datatable.send(:filter_records, records) end end @@ -272,6 +228,97 @@ class FooDatatable < AjaxDatatablesRails::Base end end +describe AjaxDatatablesRails::Column do + describe '#from_string' do + context "when model class name is regular" do + it "should successfully get right model class" do + expect(AjaxDatatablesRails::StandardColumn).to receive(:new).with(User, 'bar', :pg) + AjaxDatatablesRails::Column.from_string('User.bar', :pg) + end + end + + context "when custom named model class" do + it "should successfully get right model class" do + expect(AjaxDatatablesRails::StandardColumn).to receive(:new).with(Statistics::Request, 'bar', :pg) + AjaxDatatablesRails::Column.from_string('Statistics::Request.bar', :pg) + end + end + + + context "when model class name camelcased" do + it "should successfully get right model class" do + expect(AjaxDatatablesRails::StandardColumn).to receive(:new).with(PurchasedOrder, 'bar', :pg) + AjaxDatatablesRails::Column.from_string('PurchasedOrder.bar', :pg) + end + end + + context "when model class name is namespaced" do + it "should successfully get right model class" do + expect(AjaxDatatablesRails::StandardColumn).to receive(:new).with(Statistics::Session, 'bar', :pg) + AjaxDatatablesRails::Column.from_string('Statistics::Session.bar', :pg) + end + end + + context "when model class defined but not found" do + it "raise 'uninitialized constant'" do + expect { + AjaxDatatablesRails::Column.from_string('UnexistentModel.bar', :pg) + }.to raise_error(NameError, /uninitialized constant/) + end + end + + context "when the column is an enum" do + it "should successfully create an enum column" do + expect(AjaxDatatablesRails::EnumColumn).to receive(:new).with(User, 'status', :pg) + AjaxDatatablesRails::Column.from_string('User.status', :pg) + end + end + + context 'when using deprecated notation' do + it "should successfully get right model class if exists" do + expect(AjaxDatatablesRails::StandardColumn).to receive(:new).with(User, 'bar', :pg) + AjaxDatatablesRails::Column.from_string('users.bar', :pg) + end + + it "should display a deprecated message" do + expect(AjaxDatatablesRails::Base).to receive(:deprecated) + AjaxDatatablesRails::Column.from_string('users.bar', :pg) + end + end + end + + describe "#filter_condition" do + def filter_typecast(db_type) + column = AjaxDatatablesRails::StandardColumn.new(User, 'bar', db_type) + column.filter_condition('value').left.expressions.first.right.to_s + end + + it "sets VARCHAR if :db_adapter is :pg" do + expect(filter_typecast(:pg)).to eq('VARCHAR') + end + + it "sets CHAR if :db_adapter is :mysql2" do + expect(filter_typecast(:mysql2)).to eq('CHAR') + end + + it "sets TEXT if :db_adapter is :sqlite3" do + expect(filter_typecast(:sqlite3)).to eq('TEXT') + end + end + + describe "#order_condition" do + context "for a EnumColumn" do + it "should return a SQL sort statement" do + column = AjaxDatatablesRails::EnumColumn.new(User, 'status', :pg) + expected_asc_sql = 'CASE WHEN "users"."status" = 1 THEN 0 WHEN "users"."status" = 0 THEN 1 ELSE "users"."status" END ASC' + expect(column.order_condition(:asc)).to eq(Arel::Nodes::SqlLiteral.new(expected_asc_sql)) + + expected_desc_sql = 'CASE WHEN "users"."status" = 1 THEN 0 WHEN "users"."status" = 0 THEN 1 ELSE "users"."status" END DESC' + expect(column.order_condition(:desc)).to eq(Arel::Nodes::SqlLiteral.new(expected_desc_sql)) + end + end + end +end describe AjaxDatatablesRails::Configuration do let(:config) { AjaxDatatablesRails::Configuration.new } @@ -288,49 +335,6 @@ class FooDatatable < AjaxDatatablesRails::Base expect(config.db_adapter).to eq(:mysql2) end end - - describe '#typecast' do - params = { - :draw => '5', - :columns => { - "0" => { - :data => '0', - :name => '', - :searchable => true, - :orderable => true, - :search => { :value => '', :regex => false } - }, - "1" => { - :data => '1', - :name => '', - :searchable => true, - :orderable => true, - :search => { :value => '', :regex => false } - } - }, - :order => { "0" => { :column => '1', :dir => 'desc' } }, - :start => '0', - :length => '10', - :search => { :value => '', :regex => false }, - '_' => '1403141483098' - } - let(:view) { double('view', :params => params) } - let(:datatable) { AjaxDatatablesRails::Base.new(view) } - - it 'returns VARCHAR if :db_adapter is :pg' do - expect(datatable.send(:typecast)).to eq('VARCHAR') - end - - it 'returns CHAR if :db_adapter is :mysql2' do - allow_any_instance_of(AjaxDatatablesRails::Configuration).to receive(:db_adapter) { :mysql2 } - expect(datatable.send(:typecast)).to eq('CHAR') - end - - it 'returns TEXT if :db_adapter is :sqlite3' do - allow_any_instance_of(AjaxDatatablesRails::Configuration).to receive(:db_adapter) { :sqlite3 } - expect(datatable.send(:typecast)).to eq('TEXT') - end - end end describe AjaxDatatablesRails do @@ -346,6 +350,5 @@ class FooDatatable < AjaxDatatablesRails::Base expect(AjaxDatatablesRails.config.db_adapter).to eq(:mysql2) end end - end end diff --git a/spec/schema.rb b/spec/schema.rb index a5442bc1..93fd9893 100644 --- a/spec/schema.rb +++ b/spec/schema.rb @@ -2,7 +2,8 @@ self.verbose = false create_table :users, :force => true do |t| - t.string :username + t.string :username + t.integer :status t.timestamps end diff --git a/spec/test_models.rb b/spec/test_models.rb index 6a2267a6..1ca720d5 100644 --- a/spec/test_models.rb +++ b/spec/test_models.rb @@ -1,4 +1,5 @@ class User < ActiveRecord::Base + enum status: [:disabled, :active] end class UserData < ActiveRecord::Base