diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8eb594659d..c8ccea764e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,6 +39,11 @@ jobs: orm: active_record adapter: postgresql asset: sprockets + - ruby: "3.0" + gemfile: gemfiles/composite_primary_keys.gemfile + orm: active_record + adapter: sqlite3 + asset: sprockets - ruby: 2.7 gemfile: gemfiles/rails_6.0.gemfile orm: mongoid diff --git a/Appraisals b/Appraisals index 301ef22aa8..46355b0ba9 100644 --- a/Appraisals +++ b/Appraisals @@ -85,3 +85,21 @@ appraise 'rails-7.0' do gem 'paper_trail', '>= 12.0' end end + +appraise 'composite_primary_keys' do + gem 'rails', '~> 7.0.0' + gem 'sassc-rails', '~> 2.1' + gem 'devise', '~> 4.8' + + group :test do + gem 'cancancan', '~> 3.2' + gem 'kt-paperclip' + gem 'rspec-rails', '>= 4.0.0.beta2' + gem 'shrine', '~> 3.0' + end + + group :active_record do + gem 'composite_primary_keys' + gem 'paper_trail', '>= 12.0' + end +end diff --git a/app/controllers/rails_admin/main_controller.rb b/app/controllers/rails_admin/main_controller.rb index c812ee8c8a..460c099ece 100644 --- a/app/controllers/rails_admin/main_controller.rb +++ b/app/controllers/rails_admin/main_controller.rb @@ -60,14 +60,13 @@ def back_or_index end def get_sort_hash(model_config) - abstract_model = model_config.abstract_model field = model_config.list.fields.detect { |f| f.name.to_s == params[:sort] } # If no sort param, default to the `sort_by` specified in the list config - field ||= model_config.list.possible_fields.detect { |f| f.name == model_config.list.sort_by.to_sym } + field ||= model_config.list.possible_fields.detect { |f| f.name == model_config.list.sort_by.try(:to_sym) } column = if field.nil? || field.sortable == false # use default sort, asked field does not exist or is not sortable - "#{abstract_model.table_name}.#{model_config.list.sort_by}" + model_config.list.sort_by else field.sort_column end diff --git a/app/helpers/rails_admin/form_builder.rb b/app/helpers/rails_admin/form_builder.rb index 27aaaf53c2..37cbb03e28 100644 --- a/app/helpers/rails_admin/form_builder.rb +++ b/app/helpers/rails_admin/form_builder.rb @@ -107,6 +107,14 @@ def dom_name(field) (@dom_name ||= {})[field.name] ||= %(#{@object_name}#{options[:index] && "[#{options[:index]}]"}[#{field.method_name}]#{field.is_a?(Config::Fields::Association) && field.multiple? ? '[]' : ''}) end + def hidden_field(method, options = {}) + if method == :id + super method, {value: object.id.to_s} + else + super + end + end + protected def generator_action(action, nested) diff --git a/app/views/rails_admin/main/_form_filtering_multiselect.html.erb b/app/views/rails_admin/main/_form_filtering_multiselect.html.erb index e94f588f7f..f279c940dd 100644 --- a/app/views/rails_admin/main/_form_filtering_multiselect.html.erb +++ b/app/views/rails_admin/main/_form_filtering_multiselect.html.erb @@ -3,7 +3,7 @@ source_abstract_model = RailsAdmin.config(form.object.class).abstract_model selected = form.object.send(field.name) - selected_ids = selected.map{|s| s.send(field.associated_primary_key)} + selected_ids = selected.map{|s| s.send(field.associated_primary_key).to_s} current_action = params[:action].in?(['create', 'new']) ? 'create' : 'update' @@ -13,7 +13,7 @@ selected.map { |o| [o.send(field.associated_object_label_method), o.send(field.associated_primary_key)] } else i = 0 - controller.list_entries(config, :index, field.associated_collection_scope, false).map { |o| [o.send(field.associated_object_label_method), o.send(field.associated_primary_key)] }.sort_by {|a| [selected_ids.index(a[1]) || selected_ids.size, i+=1] } + controller.list_entries(config, :index, field.associated_collection_scope, false).map { |o| [o.send(field.associated_object_label_method), o.send(field.associated_primary_key).to_s] }.sort_by {|a| [selected_ids.index(a[1]) || selected_ids.size, i+=1] } end js_data = { diff --git a/app/views/rails_admin/main/_form_filtering_select.html.erb b/app/views/rails_admin/main/_form_filtering_select.html.erb index 27ed20e9e4..a18e293c48 100644 --- a/app/views/rails_admin/main/_form_filtering_select.html.erb +++ b/app/views/rails_admin/main/_form_filtering_select.html.erb @@ -8,7 +8,7 @@ xhr = !field.associated_collection_cache_all - collection = xhr ? [[field.formatted_value, field.selected_id]] : controller.list_entries(config, :index, field.associated_collection_scope, false).map { |o| [o.send(field.associated_object_label_method), o.send(field.associated_primary_key)] } + collection = xhr ? [[field.formatted_value, field.selected_id]] : controller.list_entries(config, :index, field.associated_collection_scope, false).map { |o| [o.send(field.associated_object_label_method), o.send(field.associated_primary_key).to_s] } js_data = { xhr: xhr, diff --git a/app/views/rails_admin/main/index.html.erb b/app/views/rails_admin/main/index.html.erb index 0436e9b6d0..dad7c9ab5c 100644 --- a/app/views/rails_admin/main/index.html.erb +++ b/app/views/rails_admin/main/index.html.erb @@ -140,7 +140,7 @@ <% if checkboxes %> - <%= check_box_tag "bulk_ids[]", object.id, false %> + <%= check_box_tag "bulk_ids[]", object.id.to_s, false %> <% end %> <% properties.map{ |property| property.bind(:object, object) }.each do |property| %> diff --git a/config/initializers/active_record_extensions.rb b/config/initializers/active_record_extensions.rb index 11c2d1d04d..0e8a6550b2 100644 --- a/config/initializers/active_record_extensions.rb +++ b/config/initializers/active_record_extensions.rb @@ -22,4 +22,27 @@ def safe_send(value) end end end + + if defined?(CompositePrimaryKeys) + # Apply patch until the fix is released: + # https://github.com/composite-primary-keys/composite_primary_keys/pull/572 + CompositePrimaryKeys::CompositeKeys.class_eval do + alias_method :to_param, :to_s + end + + CompositePrimaryKeys::CollectionAssociation.prepend(Module.new do + def ids_writer(ids) + if reflection.association_primary_key.is_a? Array + ids = CompositePrimaryKeys.normalize(Array(ids).reject(&:blank?), reflection.association_primary_key.size) + reflection.association_primary_key.each_with_index do |primary_key, i| + pk_type = klass.type_for_attribute(primary_key) + ids.each do |id| + id[i] = pk_type.cast(id[i]) if id.is_a? Array + end + end + end + super ids + end + end) + end end diff --git a/gemfiles/composite_primary_keys.gemfile b/gemfiles/composite_primary_keys.gemfile new file mode 100644 index 0000000000..091146178c --- /dev/null +++ b/gemfiles/composite_primary_keys.gemfile @@ -0,0 +1,52 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", ">= 2.0" +gem "devise", "~> 4.8" +gem "rails", "~> 7.0.0" +gem "webpacker", require: false +gem "webrick", "~> 1.7" +gem "sassc-rails", "~> 2.1" + +group :active_record do + gem "paper_trail", ">= 12.0" + gem "composite_primary_keys" + + platforms :ruby, :mswin, :mingw, :x64_mingw do + gem "mysql2", ">= 0.3.14" + gem "sqlite3", ">= 1.3" + end +end + +group :development, :test do + gem "pry", ">= 0.9" +end + +group :test do + gem "cancancan", "~> 3.2" + gem "carrierwave", [">= 2.0.0.rc", "< 3"] + gem "cuprite" + gem "database_cleaner-active_record", ">= 2.0", require: false + gem "database_cleaner-mongoid", ">= 2.0", require: false + gem "dragonfly", "~> 1.0" + gem "factory_bot", ">= 4.2" + gem "generator_spec", ">= 0.8" + gem "launchy", ">= 2.2" + gem "mini_magick", ">= 3.4" + gem "pundit" + gem "rack-cache", require: "rack/cache" + gem "rspec-expectations", "!= 3.8.3" + gem "rspec-rails", ">= 4.0.0.beta2" + gem "rspec-retry" + gem "rubocop", ["~> 1.20", "!= 1.22.2"], require: false + gem "rubocop-performance", require: false + gem "simplecov", ">= 0.9", require: false + gem "simplecov-lcov", require: false + gem "timecop", ">= 0.5" + gem "tzinfo-data", platforms: [:mingw, :mswin, :x64_mingw, :jruby] + gem "kt-paperclip" + gem "shrine", "~> 3.0" +end + +gemspec path: "../" diff --git a/lib/rails_admin/abstract_model.rb b/lib/rails_admin/abstract_model.rb index d0a127d8c4..88f2e5dcef 100644 --- a/lib/rails_admin/abstract_model.rb +++ b/lib/rails_admin/abstract_model.rb @@ -101,8 +101,13 @@ def each_associated_children(object) def initialize_active_record @adapter = :active_record - require 'rails_admin/adapters/active_record' - extend Adapters::ActiveRecord + if defined?(::CompositePrimaryKeys) + require 'rails_admin/adapters/composite_primary_keys' + extend Adapters::CompositePrimaryKeys + else + require 'rails_admin/adapters/active_record' + extend Adapters::ActiveRecord + end end def initialize_mongoid diff --git a/lib/rails_admin/adapters/active_record.rb b/lib/rails_admin/adapters/active_record.rb index 51dd136180..1c165d265a 100644 --- a/lib/rails_admin/adapters/active_record.rb +++ b/lib/rails_admin/adapters/active_record.rb @@ -33,11 +33,11 @@ def all(options = {}, scope = nil) scope ||= scoped scope = scope.includes(options[:include]) if options[:include] scope = scope.limit(options[:limit]) if options[:limit] - scope = scope.where(primary_key => options[:bulk_ids]) if options[:bulk_ids] + scope = bulk_scope(scope, options) if options[:bulk_ids] scope = query_scope(scope, options[:query]) if options[:query] scope = filter_scope(scope, options[:filters]) if options[:filters] scope = scope.send(Kaminari.config.page_method_name, options[:page]).per(options[:per]) if options[:page] && options[:per] - scope = scope.reorder("#{options[:sort]} #{options[:sort_reverse] ? 'asc' : 'desc'}") if options[:sort] + scope = sort_scope(scope, options) if options[:sort] scope end @@ -107,6 +107,27 @@ def adapter_supports_joins? true end + private + + def bulk_scope(scope, options) + scope.where(primary_key => options[:bulk_ids]) + end + + def sort_scope(scope, options) + direction = options[:sort_reverse] ? :asc : :desc + case options[:sort] + when String, Symbol + scope.reorder("#{options[:sort]} #{direction}") + when Array + scope.reorder(options[:sort].zip(Array.new(options[:sort].size) { direction }).to_h) + when Hash + scope.reorder(options[:sort].map { |table_name, column| "#{table_name}.#{column}" }. + zip(Array.new(options[:sort].size) { direction }).to_h) + else + raise ArgumentError.new("Unsupported sort value: #{options[:sort]}") + end + end + class WhereBuilder def initialize(scope) @statements = [] diff --git a/lib/rails_admin/adapters/active_record/association.rb b/lib/rails_admin/adapters/active_record/association.rb index 77402a9a3c..c59d973d6c 100644 --- a/lib/rails_admin/adapters/active_record/association.rb +++ b/lib/rails_admin/adapters/active_record/association.rb @@ -23,6 +23,14 @@ def type association.macro end + def field_type + if polymorphic? + :polymorphic_association + else + :"#{association.macro}_association" + end + end + def klass if options[:polymorphic] polymorphic_parents(:active_record, model.name.to_s, name) || [] diff --git a/lib/rails_admin/adapters/composite_primary_keys.rb b/lib/rails_admin/adapters/composite_primary_keys.rb new file mode 100644 index 0000000000..851ea25725 --- /dev/null +++ b/lib/rails_admin/adapters/composite_primary_keys.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'rails_admin/adapters/active_record' +require 'rails_admin/adapters/composite_primary_keys/association' + +module RailsAdmin + module Adapters + module CompositePrimaryKeys + include RailsAdmin::Adapters::ActiveRecord + + def get(id, scope = scoped) + begin + object = scope.find(id) + rescue ::ActiveRecord::RecordNotFound + return nil + end + + object.extend(RailsAdmin::Adapters::ActiveRecord::ObjectExtension) + end + + def associations + model.reflect_on_all_associations.collect do |association| + RailsAdmin::Adapters::CompositePrimaryKeys::Association.new(association, model) + end + end + + private + + def bulk_scope(scope, options) + if primary_key.is_a? Array + options[:bulk_ids].map do |id| + scope.where(primary_key.zip(::CompositePrimaryKeys::CompositeKeys.parse(id)).to_h) + end.reduce(&:or) + else + super + end + end + end + end +end diff --git a/lib/rails_admin/adapters/composite_primary_keys/association.rb b/lib/rails_admin/adapters/composite_primary_keys/association.rb new file mode 100644 index 0000000000..73a7edfdfd --- /dev/null +++ b/lib/rails_admin/adapters/composite_primary_keys/association.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module RailsAdmin + module Adapters + module CompositePrimaryKeys + class Association < RailsAdmin::Adapters::ActiveRecord::Association + def field_type + if type == :belongs_to && association.foreign_key.is_a?(Array) + :composite_keys_belongs_to_association + else + super + end + end + + def primary_key + return nil if polymorphic? + + value = association.association_primary_key + + if value.is_a? Array + :id + else + value.to_sym + end + end + + def foreign_key + if association.foreign_key.is_a? Array + association.foreign_key.map(&:to_sym) + else + super + end + end + + def key_accessor + if type == :belongs_to && foreign_key.is_a?(Array) + :"#{name}_id" + else + super + end + end + end + end + end +end diff --git a/lib/rails_admin/adapters/mongoid/association.rb b/lib/rails_admin/adapters/mongoid/association.rb index 24233108cf..80bcc43203 100644 --- a/lib/rails_admin/adapters/mongoid/association.rb +++ b/lib/rails_admin/adapters/mongoid/association.rb @@ -36,6 +36,14 @@ def type end end + def field_type + if polymorphic? + :polymorphic_association + else + :"#{type}_association" + end + end + def klass if polymorphic? && %i[referenced_in belongs_to].include?(macro) polymorphic_parents(:mongoid, model.name, name) || [] diff --git a/lib/rails_admin/config/fields/factories/association.rb b/lib/rails_admin/config/fields/factories/association.rb index 402b1b1952..050124db63 100644 --- a/lib/rails_admin/config/fields/factories/association.rb +++ b/lib/rails_admin/config/fields/factories/association.rb @@ -5,9 +5,8 @@ require 'rails_admin/config/fields/types/belongs_to_association' RailsAdmin::Config::Fields.register_factory do |parent, properties, fields| - association = parent.abstract_model.associations.detect { |a| a.foreign_key == properties.name && %i[belongs_to has_and_belongs_to_many].include?(a.type) } - if association - field = RailsAdmin::Config::Fields::Types.load("#{association.polymorphic? ? :polymorphic : association.type}_association").new(parent, association.name, association) + parent.abstract_model.associations.filter { |a| Array(a.foreign_key).include?(properties.name) && %i[belongs_to has_and_belongs_to_many].include?(a.type) }.each do |association| + field = RailsAdmin::Config::Fields::Types.load(association.field_type).new(parent, association.name, association) fields << field child_columns = [] @@ -15,7 +14,7 @@ %i[foreign_key foreign_type foreign_inverse_of] else [:foreign_key] - end.collect { |k| association.send(k) }.compact + end.flat_map { |k| Array(association.send(k)) }.compact parent.abstract_model.properties.select { |p| possible_field_names.include? p.name }.each do |column| child_field = fields.detect { |f| f.name.to_s == column.name.to_s } @@ -29,5 +28,5 @@ end field.children_fields child_columns.collect(&:name) - end + end.any? end diff --git a/lib/rails_admin/config/fields/types/all.rb b/lib/rails_admin/config/fields/types/all.rb index 0d66d3829a..b9c1aebdad 100644 --- a/lib/rails_admin/config/fields/types/all.rb +++ b/lib/rails_admin/config/fields/types/all.rb @@ -6,6 +6,7 @@ require 'rails_admin/config/fields/types/belongs_to_association' require 'rails_admin/config/fields/types/boolean' require 'rails_admin/config/fields/types/bson_object_id' +require 'rails_admin/config/fields/types/composite_keys_belongs_to_association' require 'rails_admin/config/fields/types/date' require 'rails_admin/config/fields/types/datetime' require 'rails_admin/config/fields/types/decimal' diff --git a/lib/rails_admin/config/fields/types/belongs_to_association.rb b/lib/rails_admin/config/fields/types/belongs_to_association.rb index 55d3c0197b..76a8eb7159 100644 --- a/lib/rails_admin/config/fields/types/belongs_to_association.rb +++ b/lib/rails_admin/config/fields/types/belongs_to_association.rb @@ -29,12 +29,8 @@ class BelongsToAssociation < RailsAdmin::Config::Fields::Association true end - def associated_primary_key - association.primary_key - end - def selected_id - bindings[:object].send(association.key_accessor) + bindings[:object].safe_send(association.key_accessor) end def method_name diff --git a/lib/rails_admin/config/fields/types/composite_keys_belongs_to_association.rb b/lib/rails_admin/config/fields/types/composite_keys_belongs_to_association.rb new file mode 100644 index 0000000000..1c1f85c134 --- /dev/null +++ b/lib/rails_admin/config/fields/types/composite_keys_belongs_to_association.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'rails_admin/config/fields/types/belongs_to_association' + +module RailsAdmin + module Config + module Fields + module Types + class CompositeKeysBelongsToAssociation < RailsAdmin::Config::Fields::Types::BelongsToAssociation + RailsAdmin::Config::Fields::Types.register(self) + + register_instance_option :allowed_methods do + nested_form ? [method_name] : Array(association.foreign_key) + end + + def selected_id + association.foreign_key.map { |attribute| bindings[:object].safe_send(attribute) }.to_composite_keys.to_s + end + + def parse_input(params) + return unless params[method_name].present? && association.foreign_key.is_a?(Array) && !nested_form + + association.foreign_key.zip(CompositePrimaryKeys::CompositeKeys.parse(params.delete(method_name))).each do |key, value| + params[key] = value + end + end + end + end + end + end +end diff --git a/lib/rails_admin/config/fields/types/has_one_association.rb b/lib/rails_admin/config/fields/types/has_one_association.rb index ef2e2588b8..dc8551ac3d 100644 --- a/lib/rails_admin/config/fields/types/has_one_association.rb +++ b/lib/rails_admin/config/fields/types/has_one_association.rb @@ -20,7 +20,7 @@ class HasOneAssociation < RailsAdmin::Config::Fields::Association end def selected_id - value.try :id + value.try(:id).try(:to_s) end def method_name diff --git a/spec/dummy_app/Gemfile b/spec/dummy_app/Gemfile index ae5cad5c25..16dd596ce9 100644 --- a/spec/dummy_app/Gemfile +++ b/spec/dummy_app/Gemfile @@ -18,6 +18,7 @@ group :active_record do gem 'sqlite3', '>= 1.3.0' end + gem 'composite_primary_keys' gem 'paper_trail', '>= 12.0' end diff --git a/spec/dummy_app/app/active_record/fan.rb b/spec/dummy_app/app/active_record/fan.rb index 791baef70c..106d0ae341 100644 --- a/spec/dummy_app/app/active_record/fan.rb +++ b/spec/dummy_app/app/active_record/fan.rb @@ -3,5 +3,10 @@ class Fan < ActiveRecord::Base has_and_belongs_to_many :teams + if defined?(CompositePrimaryKeys) + has_many :fanships, inverse_of: :fan + has_one :fanship, inverse_of: :fan + end + validates_presence_of(:name) end diff --git a/spec/dummy_app/app/active_record/fanship.rb b/spec/dummy_app/app/active_record/fanship.rb new file mode 100644 index 0000000000..5b97bcba19 --- /dev/null +++ b/spec/dummy_app/app/active_record/fanship.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +if defined?(CompositePrimaryKeys) + class Fanship < ActiveRecord::Base + self.table_name = :fans_teams + self.primary_keys = :fan_id, :team_id + belongs_to :fan, inverse_of: :fanships, optional: true + belongs_to :team, optional: true + has_many :favorite_players, foreign_key: %i[fan_id team_id], inverse_of: :fanship + end +else + class Fanship; end +end diff --git a/spec/dummy_app/app/active_record/favorite_player.rb b/spec/dummy_app/app/active_record/favorite_player.rb new file mode 100644 index 0000000000..de222659d3 --- /dev/null +++ b/spec/dummy_app/app/active_record/favorite_player.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +if defined?(CompositePrimaryKeys) + class FavoritePlayer < ActiveRecord::Base + self.primary_keys = :fan_id, :team_id, :player_id + belongs_to :fanship, foreign_key: %i[fan_id team_id], inverse_of: :favorite_players + belongs_to :player + end +end diff --git a/spec/dummy_app/app/active_record/nested_fan.rb b/spec/dummy_app/app/active_record/nested_fan.rb new file mode 100644 index 0000000000..85dd66a63b --- /dev/null +++ b/spec/dummy_app/app/active_record/nested_fan.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +if defined?(CompositePrimaryKeys) + class NestedFan < Fan + accepts_nested_attributes_for :fanships + accepts_nested_attributes_for :fanship + end +end diff --git a/spec/dummy_app/app/active_record/nested_favorite_player.rb b/spec/dummy_app/app/active_record/nested_favorite_player.rb new file mode 100644 index 0000000000..a188ef63cb --- /dev/null +++ b/spec/dummy_app/app/active_record/nested_favorite_player.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +if defined?(CompositePrimaryKeys) + class NestedFavoritePlayer < FavoritePlayer + accepts_nested_attributes_for :fanship + end +end diff --git a/spec/dummy_app/db/migrate/20220416102741_create_composite_key_tables.rb b/spec/dummy_app/db/migrate/20220416102741_create_composite_key_tables.rb new file mode 100644 index 0000000000..3586cf5d34 --- /dev/null +++ b/spec/dummy_app/db/migrate/20220416102741_create_composite_key_tables.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateCompositeKeyTables < ActiveRecord::Migration[6.0] + def change + add_column :fans_teams, :since, :date + + create_table :favorite_players, primary_key: %i[fan_id team_id player_id] do |t| + t.integer :fan_id, null: false + t.integer :team_id, null: false + t.integer :player_id, null: false + t.string :reason + + t.timestamps + end + end +end diff --git a/spec/factories.rb b/spec/factories.rb index 15a1a838ff..c28bf291a4 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -44,6 +44,16 @@ sequence(:name) { |n| "Fan #{n}" } end + factory :fanship do + association :fan + association :team + end + + factory :favorite_player do + association :fanship + association :player + end + factory :user do sequence(:email) { |n| "username_#{n}@example.com" } sequence(:password) { |_n| 'password' } diff --git a/spec/integration/actions/bulk_delete_spec.rb b/spec/integration/actions/bulk_delete_spec.rb index d7551fb402..36a20df8b6 100644 --- a/spec/integration/actions/bulk_delete_spec.rb +++ b/spec/integration/actions/bulk_delete_spec.rb @@ -86,4 +86,19 @@ expect(RailsAdmin::AbstractModel.new('Player').count).to eq(3) end end + + context 'with composite_primary_keys', composite_primary_keys: true do + let!(:fanships) { FactoryBot.create_list(:fanship, 3) } + + it 'provides check boxes for bulk operation' do + visit index_path(model_name: 'fanship') + fanships.each { |fanship| is_expected.to have_css(%(input[name="bulk_ids[]"][value="#{fanship.id}"])) } + end + + it 'deletes selected records' do + delete(bulk_delete_path(bulk_action: 'bulk_delete', model_name: 'fanship', bulk_ids: fanships[0..1].map { |fanship| fanship.id.to_s })) + expect(flash[:success]).to match(/2 Fanships successfully deleted/) + expect(Fanship.all).to eq fanships[2..2] + end + end end diff --git a/spec/integration/actions/delete_spec.rb b/spec/integration/actions/delete_spec.rb index 258a1b9118..63faf63f7d 100644 --- a/spec/integration/actions/delete_spec.rb +++ b/spec/integration/actions/delete_spec.rb @@ -172,4 +172,15 @@ expect(URI.parse(page.current_url).path).to eq(delete_path(model_name: 'player', id: @player.id)) end end + + context 'with composite_primary_keys', composite_primary_keys: true do + let(:fanship) { FactoryBot.create(:fanship) } + + it 'deletes the object' do + visit delete_path(model_name: 'fanship', id: fanship.id) + click_button "Yes, I'm sure" + is_expected.to have_content('Fanship successfully deleted') + expect(Fanship.all).to be_empty + end + end end diff --git a/spec/integration/actions/edit_spec.rb b/spec/integration/actions/edit_spec.rb index 746759cc06..83e3de069c 100644 --- a/spec/integration/actions/edit_spec.rb +++ b/spec/integration/actions/edit_spec.rb @@ -861,4 +861,15 @@ class HelpTest < Tableless end.to change { record.reload.open }.from(nil).to(true) end end + + context 'with composite_primary_keys', composite_primary_keys: true do + let(:fanship) { FactoryBot.create(:fanship) } + + it 'edits the object' do + visit edit_path(model_name: 'fanship', id: fanship.id) + fill_in 'Since', with: '2000-01-23' + click_button 'Save' + expect { fanship.reload }.to change { fanship.since }.from(nil).to(Date.new(2000, 1, 23)) + end + end end diff --git a/spec/integration/actions/export_spec.rb b/spec/integration/actions/export_spec.rb index 6120364014..b91563d61b 100644 --- a/spec/integration/actions/export_spec.rb +++ b/spec/integration/actions/export_spec.rb @@ -10,7 +10,7 @@ it 'exports to CSV' do visit export_path(model_name: 'player') - click_button 'Export to json' + click_button 'Export to csv' is_expected.to have_content player.name end @@ -159,4 +159,15 @@ end end end + + context 'with composite_primary_keys', composite_primary_keys: true do + let!(:fanship) { FactoryBot.create(:fanship) } + + it 'exports to CSV' do + visit export_path(model_name: 'fanship') + click_button 'Export to csv' + is_expected.to have_content fanship.fan.name + is_expected.to have_content fanship.team.name + end + end end diff --git a/spec/integration/actions/index_spec.rb b/spec/integration/actions/index_spec.rb index 835243d51a..53c61ac3cd 100644 --- a/spec/integration/actions/index_spec.rb +++ b/spec/integration/actions/index_spec.rb @@ -324,6 +324,14 @@ end describe 'fields' do + before do + if defined?(CompositePrimaryKeys) + RailsAdmin.config Fan do + configure(:fanships) { hide } + configure(:fanship) { hide } + end + end + end it 'shows all by default' do visit index_path(model_name: 'fan') expect(all('th').collect(&:text).delete_if { |t| /^\n*$/ =~ t }). @@ -1186,4 +1194,18 @@ def visit_page(page) expect(cols).to contain_exactly(*all_team_columns[1..]) end end + + context 'with composite_primary_keys', composite_primary_keys: true do + let!(:fanships) { FactoryBot.create_list(:fanship, 3) } + + it 'shows the list' do + visit index_path(model_name: 'fanship') + expect(all('th').collect(&:text)[0..3]).to eq(['', 'Fan', 'Team', 'Since']) + fanships.each do |fanship| + is_expected.to have_content fanship.fan.name + is_expected.to have_content fanship.team.name + end + is_expected.to have_content '3 fanships' + end + end end diff --git a/spec/integration/actions/new_spec.rb b/spec/integration/actions/new_spec.rb index 2518612007..3a83184396 100644 --- a/spec/integration/actions/new_spec.rb +++ b/spec/integration/actions/new_spec.rb @@ -194,4 +194,17 @@ is_expected.to have_css('button[name="_save"]:disabled') end end + + context 'with composite_primary_keys', composite_primary_keys: true do + let!(:fan) { FactoryBot.create(:fan) } + let!(:team) { FactoryBot.create(:team) } + + it 'creates an object' do + visit new_path(model_name: 'fanship') + select(fan.name, from: 'Fan') + select(team.name, from: 'Team') + expect { click_button 'Save' }.to change { Fanship.count }.by(1) + expect(Fanship.first.attributes.fetch_values('fan_id', 'team_id')).to eq [fan.id, team.id] + end + end end diff --git a/spec/integration/actions/show_spec.rb b/spec/integration/actions/show_spec.rb index 5c1b52ee85..47b639d38f 100644 --- a/spec/integration/actions/show_spec.rb +++ b/spec/integration/actions/show_spec.rb @@ -457,4 +457,14 @@ is_expected.to have_selector('.card-body', text: 'anything') end end + + context 'with composite_primary_keys', composite_primary_keys: true do + let(:fanship) { FactoryBot.create(:fanship) } + + it 'shows the object' do + visit show_path(model_name: 'fanship', id: fanship.id) + is_expected.to have_link(fanship.fan.name) + is_expected.to have_link(fanship.team.name) + end + end end diff --git a/spec/integration/fields/belongs_to_association_spec.rb b/spec/integration/fields/belongs_to_association_spec.rb index 54e75efb41..9a03a93fa1 100644 --- a/spec/integration/fields/belongs_to_association_spec.rb +++ b/spec/integration/fields/belongs_to_association_spec.rb @@ -72,4 +72,51 @@ end end end + + context 'with composite foreign keys', composite_primary_keys: true do + let!(:fanship) { FactoryBot.create(:fanship) } + let(:favorite_player) { FactoryBot.create(:favorite_player) } + + describe 'via default field' do + it 'allows update' do + visit edit_path(model_name: 'favorite_player', id: favorite_player.id) + is_expected.to have_select('Fanship', selected: "Fanship ##{favorite_player.fanship.id}") + select("Fanship ##{fanship.id}", from: 'Fanship') + click_button 'Save' + is_expected.to have_content 'Favorite player successfully updated' + expect(FavoritePlayer.all.map(&:fanship)).to eq [fanship] + end + end + + describe 'via remote-sourced field' do + before do + RailsAdmin.config FavoritePlayer do + field :fanship do + associated_collection_cache_all false + end + end + end + + it 'allows update', js: true do + visit edit_path(model_name: 'favorite_player', id: favorite_player.id) + find('.fanship_field input.ra-filtering-select-input').set(fanship.fan_id) + page.execute_script("document.querySelector('.fanship_field input.ra-filtering-select-input').dispatchEvent(new KeyboardEvent('keydown'))") + expect(page).to have_selector('ul.ui-autocomplete li.ui-menu-item a') + page.execute_script %{[...document.querySelectorAll('ul.ui-autocomplete li.ui-menu-item')].find(e => e.innerText.includes("Fanship ##{fanship.id}")).click()} + click_button 'Save' + is_expected.to have_content 'Favorite player successfully updated' + expect(FavoritePlayer.all.map(&:fanship)).to eq [fanship] + end + end + + describe 'via nested field' do + it 'allows update' do + visit edit_path(model_name: 'nested_favorite_player', id: favorite_player.id) + fill_in 'Since', with: '2020-01-23' + click_button 'Save' + is_expected.to have_content 'Nested favorite player successfully updated' + expect(favorite_player.reload.fanship.since).to eq Date.new(2020, 1, 23) + end + end + end end diff --git a/spec/integration/fields/has_many_association_spec.rb b/spec/integration/fields/has_many_association_spec.rb index 965894d7dd..e6f815e9cf 100644 --- a/spec/integration/fields/has_many_association_spec.rb +++ b/spec/integration/fields/has_many_association_spec.rb @@ -184,4 +184,75 @@ end end end + + context 'with composite foreign keys', composite_primary_keys: true do + let(:fan) { FactoryBot.create(:fan) } + let!(:fanships) { FactoryBot.create_list(:fanship, 3) } + + describe 'via default field' do + before do + RailsAdmin.config Fan do + field :name + field :fanships + end + end + + it 'shows the current selection' do + visit edit_path(model_name: 'fan', id: fanships[0].fan.id) + is_expected.to have_select('Fanships', selected: "Fanship ##{fanships[0].id}") + end + + it 'allows update' do + visit edit_path(model_name: 'fan', id: fan.id) + select("Fanship ##{fanships[0].id}", from: 'Fanships') + select("Fanship ##{fanships[1].id}", from: 'Fanships') + click_button 'Save' + is_expected.to have_content 'Fan successfully updated' + expect(fan.reload.fanships.map(&:team_id)).to match_array fanships.map(&:team_id)[0..1] + end + end + + describe 'via remote-sourced field' do + before do + RailsAdmin.config Fan do + field :name + field :fanships do + associated_collection_cache_all false + end + end + end + + it 'allows update', js: true do + visit edit_path(model_name: 'fan', id: fan.id) + find('input.ra-multiselect-search').set('F') + find('.ra-multiselect-collection option', text: "Fanship ##{fanships[0].id}").select_option + find('.ra-multiselect-collection option', text: "Fanship ##{fanships[1].id}").select_option + find('.ra-multiselect-item-add').click + click_button 'Save' + is_expected.to have_content 'Fan successfully updated' + expect(fan.reload.fanships.map(&:team_id)).to match_array fanships.map(&:team_id)[0..1] + end + end + + describe 'via nested field' do + let!(:team) { FactoryBot.create :team } + let!(:fanships) { FactoryBot.create_list(:fanship, 2, fan: fan) } + before do + RailsAdmin.config NestedFan do + field :name + field :fanships + end + end + + it 'allows update' do + visit edit_path(model_name: 'nested_fan', id: fan.id) + select(team.name, from: 'nested_fan_fanships_attributes_0_team_id') + fill_in 'nested_fan_fanships_attributes_1_since', with: '2020-01-23' + click_button 'Save' + is_expected.to have_content 'Nested fan successfully updated' + expect(fan.fanships[0].team).to eq team + expect(fan.fanships[1].since).to eq Date.new(2020, 1, 23) + end + end + end end diff --git a/spec/integration/fields/has_one_association_spec.rb b/spec/integration/fields/has_one_association_spec.rb index 1de25a3b5d..b270d879c3 100644 --- a/spec/integration/fields/has_one_association_spec.rb +++ b/spec/integration/fields/has_one_association_spec.rb @@ -97,4 +97,75 @@ end end end + + context 'with composite foreign keys', composite_primary_keys: true do + let(:fan) { FactoryBot.create(:fan) } + let!(:fanship) { FactoryBot.create(:fanship, fan: fan) } + + describe 'via default field' do + before do + RailsAdmin.config Fan do + field :name + field :fanship + end + end + + it 'allows create' do + visit new_path(model_name: 'fan') + fill_in 'Name', with: 'someone' + select("Fanship ##{fanship.id}", from: 'Fanship') + click_button 'Save' + is_expected.to have_content 'Fan successfully created' + expect(Fan.where(name: 'someone').first.fanship.team_id).to eq fanship.team_id + end + + it 'shows the current selection' do + visit edit_path(model_name: 'fan', id: fanship.fan_id) + is_expected.to have_select('Fanship', selected: "Fanship ##{fanship.id}") + end + end + + describe 'via remote-sourced field' do + before do + RailsAdmin.config Fan do + field :name + field :fanship do + associated_collection_cache_all false + end + end + end + + it 'allows create', js: true do + visit new_path(model_name: 'fan') + fill_in 'Name', with: 'someone' + find('.fanship_field input.ra-filtering-select-input').set(fanship.fan_id) + page.execute_script("document.querySelector('.fanship_field input.ra-filtering-select-input').dispatchEvent(new KeyboardEvent('keydown'))") + expect(page).to have_selector('ul.ui-autocomplete li.ui-menu-item a') + page.execute_script %{[...document.querySelectorAll('ul.ui-autocomplete li.ui-menu-item')].find(e => e.innerText.includes("Fanship ##{fanship.id}")).click()} + click_button 'Save' + is_expected.to have_content 'Fan successfully created' + expect(Fan.where(name: 'someone').first.fanship.team_id).to eq fanship.team_id + end + end + + describe 'via nested field' do + let!(:team) { FactoryBot.create :team } + before do + RailsAdmin.config NestedFan do + field :name + field :fanship + end + end + + it 'allows update' do + visit edit_path(model_name: 'nested_fan', id: fanship.fan_id) + select(team.name, from: 'Team') + fill_in 'Since', with: '2020-01-23' + click_button 'Save' + is_expected.to have_content 'Nested fan successfully updated' + expect(fan.fanship.team).to eq team + expect(fan.fanship.since).to eq Date.new(2020, 1, 23) + end + end + end end diff --git a/spec/rails_admin/adapters/active_record_spec.rb b/spec/rails_admin/adapters/active_record_spec.rb index 278eba03b2..b2b5eb6cbd 100644 --- a/spec/rails_admin/adapters/active_record_spec.rb +++ b/spec/rails_admin/adapters/active_record_spec.rb @@ -127,6 +127,11 @@ class PlayerWithDefaultScope < Player expect(abstract_model.all(bulk_ids: @players[0..1].collect(&:id))).to match_array @players[0..1] end + it 'supports retrieval by bulk_ids with composite_primary_keys', composite_primary_keys: true do + expect(RailsAdmin::AbstractModel.new(Fanship).all(bulk_ids: ['1,2', '3,4']).to_sql). + to include 'WHERE ("fans_teams"."fan_id" = 1 AND "fans_teams"."team_id" = 2 OR "fans_teams"."fan_id" = 3 AND "fans_teams"."team_id" = 4)' + end + it 'supports pagination' do expect(abstract_model.all(sort: 'id', page: 2, per: 1)).to eq(@players[-2, 1]) expect(abstract_model.all(sort: 'id', page: 1, per: 2)).to eq(@players[-2, 2].reverse) @@ -134,6 +139,9 @@ class PlayerWithDefaultScope < Player it 'supports ordering' do expect(abstract_model.all(sort: 'id', sort_reverse: true)).to eq(@players.sort) + expect(abstract_model.all(sort: %w[id name], sort_reverse: true).to_sql.tr('`', '"')).to include('ORDER BY "players"."id" ASC, "players"."name" ASC') + expect(abstract_model.all(include: :team, sort: {players: :name, teams: :name}, sort_reverse: true).to_sql.tr('`', '"')).to include('ORDER BY "players"."name" ASC, "teams"."name" ASC') + expect { abstract_model.all(sort: 1, sort_reverse: true) }.to raise_error ArgumentError, /Unsupported/ end it 'supports querying' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e4d9fa8feb..581c834848 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -107,4 +107,6 @@ config.filter_run_excluding orm => true end end + + config.filter_run_excluding composite_primary_keys: true unless defined?(CompositePrimaryKeys) end