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