Class: ProjectUnification::Service

Inherits:
Object
  • Object
show all
Defined in:
lib/project_unification.rb

Constant Summary collapse

UNIFY_CUTOFF =
1000

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source_project:, target_project:, options: {}) ⇒ Service

Returns a new instance of Service.

Parameters:

  • source_project (Project)

    Project to merge from (will be emptied)

  • target_project (Project)

    Project to merge into (receives all data)

  • options (Hash) (defaults to: {})

Options Hash (options:):

  • :root_taxon_name_id (Integer)

    Optional target parent for TaxonName hierarchy

  • :preview (Boolean)

    If true, rolls back all changes (default: true)

  • :user_id (Integer)

    ID of the user performing the unification; required so that updated_by_id is set correctly on all migrated records (including during preview, since record.valid? triggers before_validation callbacks)

  • :skip_cached_rebuild (Boolean)

    Skip rebuilding cached fields (default: false)



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/project_unification.rb', line 30

def initialize(source_project:, target_project:, options: {})
  @source_project = source_project
  @target_project = target_project
  @options = {
    preview: true,
    skip_cached_rebuild: false
  }.merge(options)

  @results = {
    unified: false,
    preview_mode: @options[:preview],
    source_project_id: source_project.id,
    target_project_id: target_project.id,
    started_at: nil,
    completed_at: nil,
    duration_seconds: 0,
    statistics: {},
    details_by_model: {},
    conflicts: [],
    errors: [],
    rollback_performed: false
  }
end

Instance Attribute Details

#optionsObject (readonly)

Returns the value of attribute options.



15
16
17
# File 'lib/project_unification.rb', line 15

def options
  @options
end

#resultsObject (readonly)

Returns the value of attribute results.



15
16
17
# File 'lib/project_unification.rb', line 15

def results
  @results
end

#source_projectObject (readonly)

Returns the value of attribute source_project.



15
16
17
# File 'lib/project_unification.rb', line 15

def source_project
  @source_project
end

#target_projectObject (readonly)

Returns the value of attribute target_project.



15
16
17
# File 'lib/project_unification.rb', line 15

def target_project
  @target_project
end

Instance Method Details

#run_cleanup(merge_registry) ⇒ Object (private)

Iterate the merge registry produced by Phase 1 and collapse each sentinel record into its canonical target. Failures are collected as errors (not raised) so one bad pair does not abort the remaining cleanup.



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/project_unification.rb', line 160

def run_cleanup(merge_registry)
  merge_registry.each do |entry|
    klass   = entry[:model].constantize
    target  = klass.find(entry[:target_id])
    renamed = klass.find(entry[:renamed_id])

    # TODO: this pre-step should live in Image#unify (or a before_unify hook)
    # so that any image merge handles it, not just project unification.
    # Image has dependent: :restrict_with_error on depictions, so unify
    # rolls back if any Depiction (on community data like Person) can't be
    # rerouted due to a uniqueness conflict.
    if entry[:model] == 'Image'
      renamed.depictions.find_each do |sentinel_dep|
        target_dep = Depiction.find_by(
          image_id: target.id,
          depiction_object_type: sentinel_dep.depiction_object_type,
          depiction_object_id:   sentinel_dep.depiction_object_id
        )
        target_dep.unify(sentinel_dep, cutoff: UNIFY_CUTOFF) if target_dep
      end
    end

    result = target.unify(renamed, cutoff: UNIFY_CUTOFF)
    unless result[:result][:unified]
      @results[:errors] << {
        model: entry[:model],
        error: "Post-migration unify failed for #{entry[:model]} ID #{entry[:renamed_id]}: #{result[:result][:message]}"
      }
    end
  rescue => e
    @results[:errors] << { model: entry[:model], error: e.message }
  end
end

#run_migrationObject (private)



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/project_unification.rb', line 131

def run_migration
  migrator = ProjectUnification::Migrator.new(
    source_project_id: source_project.id,
    target_project_id: target_project.id,
    options: @options
  )

  migration_results = migrator.migrate_all

  @results[:statistics] = migration_results[:statistics]
  @results[:details_by_model] = migration_results[:details_by_model]
  @results[:conflicts].concat(migration_results[:conflicts])
  @results[:errors].concat(migration_results[:errors])

  # Run unify on records that need it - both sides are now in the target
  # project, so Shared::Unify works without cross-project restrictions.
  run_cleanup(migration_results[:merge_registry] || [])

  # Rebuild cached fields unless skipped
  unless @options[:skip_cached_rebuild] || @options[:preview]
    rebuilder = ProjectUnification::CachedRebuilder.new(target_project.id)
    rebuild_results = rebuilder.rebuild_all
    @results[:cached_rebuild] = rebuild_results
  end
end

#unifyHash

Execute the unification process

Returns:

  • (Hash)

    Results hash with detailed statistics and error information



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/project_unification.rb', line 56

def unify
  @results[:started_at] = Time.now

  validate_prerequisites!

  # Capture ambient Current state so we can restore it after (important if
  # called from a request context; from a rake task they'd both be nil).
  saved_user_id = Current.user_id
  saved_project_id = Current.project_id

  # Migration must run under the provided user so that updated_by_id is set
  # correctly on every record. project_id is cleared so that
  # find_or_create_by calls in callbacks cannot accidentally scope to the
  # caller's ambient project.
  Current.user_id = @options[:user_id]
  Current.project_id = nil

  # Allows for cross-project saves
  Utilities::ThreadStore[:tw_project_unification] = true

  Project.transaction do
    run_migration

    @results[:unified] = @results[:errors].empty? && @results[:conflicts].empty?

    if @options[:preview] || @results[:errors].any? || @results[:conflicts].any?
      raise ActiveRecord::Rollback
    end
  rescue ActiveRecord::Rollback
    @results[:rollback_performed] = true
    raise
  rescue StandardError => e
    @results[:errors] << {
      model: 'Transaction',
      error: e.message,
      backtrace: e.backtrace.first(5)
    }
    @results[:rollback_performed] = true
    raise ActiveRecord::Rollback
  rescue Exception => e
    @results[:rollback_performed] = true
    raise
  end

  @results[:completed_at] = Time.now
  @results[:duration_seconds] = (@results[:completed_at] - @results[:started_at]).round(2)

  @results
ensure
  Utilities::ThreadStore[:tw_project_unification] = nil
  Current.user_id = saved_user_id
  Current.project_id = saved_project_id
end

#validate_prerequisites!Object (private)



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/project_unification.rb', line 112

def validate_prerequisites!
  if source_project.id == target_project.id
    raise ArgumentError, 'Cannot unify a project with itself'
  end

  if @options[:root_taxon_name_id]
    target_taxon = TaxonName.find_by(id: @options[:root_taxon_name_id])
    unless target_taxon && target_taxon.project_id == target_project.id
      raise ArgumentError, 'root_taxon_name_id must belong to target project'
    end
  end

  if @options[:user_id]
    unless User.find_by(id: @options[:user_id])
      raise ArgumentError, 'user_id must be a valid user'
    end
  end
end