effective_mergery 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/MIT-LICENSE +1 -1
- data/README.md +4 -5
- data/app/assets/javascripts/effective_mergery/base.js +0 -0
- data/app/controllers/admin/merges_controller.rb +15 -0
- data/app/models/effective/merge.rb +174 -42
- data/app/views/admin/merges/_associations.html.haml +23 -0
- data/app/views/admin/merges/_form.html.haml +35 -0
- data/app/views/admin/merges/_resource.html.haml +6 -0
- data/app/views/admin/merges/_user.html.haml +3 -0
- data/app/views/admin/merges/index.html.haml +1 -0
- data/config/effective_mergery.rb +1 -22
- data/config/routes.rb +5 -7
- data/lib/effective_mergery/version.rb +1 -1
- data/lib/effective_mergery.rb +1 -7
- metadata +102 -17
- data/app/assets/javascripts/effective_mergery/merge.js.coffee +0 -20
- data/app/controllers/admin/merge_controller.rb +0 -75
- data/app/views/admin/merge/_attributes.html.haml +0 -3
- data/app/views/admin/merge/create.html.haml +0 -11
- data/app/views/admin/merge/index.html.haml +0 -10
- data/app/views/admin/merge/new.html.haml +0 -29
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d8a7243e93f05391396a3da6a84e7d1716df8cb7e8654ebb3256580305d8b58a
|
|
4
|
+
data.tar.gz: 2a18ad03f692f3a6d114a1ae098f2f09d981f03c4aecf338c7cc90504ecb454a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dfd33e2d4c7d72786842be57e0ff29dd516ac08f390f0f2b85f845adb81494edccf6a593d15af8459d167f74433776e7c3fe6d9cee6a75b88f04e1880d055791
|
|
7
|
+
data.tar.gz: d5f1a5868dc219bcc2980ecafa86416cf03949450a4614363fdb917b725a922452afdd17924f58f6ca7f1abdfc3f8acc8301fcf5aaba4554dada6d479066f3e1
|
data/MIT-LICENSE
CHANGED
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Effective Mergery
|
|
2
2
|
|
|
3
|
-
Merge any two Active Record
|
|
3
|
+
Merge any two Active Record Users, along with all associated objects, into one record.
|
|
4
4
|
|
|
5
5
|
## Getting Started
|
|
6
6
|
|
|
@@ -38,11 +38,10 @@ Require the stylesheet on the asset pipeline by adding the following to your app
|
|
|
38
38
|
|
|
39
39
|
## Usage
|
|
40
40
|
|
|
41
|
-
Visit `/admin/merge` and select an object type to merge.
|
|
41
|
+
Visit `/admin/merge/new` and select an object type to merge.
|
|
42
42
|
|
|
43
43
|
```ruby
|
|
44
|
-
link_to 'Merge', effective_mergery.
|
|
45
|
-
link_to 'Merge: User', effective_mergery.new_admin_merge_path(type: 'User')
|
|
44
|
+
link_to 'Merge', effective_mergery.new_admin_merge_path
|
|
46
45
|
```
|
|
47
46
|
|
|
48
47
|
## Permissions
|
|
@@ -51,6 +50,7 @@ Add the following permissions (using CanCan):
|
|
|
51
50
|
|
|
52
51
|
```ruby
|
|
53
52
|
can :admin, :effective_mergery
|
|
53
|
+
can :manage, Effective::Merge
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
## License
|
|
@@ -65,4 +65,3 @@ MIT License. Copyright [Code and Effect Inc.](http://www.codeandeffect.com/)
|
|
|
65
65
|
4. Push to the branch (`git push origin my-new-feature`)
|
|
66
66
|
5. Bonus points for test coverage
|
|
67
67
|
6. Create new Pull Request
|
|
68
|
-
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
class MergesController < ApplicationController
|
|
3
|
+
before_action(:authenticate_user!) if defined?(Devise)
|
|
4
|
+
before_action { EffectiveResources.authorize!(self, :admin, :effective_mergery) }
|
|
5
|
+
|
|
6
|
+
include Effective::CrudController
|
|
7
|
+
|
|
8
|
+
page_title "Merge Users"
|
|
9
|
+
|
|
10
|
+
def merge_params
|
|
11
|
+
params.require(:effective_merge).permit!
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -2,83 +2,215 @@ module Effective
|
|
|
2
2
|
class Merge
|
|
3
3
|
include ActiveModel::Model
|
|
4
4
|
|
|
5
|
-
attr_accessor :
|
|
5
|
+
attr_accessor :current_user
|
|
6
|
+
attr_accessor :source_type, :source_id
|
|
7
|
+
attr_accessor :target_type, :target_id
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
validates :target_id, presence: true
|
|
10
|
+
validates :target_type, presence: true
|
|
9
11
|
|
|
10
|
-
validates :
|
|
11
|
-
validates :
|
|
12
|
-
validates :target_id, presence: true, unless: -> { target.present? }
|
|
12
|
+
validates :source_id, presence: true
|
|
13
|
+
validates :source_type, presence: true
|
|
13
14
|
|
|
14
|
-
validate(if: -> {
|
|
15
|
-
|
|
15
|
+
validate(if: -> { source.present? && target.present? }) do
|
|
16
|
+
errors.add(:base, "must be the same type") unless target.class == source.class
|
|
17
|
+
errors.add(:target_id, "can't be the same record") if target.id == source.id
|
|
16
18
|
end
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
# Both records must be fully valid before we start moving anything
|
|
21
|
+
validate(if: -> { source.present? }) do
|
|
22
|
+
errors.add(:source_id, "is invalid: #{source.errors.full_messages.to_sentence}") unless source.valid?
|
|
23
|
+
end
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
type.downcase
|
|
25
|
+
validate(if: -> { target.present? }) do
|
|
26
|
+
errors.add(:target_id, "is invalid: #{target.errors.full_messages.to_sentence}") unless target.valid?
|
|
24
27
|
end
|
|
25
28
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
(merge!(validate: validate) rescue false)
|
|
29
|
+
def to_s
|
|
30
|
+
(source.present? && target.present?) ? "Merge of #{source} to #{target}" : "New Merge"
|
|
29
31
|
end
|
|
30
32
|
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
-
merge!(validate: validate)
|
|
33
|
+
def source
|
|
34
|
+
@source ||= source_type.try(:safe_constantize).try(:find_by_id, source_id)
|
|
34
35
|
end
|
|
35
36
|
|
|
36
|
-
def
|
|
37
|
-
|
|
37
|
+
def source=(resource)
|
|
38
|
+
raise('expected an ActiveRecord::Base resource') unless resource.is_a?(ActiveRecord::Base)
|
|
39
|
+
assign_attributes(source_type: resource.class.name, source_id: resource.id)
|
|
40
|
+
@source = resource
|
|
38
41
|
end
|
|
39
42
|
|
|
40
|
-
def
|
|
41
|
-
@
|
|
43
|
+
def target
|
|
44
|
+
@target ||= target_type.try(:safe_constantize).try(:find_by_id, target_id)
|
|
42
45
|
end
|
|
43
46
|
|
|
44
|
-
def
|
|
45
|
-
|
|
47
|
+
def target=(resource)
|
|
48
|
+
raise('expected an ActiveRecord::Base resource') unless resource.is_a?(ActiveRecord::Base)
|
|
49
|
+
assign_attributes(target_type: resource.class.name, target_id: resource.id)
|
|
50
|
+
@target = resource
|
|
46
51
|
end
|
|
47
52
|
|
|
48
|
-
|
|
49
|
-
def validate_klass!
|
|
50
|
-
raise "type can't be blank" unless type.present?
|
|
51
|
-
raise 'type must be a mergable type' unless EffectiveMergery.mergables.map(&:name).include?(type)
|
|
52
|
-
raise "invalid ActiveRecord klass" unless klass
|
|
53
|
-
raise "invalid ActiveRecord collection" unless collection.kind_of?(ActiveRecord::Relation)
|
|
53
|
+
def new_record?
|
|
54
54
|
true
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
def save!
|
|
58
|
+
merge!
|
|
59
|
+
end
|
|
58
60
|
|
|
59
61
|
def merge!(validate: true)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
raise ActiveRecord::RecordInvalid.new(self) unless valid?
|
|
63
|
+
|
|
64
|
+
Rails.application.eager_load! unless Rails.application.config.eager_load
|
|
62
65
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
(resource.has_ones + resource.has_manys + resource.nested_resources).compact.each do |association|
|
|
66
|
-
next if association.options[:through].present?
|
|
66
|
+
klasses = defined?(Tenant) ? Tenant.klasses : ActiveRecord::Base.descendants.reject(&:abstract_class?)
|
|
67
|
+
klasses = klasses.select { |klass| klass.table_exists? }
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
success = false
|
|
70
|
+
|
|
71
|
+
EffectiveResources.transaction do
|
|
72
|
+
# Re-point every record that belongs to the source onto the target, treating the target as the
|
|
73
|
+
# authoritative account. We walk from the belongs_to side so we catch foreign keys no has_many/has_one
|
|
74
|
+
# is declared for - polymorphic owners (Effective::Address, Effective::EventRegistration) and named
|
|
75
|
+
# self-refs alike (advisor_id, endorser_id, reviewer_id) - and move them with update_all. No per-record
|
|
76
|
+
# validations or callbacks run, so historical data and business rules can't block the merge. Any source
|
|
77
|
+
# record that would duplicate one the target already owns (per a uniqueness validator OR a unique index)
|
|
78
|
+
# is deleted instead of moved, so the merge never creates a duplicate or trips a unique constraint.
|
|
79
|
+
klasses.each do |klass|
|
|
80
|
+
source_foreign_keys(klass).each do |foreign_key, foreign_type|
|
|
81
|
+
source_records = records_for(klass, foreign_key, foreign_type, source)
|
|
82
|
+
next unless source_records.exists?
|
|
83
|
+
|
|
84
|
+
attributes = { foreign_key => target.id }
|
|
85
|
+
attributes[foreign_type] = target.class.name if foreign_type
|
|
86
|
+
|
|
87
|
+
# Addresses are kept as a whole set, not merged record by record: if the target already has any
|
|
88
|
+
# addresses, keep the target's and drop the source's; only copy the source's over when the target
|
|
89
|
+
# has none.
|
|
90
|
+
if klass.name == 'Effective::Address'
|
|
91
|
+
records_for(klass, foreign_key, foreign_type, target).exists? ? source_records.delete_all : source_records.update_all(attributes)
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
duplicate_ids = duplicate_record_ids(klass, foreign_key, foreign_type)
|
|
96
|
+
source_records.where(id: duplicate_ids).delete_all if duplicate_ids.present?
|
|
97
|
+
source_records.where.not(id: duplicate_ids).update_all(attributes)
|
|
71
98
|
end
|
|
72
99
|
end
|
|
73
100
|
|
|
74
|
-
source.
|
|
101
|
+
# Prove the merge is complete before we destroy the source: nothing may still reference it.
|
|
102
|
+
assert_no_references_to_source!(klasses)
|
|
75
103
|
|
|
104
|
+
# Everything the source owned now points at the target; whatever is left dies with the source.
|
|
105
|
+
# Reload first so dependent: callbacks only fire for what STILL points at the source (update_all
|
|
106
|
+
# bypasses the in-memory association cache).
|
|
107
|
+
source.reload.destroy!
|
|
76
108
|
target.save!(validate: validate)
|
|
109
|
+
|
|
110
|
+
log_merged!
|
|
111
|
+
|
|
77
112
|
success = true
|
|
78
113
|
end
|
|
79
114
|
|
|
80
115
|
success
|
|
81
116
|
end
|
|
82
117
|
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# [[foreign_key, foreign_type], ...] for every belongs_to on klass that could point at the source:
|
|
121
|
+
# polymorphic, or one whose target IS the source's class (belongs_to :user, self-refs like advisor_id).
|
|
122
|
+
# foreign_type is nil for non-polymorphic associations; associations missing their column are skipped.
|
|
123
|
+
def source_foreign_keys(klass)
|
|
124
|
+
klass.reflect_on_all_associations(:belongs_to).filter_map do |reflection|
|
|
125
|
+
next unless reflection.polymorphic? || (reflection.klass == source.class rescue false)
|
|
126
|
+
|
|
127
|
+
foreign_key = reflection.foreign_key.to_s
|
|
128
|
+
next unless klass.column_names.include?(foreign_key)
|
|
129
|
+
|
|
130
|
+
[foreign_key, (reflection.foreign_type.to_s if reflection.polymorphic?)]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The klass records owned by `owner` through foreign_key (scoped by *_type for polymorphics).
|
|
135
|
+
def records_for(klass, foreign_key, foreign_type, owner)
|
|
136
|
+
scope = klass.where(foreign_key => owner.id)
|
|
137
|
+
foreign_type ? scope.where(foreign_type => owner.class.name) : scope
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Safety net run before we destroy the source: after the move, none of the models we moved may still point
|
|
141
|
+
# at the source - otherwise destroying it would orphan or cascade-delete that record. Re-checks the same
|
|
142
|
+
# klasses the move walked, so a bug there (an STI type mismatch, a row written mid-merge) fails the merge
|
|
143
|
+
# loudly and rolls it back instead of losing data.
|
|
144
|
+
def assert_no_references_to_source!(klasses)
|
|
145
|
+
klasses.uniq(&:table_name).each do |klass|
|
|
146
|
+
source_foreign_keys(klass).each do |foreign_key, foreign_type|
|
|
147
|
+
next unless records_for(klass, foreign_key, foreign_type, source).exists?
|
|
148
|
+
|
|
149
|
+
raise "Merge incomplete: #{klass.table_name}.#{foreign_key} still references #{source_type} ##{source_id}"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Ids of the source's `klass` records the (authoritative) target already has an equivalent of - judged by
|
|
155
|
+
# klass's uniqueness validators AND unique indexes that involve the foreign key we're repointing. These get
|
|
156
|
+
# deleted instead of moved, so the merge can neither create a duplicate nor trip a unique index.
|
|
157
|
+
def duplicate_record_ids(klass, foreign_key, foreign_type)
|
|
158
|
+
identifying_columns = dedupe_key_columns(klass, foreign_key, foreign_type)
|
|
159
|
+
return [] if identifying_columns.blank?
|
|
160
|
+
|
|
161
|
+
source_records = records_for(klass, foreign_key, foreign_type, source)
|
|
162
|
+
target_records = records_for(klass, foreign_key, foreign_type, target)
|
|
163
|
+
|
|
164
|
+
identifying_columns.flat_map do |columns|
|
|
165
|
+
if columns.empty?
|
|
166
|
+
# One-per-owner (e.g. a membership, unique on the owner alone): if the target already owns one,
|
|
167
|
+
# every source record is a duplicate and is dropped rather than moved into the unique constraint.
|
|
168
|
+
target_records.exists? ? source_records.pluck(:id) : []
|
|
169
|
+
else
|
|
170
|
+
existing = target_records.pluck(*columns).map { |row| Array(row) }.to_set
|
|
171
|
+
source_records.pluck(:id, *columns).filter_map { |id, *values| id if existing.include?(values) }
|
|
172
|
+
end
|
|
173
|
+
end.uniq
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The column sets that - together with the foreign key - make a record unique for its owner, drawn from
|
|
177
|
+
# both uniqueness validators and unique indexes. The foreign key (and its *_type) is dropped from each set
|
|
178
|
+
# since every moved record shares the target's value for those; an empty set means the record is unique on
|
|
179
|
+
# the owner alone (one-per-owner). Partial indexes are skipped - their WHERE can't be judged from columns.
|
|
180
|
+
def dedupe_key_columns(klass, foreign_key, foreign_type)
|
|
181
|
+
removable = [foreign_key, foreign_type].compact
|
|
182
|
+
|
|
183
|
+
from_validators = klass.validators.select { |validator| validator.kind == :uniqueness }.filter_map do |validator|
|
|
184
|
+
columns = (Array(validator.attributes) + Array(validator.options[:scope])).map(&:to_s)
|
|
185
|
+
(columns - removable) if columns.include?(foreign_key)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
from_indexes =
|
|
189
|
+
begin
|
|
190
|
+
klass.connection.indexes(klass.table_name).filter_map do |index|
|
|
191
|
+
next unless index.unique && index.where.blank?
|
|
192
|
+
columns = Array(index.columns).map(&:to_s)
|
|
193
|
+
(columns - removable) if columns.include?(foreign_key)
|
|
194
|
+
end
|
|
195
|
+
rescue StandardError
|
|
196
|
+
[]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
(from_validators + from_indexes).uniq
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def log_merged!
|
|
203
|
+
return unless defined?(EffectiveLogger)
|
|
204
|
+
|
|
205
|
+
EffectiveLogger.success(
|
|
206
|
+
"Merged #{source} into #{target}",
|
|
207
|
+
user: current_user,
|
|
208
|
+
associated: target,
|
|
209
|
+
source_id: source_id,
|
|
210
|
+
target_id: target_id,
|
|
211
|
+
source_email: source.try(:email),
|
|
212
|
+
source_name: source.to_s
|
|
213
|
+
)
|
|
214
|
+
end
|
|
83
215
|
end
|
|
84
216
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
- effective_resource = Effective::Resource.new(resource)
|
|
2
|
+
- associations = (effective_resource.has_ones + effective_resource.has_manys).reject { |association| association.options[:through].present? }
|
|
3
|
+
- limit = 10
|
|
4
|
+
|
|
5
|
+
%table.table.table-sm.table-striped.mt-4
|
|
6
|
+
%tbody
|
|
7
|
+
- associations.each do |association|
|
|
8
|
+
- collection = resource.send(association.name)
|
|
9
|
+
- records = (collection.respond_to?(:limit) ? collection.limit(limit).to_a : Array(collection))
|
|
10
|
+
- next if records.blank?
|
|
11
|
+
|
|
12
|
+
- total = (collection.respond_to?(:count) ? collection.count : records.size)
|
|
13
|
+
- admin_resource = (Effective::Resource.new(association.klass, namespace: :admin) rescue nil)
|
|
14
|
+
|
|
15
|
+
%tr
|
|
16
|
+
%th.text-nowrap= association.name.to_s.humanize
|
|
17
|
+
%td
|
|
18
|
+
- records.each do |record|
|
|
19
|
+
- path = (admin_resource&.action_path(:edit, record) || admin_resource&.action_path(:show, record))
|
|
20
|
+
%div= path.present? ? link_to(record.to_s, path) : record.to_s
|
|
21
|
+
|
|
22
|
+
- if total > records.size
|
|
23
|
+
%small.text-muted= "and #{total - records.size} more…"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
= effective_form_with(model: [:admin, merge], engine: true) do |f|
|
|
2
|
+
-# User
|
|
3
|
+
- klass = (current_user.class)
|
|
4
|
+
- ajax_url = (effective_resources.users_effective_ajax_index_path unless Rails.env.test?)
|
|
5
|
+
|
|
6
|
+
.row
|
|
7
|
+
.col
|
|
8
|
+
= f.hidden_field :source_type, value: klass.name
|
|
9
|
+
= f.select :source_id, klass.all, ajax_url: ajax_url, hint: 'This record will be destroyed',
|
|
10
|
+
'data-load-ajax-url': effective_mergery.new_admin_merge_path,
|
|
11
|
+
'data-load-ajax-div': '#effective-mergery-ajax',
|
|
12
|
+
'data-load-ajax-all': true
|
|
13
|
+
.col
|
|
14
|
+
= f.hidden_field :target_type, value: klass.name
|
|
15
|
+
= f.select :target_id, klass.all, ajax_url: ajax_url, hint: 'This record will be kept',
|
|
16
|
+
'data-load-ajax-url': effective_mergery.new_admin_merge_path,
|
|
17
|
+
'data-load-ajax-div': '#effective-mergery-ajax',
|
|
18
|
+
'data-load-ajax-all': true
|
|
19
|
+
|
|
20
|
+
#effective-mergery-ajax
|
|
21
|
+
- source = f.object.source
|
|
22
|
+
- target = f.object.target
|
|
23
|
+
|
|
24
|
+
.row
|
|
25
|
+
.col-6
|
|
26
|
+
- if source.present?
|
|
27
|
+
= card(source.to_s) do
|
|
28
|
+
= render('admin/merges/resource', resource: source)
|
|
29
|
+
.col-6
|
|
30
|
+
- if target.present?
|
|
31
|
+
= card(target.to_s) do
|
|
32
|
+
= render('admin/merges/resource', resource: target)
|
|
33
|
+
|
|
34
|
+
- if source.present? && target.present?
|
|
35
|
+
= f.submit "Merge", center: true, 'data-confirm': "Are you sure you want to merge these records and destroy #{source}?"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
= render('admin/merges/form', merge: Effective::Merge.new)
|
data/config/effective_mergery.rb
CHANGED
|
@@ -1,24 +1,3 @@
|
|
|
1
1
|
EffectiveMergery.setup do |config|
|
|
2
|
-
|
|
3
|
-
# Admin Screens Layout Settings
|
|
4
|
-
config.layout = 'application' # All EffectiveMergery controllers will use this layout
|
|
5
|
-
|
|
6
|
-
# config.layout = {
|
|
7
|
-
# merge: 'application',
|
|
8
|
-
# admin_merge: 'admin',
|
|
9
|
-
# }
|
|
10
|
-
|
|
11
|
-
config.admin_simple_form_options = {} # For the /admin/merge/new form
|
|
12
|
-
# config.admin_simple_form_options = {
|
|
13
|
-
# :html => {:class => ['form-horizontal']},
|
|
14
|
-
# :wrapper => :horizontal_form,
|
|
15
|
-
# :wrapper_mappings => {
|
|
16
|
-
# :boolean => :horizontal_boolean,
|
|
17
|
-
# :check_boxes => :horizontal_radio_and_checkboxes,
|
|
18
|
-
# :radio_buttons => :horizontal_radio_and_checkboxes
|
|
19
|
-
# }
|
|
20
|
-
# }
|
|
21
|
-
|
|
22
|
-
# The class names that can be merged
|
|
23
|
-
# config.class_names = ['User']
|
|
2
|
+
# config.layout = { admin_merge: 'admin' }
|
|
24
3
|
end
|
data/config/routes.rb
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
+
Rails.application.routes.draw do
|
|
2
|
+
mount EffectiveMergery::Engine => '/', :as => 'effective_mergery'
|
|
3
|
+
end
|
|
4
|
+
|
|
1
5
|
EffectiveMergery::Engine.routes.draw do
|
|
2
6
|
namespace :admin do
|
|
3
|
-
resources :
|
|
4
|
-
get :attributes, on: :collection
|
|
5
|
-
end
|
|
7
|
+
resources :merges, only: [:index, :new, :create]
|
|
6
8
|
end
|
|
7
9
|
end
|
|
8
|
-
|
|
9
|
-
Rails.application.routes.draw do
|
|
10
|
-
mount EffectiveMergery::Engine => '/', :as => 'effective_mergery'
|
|
11
|
-
end
|
data/lib/effective_mergery.rb
CHANGED
|
@@ -5,14 +5,8 @@ require 'effective_mergery/version'
|
|
|
5
5
|
module EffectiveMergery
|
|
6
6
|
|
|
7
7
|
def self.config_keys
|
|
8
|
-
[:layout
|
|
8
|
+
[:layout]
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
include EffectiveGem
|
|
12
|
-
|
|
13
|
-
# Just consider the onlies right now. sorry future matt.
|
|
14
|
-
def self.mergables
|
|
15
|
-
Array(class_names).map { |name| name.constantize }
|
|
16
|
-
end
|
|
17
|
-
|
|
18
12
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: effective_mergery
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Code and Effect
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-06-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -16,16 +16,16 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version:
|
|
19
|
+
version: 6.0.0
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version:
|
|
26
|
+
version: 6.0.0
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
|
-
name:
|
|
28
|
+
name: effective_bootstrap
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
30
30
|
requirements:
|
|
31
31
|
- - ">="
|
|
@@ -39,7 +39,21 @@ dependencies:
|
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '0'
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
|
-
name:
|
|
42
|
+
name: effective_datatables
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: 4.0.0
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: 4.0.0
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: effective_resources
|
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
|
44
58
|
requirements:
|
|
45
59
|
- - ">="
|
|
@@ -53,13 +67,13 @@ dependencies:
|
|
|
53
67
|
- !ruby/object:Gem::Version
|
|
54
68
|
version: '0'
|
|
55
69
|
- !ruby/object:Gem::Dependency
|
|
56
|
-
name:
|
|
70
|
+
name: sqlite3
|
|
57
71
|
requirement: !ruby/object:Gem::Requirement
|
|
58
72
|
requirements:
|
|
59
73
|
- - ">="
|
|
60
74
|
- !ruby/object:Gem::Version
|
|
61
75
|
version: '0'
|
|
62
|
-
type: :
|
|
76
|
+
type: :development
|
|
63
77
|
prerelease: false
|
|
64
78
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
79
|
requirements:
|
|
@@ -67,13 +81,83 @@ dependencies:
|
|
|
67
81
|
- !ruby/object:Gem::Version
|
|
68
82
|
version: '0'
|
|
69
83
|
- !ruby/object:Gem::Dependency
|
|
70
|
-
name:
|
|
84
|
+
name: devise
|
|
71
85
|
requirement: !ruby/object:Gem::Requirement
|
|
72
86
|
requirements:
|
|
73
87
|
- - ">="
|
|
74
88
|
- !ruby/object:Gem::Version
|
|
75
89
|
version: '0'
|
|
76
|
-
type: :
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - ">="
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: haml-rails
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0'
|
|
104
|
+
type: :development
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0'
|
|
111
|
+
- !ruby/object:Gem::Dependency
|
|
112
|
+
name: pry-byebug
|
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - ">="
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: '0'
|
|
118
|
+
type: :development
|
|
119
|
+
prerelease: false
|
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - ">="
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: '0'
|
|
125
|
+
- !ruby/object:Gem::Dependency
|
|
126
|
+
name: effective_logging
|
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
|
128
|
+
requirements:
|
|
129
|
+
- - ">="
|
|
130
|
+
- !ruby/object:Gem::Version
|
|
131
|
+
version: '0'
|
|
132
|
+
type: :development
|
|
133
|
+
prerelease: false
|
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
135
|
+
requirements:
|
|
136
|
+
- - ">="
|
|
137
|
+
- !ruby/object:Gem::Version
|
|
138
|
+
version: '0'
|
|
139
|
+
- !ruby/object:Gem::Dependency
|
|
140
|
+
name: effective_test_bot
|
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
|
142
|
+
requirements:
|
|
143
|
+
- - ">="
|
|
144
|
+
- !ruby/object:Gem::Version
|
|
145
|
+
version: '0'
|
|
146
|
+
type: :development
|
|
147
|
+
prerelease: false
|
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
149
|
+
requirements:
|
|
150
|
+
- - ">="
|
|
151
|
+
- !ruby/object:Gem::Version
|
|
152
|
+
version: '0'
|
|
153
|
+
- !ruby/object:Gem::Dependency
|
|
154
|
+
name: effective_developer
|
|
155
|
+
requirement: !ruby/object:Gem::Requirement
|
|
156
|
+
requirements:
|
|
157
|
+
- - ">="
|
|
158
|
+
- !ruby/object:Gem::Version
|
|
159
|
+
version: '0'
|
|
160
|
+
type: :development
|
|
77
161
|
prerelease: false
|
|
78
162
|
version_requirements: !ruby/object:Gem::Requirement
|
|
79
163
|
requirements:
|
|
@@ -91,15 +175,16 @@ files:
|
|
|
91
175
|
- README.md
|
|
92
176
|
- app/assets/config/effective_mergery_manifest.js
|
|
93
177
|
- app/assets/javascripts/effective_mergery.js
|
|
94
|
-
- app/assets/javascripts/effective_mergery/
|
|
178
|
+
- app/assets/javascripts/effective_mergery/base.js
|
|
95
179
|
- app/assets/stylesheets/effective_mergery.scss
|
|
96
180
|
- app/assets/stylesheets/effective_mergery/_merge.scss
|
|
97
|
-
- app/controllers/admin/
|
|
181
|
+
- app/controllers/admin/merges_controller.rb
|
|
98
182
|
- app/models/effective/merge.rb
|
|
99
|
-
- app/views/admin/
|
|
100
|
-
- app/views/admin/
|
|
101
|
-
- app/views/admin/
|
|
102
|
-
- app/views/admin/
|
|
183
|
+
- app/views/admin/merges/_associations.html.haml
|
|
184
|
+
- app/views/admin/merges/_form.html.haml
|
|
185
|
+
- app/views/admin/merges/_resource.html.haml
|
|
186
|
+
- app/views/admin/merges/_user.html.haml
|
|
187
|
+
- app/views/admin/merges/index.html.haml
|
|
103
188
|
- config/effective_mergery.rb
|
|
104
189
|
- config/routes.rb
|
|
105
190
|
- lib/effective_mergery.rb
|
|
@@ -125,7 +210,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
125
210
|
- !ruby/object:Gem::Version
|
|
126
211
|
version: '0'
|
|
127
212
|
requirements: []
|
|
128
|
-
rubygems_version: 3.
|
|
213
|
+
rubygems_version: 3.5.9
|
|
129
214
|
signing_key:
|
|
130
215
|
specification_version: 4
|
|
131
216
|
summary: Deep merge any two Active Record objects.
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
loadAttributes = (event) ->
|
|
2
|
-
$obj = $(event.currentTarget)
|
|
3
|
-
|
|
4
|
-
id = parseInt($obj.val())
|
|
5
|
-
type = $obj.closest('form').find("input[name='effective_merge[type]']").val()
|
|
6
|
-
|
|
7
|
-
selector = if ($obj.attr('name') == 'effective_merge[source_id]') then '.source' else '.target'
|
|
8
|
-
content = $obj.closest('form').find(selector).first()
|
|
9
|
-
|
|
10
|
-
url = "/admin/merge/attributes?id=#{id}&type=#{type}"
|
|
11
|
-
|
|
12
|
-
if id != undefined && id != NaN && id > 0 && type.length > 0
|
|
13
|
-
content.load(url, (response, status, xhr) =>
|
|
14
|
-
content.html('<p>This item is unavailable (ajax error)</p>') if status == 'error'
|
|
15
|
-
)
|
|
16
|
-
else
|
|
17
|
-
content.html('')
|
|
18
|
-
|
|
19
|
-
$(document).on 'change', "select[name='effective_merge[source_id]']", (event) -> loadAttributes(event)
|
|
20
|
-
$(document).on 'change', "select[name='effective_merge[target_id]']", (event) -> loadAttributes(event)
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
module Admin
|
|
2
|
-
class MergeController < ApplicationController
|
|
3
|
-
before_action(:authenticate_user!) if defined?(Devise)
|
|
4
|
-
before_action { EffectiveResources.authorize!(self, :admin, :effective_mergery) }
|
|
5
|
-
|
|
6
|
-
include Effective::CrudController
|
|
7
|
-
|
|
8
|
-
if (config = EffectiveMergery.layout)
|
|
9
|
-
layout(config.kind_of?(Hash) ? config[:admin] : config)
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def index
|
|
13
|
-
@page_title = 'Merges'
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def new
|
|
17
|
-
@page_title = 'New Merge'
|
|
18
|
-
|
|
19
|
-
begin
|
|
20
|
-
@merge = Effective::Merge.new(type: params[:type])
|
|
21
|
-
@merge.validate_klass!
|
|
22
|
-
rescue => e
|
|
23
|
-
flash[:danger] = "An error occurred while loading #{@merge}: #{e.message}"
|
|
24
|
-
redirect_to effective_mergery.admin_merge_index_path
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def create
|
|
29
|
-
@merge = Effective::Merge.new(merge_params)
|
|
30
|
-
|
|
31
|
-
if @merge.save
|
|
32
|
-
@page_title = 'Successful Merge'
|
|
33
|
-
flash[:success] = "Successfully merged #{@merge}"
|
|
34
|
-
|
|
35
|
-
if defined?(EffectiveLogging)
|
|
36
|
-
EffectiveLogger.success(
|
|
37
|
-
"Merged #{@merge} - #{@merge.source.respond_to?(:to_s_verbose) ? @merge.source.to_s_verbose : @merge.source.to_s}",
|
|
38
|
-
user: (current_user rescue false),
|
|
39
|
-
associated: @merge.target,
|
|
40
|
-
source_id: @merge.source_id,
|
|
41
|
-
target_id: @merge.target_id,
|
|
42
|
-
mergable_type: @merge.type,
|
|
43
|
-
source_email: (@merge.source.email if @merge.source.respond_to?(:email)),
|
|
44
|
-
source_name: (@merge.source.full_name if @merge.source.respond_to?(:full_name))
|
|
45
|
-
)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
@merge.target = @merge.collection.find(@merge.target_id)
|
|
49
|
-
else
|
|
50
|
-
@page_title = 'New Merge'
|
|
51
|
-
flash.now[:danger] = "Unable to merge #{@merge}: #{@merge.errors.full_messages.to_sentence}"
|
|
52
|
-
|
|
53
|
-
render :new
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# This is the AJAX request for the object's attributes
|
|
58
|
-
def attributes
|
|
59
|
-
object = Effective::Merge.new(type: params[:type]).collection.find(params[:id])
|
|
60
|
-
|
|
61
|
-
if object.present?
|
|
62
|
-
render partial: '/admin/merge/attributes', locals: { resource: object }
|
|
63
|
-
else
|
|
64
|
-
render body: '<p>None Available</p>'
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
private
|
|
69
|
-
|
|
70
|
-
def merge_params
|
|
71
|
-
params.require(:effective_merge).permit!
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
end
|
|
75
|
-
end
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
%h1.effective-admin-heading= @page_title
|
|
2
|
-
|
|
3
|
-
.row
|
|
4
|
-
.col-sm-2
|
|
5
|
-
.col-sm-8
|
|
6
|
-
%p= @merge.target
|
|
7
|
-
= render partial: '/admin/merge/attributes', locals: { resource: @merge.target }
|
|
8
|
-
.col-sm-2
|
|
9
|
-
|
|
10
|
-
.text-center
|
|
11
|
-
%p= link_to "Merge Another #{@merge}", effective_mergery.new_admin_merge_path(type: @merge.type), class: 'btn btn-primary'
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
%h1.effective-admin-heading= @page_title
|
|
2
|
-
|
|
3
|
-
%p Please select one of the following to merge:
|
|
4
|
-
|
|
5
|
-
- if EffectiveMergery.mergables.blank?
|
|
6
|
-
%p There is nothing available to merge.
|
|
7
|
-
- else
|
|
8
|
-
%ul
|
|
9
|
-
- EffectiveMergery.mergables.each do |name|
|
|
10
|
-
%li= link_to name, effective_mergery.new_admin_merge_path(type: name)
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
%h1.effective-admin-heading= @page_title
|
|
2
|
-
|
|
3
|
-
%p Please select a source and target. The source record's associated data, but not its attributes, will be merged into the target. The source record will then be destroyed.
|
|
4
|
-
|
|
5
|
-
.effective-merge
|
|
6
|
-
= simple_form_for([:admin, @merge], url: effective_mergery.admin_merge_index_path) do |f|
|
|
7
|
-
= f.input :type, as: :hidden
|
|
8
|
-
|
|
9
|
-
.row
|
|
10
|
-
.col-sm-6
|
|
11
|
-
= f.input :source_id, as: (defined?(EffectiveFormInputs) ? :effective_select : :select),
|
|
12
|
-
collection: f.object.form_collection,
|
|
13
|
-
hint: 'This record will be destroyed'
|
|
14
|
-
.col-sm-6
|
|
15
|
-
= f.input :target_id, as: (defined?(EffectiveFormInputs) ? :effective_select : :select),
|
|
16
|
-
collection: f.object.form_collection,
|
|
17
|
-
hint: 'This record will be kept'
|
|
18
|
-
|
|
19
|
-
.row
|
|
20
|
-
.col-sm-6.source
|
|
21
|
-
- if f.object.source.present?
|
|
22
|
-
= render partial: '/admin/merge/attributes', locals: { resource: f.object.source }
|
|
23
|
-
|
|
24
|
-
.col-sm-6.target
|
|
25
|
-
- if f.object.target.present?
|
|
26
|
-
= render partial: '/admin/merge/attributes', locals: { resource: f.object.target }
|
|
27
|
-
|
|
28
|
-
.form-actions
|
|
29
|
-
= f.button :submit, "Merge #{@merge}", data: { disable_with: 'Merging...', confirm: "Really merge? The source record will be destroyed."}
|