tessa 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3af9279c5dbfc648d1044110181aeac67f621cd2d60dbe4846f959855db06c12
4
- data.tar.gz: afdab873b2e139c7d0e317b758f15e7c810a083a29e792fbdb2747e240ea590b
3
+ metadata.gz: d31c66f27d8c731d0df1e58f196bd6279f4f2603ab39e33f75f8cf46630745fe
4
+ data.tar.gz: fe6050f938245f6502771adff53f13bd3af40a0339a1098d96bbd2416d6e0a2b
5
5
  SHA512:
6
- metadata.gz: d3ec7026403a90d19eb97b92ad5ae9ffce2bf7f473f879a27f19f863f3d776b834aa40f1e437a8a15a81e499a9aa5872f98a4de1ff6630e9cb33fffed673e27e
7
- data.tar.gz: 4f1b7591de7519f6936234e69796d358c13b0d238c987fb956ebd60289d254838028f05f1c934177264f83494529636ba6e958887cd78006898f542c74d3e1f9
6
+ metadata.gz: 76047a38e882dd59fe104b8be03a2970d996e70eb5f469549d8529e384f46732f5f76dad54e0d7c3d158c60de032b01c467ed13202209d0c4fdad4faa0328b84
7
+ data.tar.gz: '085ac99b9dcce059741887f7852d4a2fd08412b35fa5e520ac61c79f49d5511f43485fe6836863910d07f9336d0c295b31b1bb691b51abc3ea00cefefac35a0b'
data/Gemfile CHANGED
@@ -7,3 +7,8 @@ group :development do
7
7
  gem 'pry'
8
8
  gem 'dotenv'
9
9
  end
10
+
11
+ group :test do
12
+ gem 'rspec-rails'
13
+ gem 'webmock'
14
+ end
@@ -0,0 +1,7 @@
1
+
2
+ namespace :tessa do
3
+ desc "Begins the migration of all Tessa assets to ActiveStorage."
4
+ task :migrate => :environment do
5
+ Tessa::MigrateAssetsJob.perform_later
6
+ end
7
+ end
@@ -1,5 +1,9 @@
1
1
  module Tessa::ActiveStorage
2
2
  class AssetWrapper < SimpleDelegator
3
+ def id
4
+ key
5
+ end
6
+
3
7
  def public_url
4
8
  Rails.application.routes.url_helpers.
5
9
  rails_blob_url(__getobj__, disposition: :inline)
@@ -14,7 +18,11 @@ module Tessa::ActiveStorage
14
18
  end
15
19
 
16
20
  def meta
17
- {}
21
+ {
22
+ mime_type: content_type,
23
+ size: byte_size,
24
+ name: filename
25
+ }
18
26
  end
19
27
 
20
28
  def failure?
@@ -0,0 +1,216 @@
1
+ require 'open-uri'
2
+
3
+ class Tessa::MigrateAssetsJob < ActiveJob::Base
4
+
5
+ queue_as :low
6
+
7
+ def perform(*args)
8
+ options = args&.extract_options!
9
+ options = {
10
+ batch_size: 10,
11
+ interval: 10.minutes.to_i
12
+ }.merge!(options&.symbolize_keys || {})
13
+
14
+ interval = options[:interval].seconds
15
+ processing_state = args.first ? Marshal.load(args.first) : load_models_from_registry
16
+
17
+ if processing_state.fully_processed?
18
+ Rails.logger.info("Nothing to do - all models have transitioned to ActiveStorage")
19
+ return
20
+ end
21
+
22
+ processing_state.batch_count = 0
23
+ while processing_state.batch_count < options[:batch_size]
24
+ model_state = processing_state.next_model
25
+
26
+ process(processing_state, model_state, options)
27
+
28
+ break if processing_state.fully_processed?
29
+ end
30
+
31
+ if processing_state.fully_processed?
32
+ Rails.logger.info("Finished processing all Tessa assets")
33
+ else
34
+ remaining_batches = (processing_state.count / options[:batch_size].to_f).ceil
35
+
36
+ Rails.logger.info("Continuing processing in #{interval}, "\
37
+ "ETA #{(remaining_batches * interval).from_now}. "\
38
+ "Working on #{processing_state.next_model.next_field}")
39
+
40
+ processing_state.batch_count = 0
41
+ self.class.set(wait: interval)
42
+ .perform_later(Marshal.dump(processing_state), options)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def process(processing_state, model_state, options)
49
+ while processing_state.batch_count < options[:batch_size]
50
+ field_state = model_state.next_field
51
+
52
+ process_field(processing_state, field_state, options)
53
+
54
+ return if model_state.fully_processed?
55
+ end
56
+ end
57
+
58
+ def process_field(processing_state, field_state, options)
59
+ while processing_state.batch_count < options[:batch_size]
60
+ remaining = options[:batch_size] - processing_state.batch_count
61
+
62
+ next_batch = field_state.query
63
+ .offset(field_state.offset)
64
+ .limit(remaining)
65
+
66
+ next_batch.each_with_index do |record, idx|
67
+ begin
68
+ # Wait 1 second in between records to slow things down and let the
69
+ # system process for a bit
70
+ sleep 1 if idx > 0
71
+
72
+ reupload(record, field_state)
73
+ Rails.logger.info("#{record.class}#{record.id}##{field_state.field_name}: success")
74
+ field_state.success_count += 1
75
+ rescue StandardError => ex
76
+ Rails.logger.error("#{record.class}#{record.id}##{field_state.field_name}: error - #{ex}")
77
+ field_state.offset += 1
78
+ ensure
79
+ processing_state.batch_count += 1
80
+ end
81
+ end
82
+
83
+ return if field_state.fully_processed?
84
+ end
85
+ end
86
+
87
+ def reupload(record, field_state)
88
+ if field_state.tessa_field.multiple?
89
+ reupload_multiple(record, field_state)
90
+ else
91
+ reupload_single(record, field_state)
92
+ end
93
+ end
94
+
95
+ def reupload_single(record, field_state)
96
+ # models with ActiveStorage uploads have nil in the column, but if you call
97
+ # the method it hits the dynamic extensions and gives you the blob key
98
+ database_id = record.attributes["_tessa_#{field_state.tessa_field.id_field}"]
99
+ return unless database_id
100
+
101
+ asset = Tessa::Asset.find(database_id)
102
+
103
+ attachable = {
104
+ io: URI.open(asset.private_download_url),
105
+ filename: asset.meta[:name]
106
+ }
107
+
108
+ record.public_send("#{field_state.tessa_field.name}=", attachable)
109
+ record.save!
110
+ end
111
+
112
+ def reupload_multiple(record, field_state)
113
+ database_ids = record.attributes["_tessa_#{field_state.tessa_field.id_field}"]
114
+ return unless database_ids
115
+
116
+ assets = Tessa::Asset.find(database_ids)
117
+
118
+ attachables = assets.map do |asset|
119
+ {
120
+ io: URI.open(asset.private_download_url),
121
+ filename: asset.meta[:name]
122
+ }
123
+ end
124
+
125
+ record.public_send("#{field_state.tessa_field.name}=", attachables)
126
+ record.save!
127
+ end
128
+
129
+ def load_models_from_registry
130
+ Rails.application.eager_load!
131
+
132
+ # Load all Tessa models that can have attachments (not form objects)
133
+ models = Tessa.model_registry
134
+ .select { |m| m.respond_to?(:has_one_attached) }
135
+
136
+ # Initialize our Record Keeping object
137
+ ProcessingState.initialize_from_models(models)
138
+ end
139
+
140
+ ProcessingState = Struct.new(:model_queue, :batch_count) do
141
+ def self.initialize_from_models(models)
142
+ new(
143
+ models.map do |model|
144
+ ModelProcessingState.initialize_from_model(model)
145
+ end,
146
+ 0
147
+ )
148
+ end
149
+
150
+ def next_model
151
+ model_queue.detect { |i| !i.fully_processed? }
152
+ end
153
+
154
+ def fully_processed?
155
+ model_queue.all?(&:fully_processed?)
156
+ end
157
+
158
+ def count
159
+ model_queue.sum { |m| m.field_queue.sum { |f| f.count - f.offset } }
160
+ end
161
+ end
162
+
163
+ ModelProcessingState = Struct.new(:class_name, :field_queue) do
164
+ def self.initialize_from_model(model)
165
+ new(
166
+ model.name,
167
+ model.tessa_fields.map do |name, _|
168
+ FieldProcessingState.initialize_from_model(model, name)
169
+ end
170
+ )
171
+ end
172
+
173
+ def next_field
174
+ field_queue.detect { |i| !i.fully_processed? }
175
+ end
176
+
177
+ def model
178
+ @model ||= class_name.constantize
179
+ end
180
+
181
+ def fully_processed?
182
+ field_queue.all?(&:fully_processed?)
183
+ end
184
+ end
185
+
186
+ FieldProcessingState = Struct.new(:class_name, :field_name, :offset, :success_count) do
187
+ def self.initialize_from_model(model, field_name)
188
+ new(
189
+ model.name,
190
+ field_name,
191
+ 0,
192
+ 0
193
+ )
194
+ end
195
+
196
+ def model
197
+ @model ||= class_name.constantize
198
+ end
199
+
200
+ def tessa_field
201
+ model.tessa_fields[field_name]
202
+ end
203
+
204
+ def query
205
+ model.where.not(Hash[tessa_field.id_field, nil])
206
+ end
207
+
208
+ def count
209
+ query.count
210
+ end
211
+
212
+ def fully_processed?
213
+ offset >= count
214
+ end
215
+ end
216
+ end
@@ -44,9 +44,11 @@ class Tessa::DynamicExtensions
44
44
 
45
45
  case attachable
46
46
  when Tessa::AssetChangeSet
47
- attachable.changes.each do |change|
48
- a.attach(change.id) if change.add?
49
- a.detach if change.remove?
47
+ attachable.changes.select(&:remove?).each { a.detatch }
48
+ attachable.changes.select(&:add?).each do |change|
49
+ next if #{field.id_field} == change.id
50
+
51
+ a.attach(change.id)
50
52
  end
51
53
  when nil
52
54
  a.detach
@@ -60,7 +62,8 @@ class Tessa::DynamicExtensions
60
62
 
61
63
  def attributes
62
64
  super.merge({
63
- '#{field.id_field}' => #{field.id_field}
65
+ '#{field.id_field}' => #{field.id_field},
66
+ '_tessa_#{field.id_field}' => super['#{field.id_field}']
64
67
  })
65
68
  end
66
69
  CODE
@@ -72,24 +75,22 @@ class Tessa::DynamicExtensions
72
75
  def build(mod)
73
76
  mod.class_eval <<~CODE, __FILE__, __LINE__ + 1
74
77
  def #{name}
75
- if #{name}_attachments.present?
76
- return #{name}_attachments.map do |a|
77
- Tessa::ActiveStorage::AssetWrapper.new(a)
78
- end
79
- end
80
-
81
- # fall back to old Tessa fetch if not present
82
- if field = self.class.tessa_fields["#{name}".to_sym]
83
- @#{name} ||= fetch_tessa_remote_assets(field.id(on: self))
84
- end
78
+ field = self.class.tessa_fields["#{name}".to_sym]
79
+ tessa_ids = field.id(on: self) - #{name}_attachments.map(&:key)
80
+
81
+ @#{name} ||= [
82
+ *#{name}_attachments.map { |a| Tessa::ActiveStorage::AssetWrapper.new(a) },
83
+ *fetch_tessa_remote_assets(tessa_ids)
84
+ ]
85
85
  end
86
86
 
87
87
  def #{field.id_field}
88
- # Use the attachment's key
89
- return #{name}_attachments.map(&:key) if #{name}_attachments.present?
90
-
91
- # fallback to Tessa's database column
92
- super
88
+ [
89
+ # Use the attachment's key
90
+ *#{name}_attachments.map(&:key),
91
+ # include from Tessa's database column
92
+ *super
93
+ ]
93
94
  end
94
95
 
95
96
  def #{name}=(attachables)
@@ -99,23 +100,33 @@ class Tessa::DynamicExtensions
99
100
 
100
101
  case attachables
101
102
  when Tessa::AssetChangeSet
102
- attachables.changes.each do |change|
103
- a.attach(change.id) if change.add?
104
- raise 'TODO' if change.remove?
103
+ attachables.changes.select(&:remove?).each do |change|
104
+ if existing = #{name}_attachments.find { |a| a.key == change.id }
105
+ existing.destroy
106
+ else
107
+ ids = self.#{field.id_field}
108
+ ids.delete(change.id.to_i)
109
+ self.#{field.id_field} = ids.any? ? ids : nil
110
+ end
111
+ end
112
+ attachables.changes.select(&:add?).each do |change|
113
+ next if #{field.id_field}.include? change.id
114
+
115
+ a.attach(change.id)
105
116
  end
106
117
  when nil
107
118
  a.detach
119
+ self.#{field.id_field} = nil
108
120
  else
109
121
  a.attach(*attachables)
122
+ self.#{field.id_field} = nil
110
123
  end
111
-
112
- # overwrite the tessa ID in the database
113
- self.#{field.id_field} = nil
114
124
  end
115
125
 
116
126
  def attributes
117
127
  super.merge({
118
- '#{field.id_field}' => #{field.id_field}
128
+ '#{field.id_field}' => #{field.id_field},
129
+ '_tessa_#{field.id_field}' => super['#{field.id_field}']
119
130
  })
120
131
  end
121
132
  CODE
data/lib/tessa/model.rb CHANGED
@@ -10,6 +10,8 @@ module Tessa
10
10
  base.extend ClassMethods
11
11
  base.after_commit :apply_tessa_change_sets if base.respond_to?(:after_commit)
12
12
  base.before_destroy :remove_all_tessa_assets if base.respond_to?(:before_destroy)
13
+
14
+ Tessa.model_registry << base
13
15
  end
14
16
 
15
17
  module InstanceMethods
data/lib/tessa/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Tessa
2
- VERSION = "1.0.0"
2
+ VERSION = "1.1.0"
3
3
  end
data/lib/tessa.rb CHANGED
@@ -15,38 +15,60 @@ require "tessa/rack_upload_proxy"
15
15
  require "tessa/upload"
16
16
  require "tessa/view_helpers"
17
17
 
18
+ if defined?(ActiveJob)
19
+ require "tessa/jobs/migrate_assets_job"
20
+ end
21
+
18
22
  module Tessa
19
- def self.config
20
- @config ||= Config.new
21
- end
23
+ class << self
24
+ def config
25
+ @config ||= Config.new
26
+ end
22
27
 
23
- def self.setup
24
- yield config
25
- end
28
+ def setup
29
+ yield config
30
+ end
26
31
 
27
- def self.find_assets(ids)
28
- if [*ids].empty?
29
- if ids.is_a?(Array)
30
- []
31
- else
32
- nil
33
- end
34
- elsif (blobs = ::ActiveStorage::Blob.where(key: ids).to_a).present?
35
- if ids.is_a?(Array)
36
- blobs.map { |a| Tessa::ActiveStorage::AssetWrapper.new(a) }
37
- else
38
- Tessa::ActiveStorage::AssetWrapper.new(blobs.first)
39
- end
40
- else
41
- Tessa::Asset.find(ids)
32
+ def find_assets(ids)
33
+ return find_all_assets(ids) if ids.is_a?(Array)
34
+
35
+ return find_asset(ids)
42
36
  end
43
- rescue Tessa::RequestFailed => err
44
- if ids.is_a?(Array)
45
- ids.map do |id|
46
- Tessa::Asset::Failure.factory(id: id, response: err.response)
37
+
38
+ def model_registry
39
+ @model_registry ||= []
40
+ end
41
+
42
+ private
43
+
44
+ def find_asset(id)
45
+ return nil unless id
46
+
47
+ if blob = ::ActiveStorage::Blob.find_by(key: id)
48
+ return Tessa::ActiveStorage::AssetWrapper.new(blob)
47
49
  end
48
- else
49
- Tessa::Asset::Failure.factory(id: ids, response: err.response)
50
+
51
+ Tessa::Asset.find(id)
52
+ rescue Tessa::RequestFailed => err
53
+ Tessa::Asset::Failure.factory(id: id, response: err.response)
54
+ end
55
+
56
+ def find_all_assets(ids)
57
+ return [] if ids.empty?
58
+
59
+ blobs = ::ActiveStorage::Blob.where(key: ids).to_a
60
+ .map { |a| Tessa::ActiveStorage::AssetWrapper.new(a) }
61
+ ids = ids - blobs.map(&:key)
62
+ assets =
63
+ begin
64
+ Tessa::Asset.find(ids) if ids.any?
65
+ rescue Tessa::RequestFailed => err
66
+ ids.map do |id|
67
+ Tessa::Asset::Failure.factory(id: id, response: err.response)
68
+ end
69
+ end
70
+
71
+ [*blobs, *assets]
50
72
  end
51
73
  end
52
74
 
data/spec/rails_helper.rb CHANGED
@@ -5,10 +5,14 @@ require 'spec_helper'
5
5
 
6
6
  require File.expand_path('dummy/config/environment.rb', __dir__)
7
7
 
8
+ require 'rspec/rails'
9
+
8
10
  RSpec.configure do |config|
9
11
  ActiveStorage::Current.host = 'https://www.example.com'
10
12
  Rails.application.routes.default_url_options = {
11
13
  protocol: 'https',
12
14
  host: "www.example.com"
13
15
  }
16
+
17
+ config.use_transactional_fixtures = true
14
18
  end
data/spec/spec_helper.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require 'webmock/rspec'
1
2
  require 'tessa'
2
3
  require 'tempfile'
3
4
 
@@ -11,6 +12,8 @@ if ENV['SIMPLE_COV'] || ENV['CC_TEST_REPORTER_ID']
11
12
  end
12
13
 
13
14
  RSpec.configure do |config|
15
+ WebMock.disable_net_connect!
16
+
14
17
  config.expect_with :rspec do |expectations|
15
18
  expectations.include_chain_clauses_in_custom_matcher_descriptions = true
16
19
  end
@@ -1,7 +1,7 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  RSpec.describe Tessa::Config do
4
- subject(:config) { Tessa::Config.new }
4
+ let(:cfg) { Tessa::Config.new }
5
5
 
6
6
  shared_examples_for "defaults to environment variable" do
7
7
  around { |ex| swap_environment_var(variable_name, 'from-env') { ex.run } }
@@ -19,63 +19,63 @@ RSpec.describe Tessa::Config do
19
19
  describe "#username" do
20
20
  it_behaves_like "defaults to environment variable" do
21
21
  let(:variable_name) { 'TESSA_USERNAME' }
22
- subject(:username) { config.username }
22
+ subject { cfg.username }
23
23
  end
24
24
 
25
25
  it "behaves like a normal accessor" do
26
- config.username = "my-new-value"
27
- expect(config.username).to eq("my-new-value")
26
+ cfg.username = "my-new-value"
27
+ expect(cfg.username).to eq("my-new-value")
28
28
  end
29
29
  end
30
30
 
31
31
  describe "#password" do
32
32
  it_behaves_like "defaults to environment variable" do
33
33
  let(:variable_name) { 'TESSA_PASSWORD' }
34
- subject(:password) { config.password }
34
+ subject { cfg.password }
35
35
  end
36
36
 
37
37
  it "behaves like a normal accessor" do
38
- config.password = "my-new-value"
39
- expect(config.password).to eq("my-new-value")
38
+ cfg.password = "my-new-value"
39
+ expect(cfg.password).to eq("my-new-value")
40
40
  end
41
41
  end
42
42
 
43
43
  describe "#url" do
44
44
  it_behaves_like "defaults to environment variable" do
45
45
  let(:variable_name) { 'TESSA_URL' }
46
- subject(:url) { config.url }
46
+ subject { cfg.url }
47
47
  end
48
48
 
49
49
  it "behaves like a normal accessor" do
50
- config.url = "my-new-value"
51
- expect(config.url).to eq("my-new-value")
50
+ cfg.url = "my-new-value"
51
+ expect(cfg.url).to eq("my-new-value")
52
52
  end
53
53
  end
54
54
 
55
55
  describe "#strategy" do
56
56
  it_behaves_like "defaults to environment variable" do
57
57
  let(:variable_name) { 'TESSA_STRATEGY' }
58
- subject(:strategy) { config.strategy }
58
+ subject { cfg.strategy }
59
59
  end
60
60
 
61
61
  it "uses the string 'default' when no envvar passed" do
62
- expect(config.strategy).to eq("default")
62
+ expect(cfg.strategy).to eq("default")
63
63
  end
64
64
 
65
65
  it "behaves like a normal accessor" do
66
- config.strategy = "my-new-value"
67
- expect(config.strategy).to eq("my-new-value")
66
+ cfg.strategy = "my-new-value"
67
+ expect(cfg.strategy).to eq("my-new-value")
68
68
  end
69
69
  end
70
70
 
71
71
  describe "#connection" do
72
72
  it "is a Faraday::Connection" do
73
- expect(config.connection).to be_a(Faraday::Connection)
73
+ expect(cfg.connection).to be_a(Faraday::Connection)
74
74
  end
75
75
 
76
- context "with values configured" do
77
- subject(:connection) { config.connection }
78
- before { args.each { |k, v| config.send("#{k}=", v) } }
76
+ context "with values cfgured" do
77
+ subject { cfg.connection }
78
+ before { args.each { |k, v| cfg.send("#{k}=", v) } }
79
79
  let(:args) {
80
80
  {
81
81
  url: "http://tessa.test",
@@ -85,28 +85,28 @@ RSpec.describe Tessa::Config do
85
85
  }
86
86
 
87
87
  it "sets faraday's url prefix to our url" do
88
- expect(connection.url_prefix.to_s).to match(config.url)
88
+ expect(subject.url_prefix.to_s).to match(cfg.url)
89
89
  end
90
90
 
91
91
  context "with faraday spy" do
92
92
  let(:spy) { instance_spy(Faraday::Connection) }
93
93
  before do
94
94
  expect(Faraday).to receive(:new).and_yield(spy)
95
- connection
95
+ subject
96
96
  end
97
97
 
98
98
  it "sets up url_encoded request handler" do
99
99
  expect(spy).to have_received(:request).with(:url_encoded)
100
100
  end
101
101
 
102
- it "configures the default adapter" do
102
+ it "cfgures the default adapter" do
103
103
  expect(spy).to have_received(:adapter).with(:net_http)
104
104
  end
105
105
  end
106
106
  end
107
107
 
108
108
  it "caches the result" do
109
- expect(config.connection.object_id).to eq(config.connection.object_id)
109
+ expect(cfg.connection.object_id).to eq(cfg.connection.object_id)
110
110
  end
111
111
  end
112
112
  end
@@ -0,0 +1,247 @@
1
+ require 'rails_helper'
2
+
3
+ require 'tessa/jobs/migrate_assets_job'
4
+
5
+ RSpec.describe Tessa::MigrateAssetsJob do
6
+ it 'does nothing if no db rows' do
7
+
8
+ expect {
9
+ subject.perform
10
+ }.to_not change { ActiveStorage::Attachment.count }
11
+ end
12
+
13
+ it 'creates attachments for old tessa assets' do
14
+ allow(Tessa::Asset).to receive(:find)
15
+ .with(1)
16
+ .and_return(
17
+ Tessa::Asset.new(id: 1,
18
+ meta: { name: 'README.md' },
19
+ private_download_url: 'https://test.com/README.md')
20
+ )
21
+
22
+ allow(Tessa::Asset).to receive(:find)
23
+ .with(2)
24
+ .and_return(
25
+ Tessa::Asset.new(id: 2,
26
+ meta: { name: 'LICENSE.txt' },
27
+ private_download_url: 'https://test.com/LICENSE.txt'),
28
+ )
29
+
30
+ stub_request(:get, 'https://test.com/README.md')
31
+ .to_return(body: File.new('README.md'))
32
+ stub_request(:get, 'https://test.com/LICENSE.txt')
33
+ .to_return(body: File.new('LICENSE.txt'))
34
+
35
+ # DB models with those
36
+ models = [
37
+ SingleAssetModel.create!(avatar_id: 1),
38
+ SingleAssetModel.create!(avatar_id: 2)
39
+ ]
40
+
41
+ expect {
42
+ subject.perform
43
+ }.to change { ActiveStorage::Attachment.count }.by(2)
44
+
45
+ models.each do |m|
46
+ expect(m.reload.avatar_id).to eq(m.avatar.key)
47
+ # Now it's in activestorage
48
+ expect(m.avatar.public_url).to start_with(
49
+ 'https://www.example.com/rails/active_storage/blobs/')
50
+ end
51
+
52
+ expect(SingleAssetModel.where.not('avatar_id' => nil).count)
53
+ .to eq(0)
54
+ end
55
+
56
+ it 'preserves ActiveStorage blobs' do
57
+ allow(Tessa::Asset).to receive(:find)
58
+ .with([1, 2])
59
+ .and_return([
60
+ Tessa::Asset.new(id: 1,
61
+ meta: { name: 'README.md' },
62
+ private_download_url: 'https://test.com/README.md'),
63
+ Tessa::Asset.new(id: 2,
64
+ meta: { name: 'LICENSE.txt' },
65
+ private_download_url: 'https://test.com/LICENSE.txt'),
66
+ ])
67
+ stub_request(:get, 'https://test.com/README.md')
68
+ .to_return(body: File.new('README.md'))
69
+ stub_request(:get, 'https://test.com/LICENSE.txt')
70
+ .to_return(body: File.new('LICENSE.txt'))
71
+
72
+ file2 = Rack::Test::UploadedFile.new("LICENSE.txt")
73
+
74
+ model = MultipleAssetModel.create!(
75
+ # The Tessa DB column has the one asset
76
+ another_place: [1, 2]
77
+ )
78
+ # But has already attached a second ActiveStorage blob
79
+ ::ActiveStorage::Attached::Many.new("multiple_field", model, dependent: :purge_later)
80
+ .attach(file2)
81
+ model.save!
82
+ attachment = model.multiple_field_attachments.first
83
+
84
+ expect {
85
+ subject.perform
86
+ }.to change { ActiveStorage::Attachment.count }.by(2)
87
+
88
+ model = model.reload
89
+ # The IDs are now the keys of ActiveStorage objects
90
+ expect(model.another_place).to eq(
91
+ model.multiple_field.map(&:key))
92
+ # preserves the existing attachment
93
+ expect(model.multiple_field_attachments).to include(attachment)
94
+ expect(model.another_place).to include(attachment.key)
95
+
96
+ # all assets are in activestorage
97
+ model.multiple_field.each do |blob|
98
+ expect(blob.public_url).to start_with(
99
+ 'https://www.example.com/rails/active_storage/blobs/')
100
+ end
101
+
102
+ # DB column is reset to nil
103
+ expect(MultipleAssetModel.where.not('another_place' => nil).count)
104
+ .to eq(0)
105
+ end
106
+
107
+ it 'Stops after hitting the batch size' do
108
+ 1.upto(11).each do |i|
109
+ asset = Tessa::Asset.new(id: i,
110
+ meta: { name: 'README.md' },
111
+ private_download_url: 'https://test.com/README.md')
112
+ allow(Tessa::Asset).to receive(:find)
113
+ .with(i)
114
+ .and_return(asset)
115
+ allow(Tessa::Asset).to receive(:find)
116
+ .with([i])
117
+ .and_return([asset])
118
+ end
119
+ stub_request(:get, 'https://test.com/README.md')
120
+ .to_return(body: File.new('README.md'))
121
+
122
+ # Mix of the two models...
123
+ models =
124
+ 1.upto(11).map do |i|
125
+ if i % 2 == 0
126
+ MultipleAssetModel.create!(another_place: [i])
127
+ else
128
+ SingleAssetModel.create!(avatar_id: i)
129
+ end
130
+ end
131
+
132
+ final_state = nil
133
+ final_options = nil
134
+ dbl = double('set')
135
+ expect(dbl).to receive(:perform_later) do |state, options|
136
+ final_state = Marshal.load(state)
137
+ final_options = options
138
+ end
139
+ expect(Tessa::MigrateAssetsJob).to receive(:set)
140
+ .with(wait: 10.minutes)
141
+ .and_return(dbl)
142
+
143
+ expect {
144
+ subject.perform
145
+ }.to change { ActiveStorage::Attachment.count }.by(10)
146
+
147
+ expect(final_state.fully_processed?).to be false
148
+ expect(final_options).to eq({
149
+ batch_size: 10,
150
+ interval: 10.minutes.to_i
151
+ })
152
+ # One of the two models was fully processed
153
+ expect(final_state.model_queue.count(&:fully_processed?))
154
+ .to eq(1)
155
+ end
156
+
157
+ it 'Skips over Tessa errors' do
158
+ 1.upto(11).each do |i|
159
+ if i % 2 == 0
160
+ allow(Tessa::Asset).to receive(:find)
161
+ .with(i)
162
+ .and_raise(Tessa::RequestFailed)
163
+ next
164
+ end
165
+
166
+ allow(Tessa::Asset).to receive(:find)
167
+ .with(i)
168
+ .and_return(
169
+ Tessa::Asset.new(id: i,
170
+ meta: { name: 'README.md' },
171
+ private_download_url: 'https://test.com/README.md')
172
+ )
173
+ end
174
+ stub_request(:get, 'https://test.com/README.md')
175
+ .to_return(body: File.new('README.md'))
176
+
177
+ # Mix of the two models...
178
+ models =
179
+ 1.upto(11).map do |i|
180
+ SingleAssetModel.create!(avatar_id: i)
181
+ end
182
+
183
+ final_state = nil
184
+ final_options = nil
185
+ dbl = double('set')
186
+ expect(dbl).to receive(:perform_later) do |state, options|
187
+ final_state = Marshal.load(state)
188
+ final_options = options
189
+ end
190
+ expect(Tessa::MigrateAssetsJob).to receive(:set)
191
+ .with(wait: 10.minutes)
192
+ .and_return(dbl)
193
+
194
+ expect {
195
+ subject.perform
196
+ }.to change { ActiveStorage::Attachment.count }.by(5)
197
+
198
+ expect(final_state.fully_processed?).to be false
199
+ field_state = final_state.next_model.next_field
200
+ expect(field_state.offset).to eq(5)
201
+ end
202
+
203
+ it 'Resumes from marshalled state' do
204
+
205
+ file = Rack::Test::UploadedFile.new('README.md')
206
+
207
+ state = Tessa::MigrateAssetsJob::ProcessingState.initialize_from_models(
208
+ [SingleAssetModel])
209
+ field_state = state.model_queue
210
+ .detect { |m| m.class_name == 'SingleAssetModel' }
211
+ .field_queue
212
+ .detect { |m| m.field_name == :avatar }
213
+
214
+ 1.upto(10).each do |i|
215
+ if i % 2 == 0
216
+ # This one failed
217
+ SingleAssetModel.create!(avatar_id: i).tap do |r|
218
+ field_state.offset += 1
219
+ end
220
+ else
221
+ # This one succeeded and is in ActiveStorage
222
+ SingleAssetModel.create!(avatar: file).tap do |r|
223
+ field_state.success_count += 1
224
+ end
225
+ end
226
+ end
227
+
228
+ # This one still needs to transition
229
+ model = SingleAssetModel.create!(avatar_id: 11)
230
+
231
+ asset = Tessa::Asset.new(id: 11,
232
+ meta: { name: 'README.md' },
233
+ private_download_url: 'https://test.com/README.md')
234
+ allow(Tessa::Asset).to receive(:find)
235
+ .with(11)
236
+ .and_return(asset)
237
+ stub_request(:get, 'https://test.com/README.md')
238
+ .to_return(body: File.new('README.md'))
239
+
240
+ # Doesn't reenqueue since we finished processing
241
+ expect(Tessa::MigrateAssetsJob).to_not receive(:set)
242
+
243
+ expect {
244
+ subject.perform(Marshal.dump(state), { batch_size: 2, interval: 3 })
245
+ }.to change { ActiveStorage::Attachment.count }.by(1)
246
+ end
247
+ end
@@ -41,6 +41,7 @@ RSpec.describe Tessa::Model do
41
41
  let(:model) {
42
42
  MultipleAssetModel
43
43
  }
44
+ let(:instance) { model.new(another_place: []) }
44
45
 
45
46
  it "sets all attributes on ModelField properly" do
46
47
  field = model.tessa_fields[:multiple_field]
@@ -89,6 +90,7 @@ RSpec.describe Tessa::Model do
89
90
  let(:model) {
90
91
  MultipleAssetModel
91
92
  }
93
+ let(:instance) { model.new(another_place: []) }
92
94
  subject(:getter) { instance.multiple_field }
93
95
 
94
96
  it "calls find for each of the file_ids and returns result" do
@@ -181,9 +183,11 @@ RSpec.describe Tessa::Model do
181
183
  SingleAssetModel
182
184
  }
183
185
  subject(:getter) { instance.avatar }
186
+ let(:file) {
187
+ Rack::Test::UploadedFile.new("README.md")
188
+ }
184
189
 
185
190
  it 'attaches uploaded file' do
186
- file = Rack::Test::UploadedFile.new("README.md")
187
191
  instance.avatar = file
188
192
 
189
193
  expect(getter.name).to eq('avatar')
@@ -194,28 +198,65 @@ RSpec.describe Tessa::Model do
194
198
  end
195
199
 
196
200
  it 'sets the ID to be the ActiveStorage key' do
197
- file = Rack::Test::UploadedFile.new("README.md")
198
201
  instance.avatar = file
199
202
 
200
203
  expect(instance.avatar_id).to eq(instance.avatar_attachment.key)
201
204
  end
202
205
 
203
206
  it 'sets the ID in the attributes' do
204
- file = Rack::Test::UploadedFile.new("README.md")
205
207
  instance.avatar = file
206
208
 
207
209
  expect(instance.attributes['avatar_id']).to eq(instance.avatar_attachment.key)
208
210
  end
211
+
212
+ it 'attaches signed ID from Tessa::AssetChangeSet' do
213
+ blob = ::ActiveStorage::Blob.create_before_direct_upload!({
214
+ filename: 'README.md',
215
+ byte_size: file.size,
216
+ content_type: file.content_type,
217
+ checksum: '1234'
218
+ })
219
+
220
+ changeset = Tessa::AssetChangeSet.new(
221
+ changes: [{ 'id' => blob.signed_id, 'action' => 'add' }]
222
+ )
223
+ instance.avatar = changeset
224
+
225
+ expect(instance.avatar_id).to eq(instance.avatar_attachment.key)
226
+ end
227
+
228
+ it 'does nothing when "add"ing an existing blob' do
229
+ # Before this HTTP POST, we've previously uploaded this file
230
+ instance.avatar = file
231
+
232
+ # In this HTTP POST, we re-upload the 'add' action with the same ID
233
+ changeset = Tessa::AssetChangeSet.new(
234
+ changes: [{ 'id' => instance.avatar_attachment.key, 'action' => 'add' }]
235
+ )
236
+
237
+ # We expect that we're not going to detatch the existing attachment
238
+ expect(instance.avatar_attachment).to_not receive(:destroy)
239
+
240
+ # act
241
+ instance.avatar = changeset
242
+
243
+ expect(instance.avatar_id).to eq(instance.avatar_attachment.key)
244
+ end
209
245
  end
210
246
 
211
247
  context "with a multiple typed field" do
212
248
  let(:model) {
213
249
  MultipleAssetModel
214
250
  }
251
+ let(:instance) { model.new(another_place: []) }
252
+ let(:file) {
253
+ Rack::Test::UploadedFile.new("README.md")
254
+ }
255
+ let(:file2) {
256
+ Rack::Test::UploadedFile.new("LICENSE.txt")
257
+ }
215
258
 
216
259
  it 'attaches uploaded files' do
217
- file = Rack::Test::UploadedFile.new("README.md")
218
- file2 = Rack::Test::UploadedFile.new("LICENSE.txt")
219
260
  instance.multiple_field = [file, file2]
220
261
 
221
262
  expect(instance.multiple_field[0].name).to eq('multiple_field')
@@ -231,20 +272,119 @@ RSpec.describe Tessa::Model do
231
272
  end
232
273
 
233
274
  it 'sets the ID to be the ActiveStorage key' do
234
- file = Rack::Test::UploadedFile.new("README.md")
235
- file2 = Rack::Test::UploadedFile.new("LICENSE.txt")
236
275
  instance.multiple_field = [file, file2]
237
276
 
238
277
  expect(instance.another_place).to eq(instance.multiple_field_attachments.map(&:key))
239
278
  end
240
279
 
241
280
  it 'sets the ID in the attributes' do
242
- file = Rack::Test::UploadedFile.new("README.md")
243
- file2 = Rack::Test::UploadedFile.new("LICENSE.txt")
244
281
  instance.multiple_field = [file, file2]
245
282
 
246
283
  expect(instance.attributes['another_place']).to eq(instance.multiple_field_attachments.map(&:key))
247
284
  end
285
+
286
+ it 'attaches signed ID from Tessa::AssetChangeSet' do
287
+ blob = ::ActiveStorage::Blob.create_before_direct_upload!({
288
+ filename: 'README.md',
289
+ byte_size: file.size,
290
+ content_type: file.content_type,
291
+ checksum: '1234'
292
+ })
293
+ blob2 = ::ActiveStorage::Blob.create_before_direct_upload!({
294
+ filename: "LICENSE.txt",
295
+ byte_size: file2.size,
296
+ content_type: file2.content_type,
297
+ checksum: '5678'
298
+ })
299
+
300
+ changeset = Tessa::AssetChangeSet.new(
301
+ changes: [
302
+ { 'id' => blob.signed_id, 'action' => 'add' },
303
+ { 'id' => blob2.signed_id, 'action' => 'add' },
304
+ ]
305
+ )
306
+ instance.multiple_field = changeset
307
+
308
+ expect(instance.another_place).to eq([
309
+ blob.key,
310
+ blob2.key
311
+ ])
312
+ end
313
+
314
+ it 'does nothing when "add"ing an existing blob' do
315
+ # Before this HTTP POST, we've previously uploaded these files
316
+ instance.multiple_field = [file, file2]
317
+ keys = instance.multiple_field_attachments.map(&:key)
318
+
319
+ # In this HTTP POST, we re-upload the 'add' action with the same ID
320
+ changeset = Tessa::AssetChangeSet.new(
321
+ changes: [
322
+ { 'id' => keys[0], 'action' => 'add' },
323
+ { 'id' => keys[1], 'action' => 'add' },
324
+ ]
325
+ )
326
+
327
+ # We expect that we're not going to detatch the existing attachment
328
+ instance.multiple_field_attachments.each do |a|
329
+ expect(a).to_not receive(:destroy)
330
+ end
331
+
332
+ # act
333
+ instance.multiple_field = changeset
334
+
335
+ expect(instance.another_place).to eq(keys)
336
+ end
337
+
338
+ it 'replaces Tessa assets with ActiveStorage assets' do
339
+ # Before deploying this code, we previously had DB records with Tessa IDs
340
+ instance.update!(another_place: [1, 2, 3])
341
+
342
+ # In this HTTP POST, we removed one of the tessa assets and uploaded a
343
+ # new ActiveStorage asset
344
+ blob = ::ActiveStorage::Blob.create_before_direct_upload!({
345
+ filename: 'README.md',
346
+ byte_size: file.size,
347
+ content_type: file.content_type,
348
+ checksum: '1234'
349
+ })
350
+ changeset = Tessa::AssetChangeSet.new(
351
+ changes: [
352
+ { 'id' => 1, 'action' => 'add' },
353
+ { 'id' => 2, 'action' => 'remove' },
354
+ { 'id' => 3, 'action' => 'add' },
355
+ { 'id' => blob.signed_id, 'action' => 'add' },
356
+ ]
357
+ )
358
+
359
+ # We'll download these assets when we access #multiple_field
360
+ allow(Tessa.config.connection).to receive(:get)
361
+ .with("/assets/1,3")
362
+ .and_return(double("response",
363
+ success?: true,
364
+ body: [
365
+ { 'id' => 1, 'public_url' => 'test1' },
366
+ { 'id' => 2, 'public_url' => 'test2' }
367
+ ].to_json))
368
+
369
+ blob.upload(file)
370
+
371
+ # act
372
+ instance.multiple_field = changeset
373
+
374
+ expect(instance.another_place).to eq([
375
+ blob.key, 1, 3
376
+ ])
377
+
378
+ assets = instance.multiple_field
379
+ expect(assets[0].key).to eq(blob.key)
380
+ expect(assets[0].service_url)
381
+ .to start_with('https://www.example.com/rails/active_storage/disk/')
382
+
383
+ expect(assets[1].id).to eq(1)
384
+ expect(assets[1].public_url).to eq('test1')
385
+ expect(assets[2].id).to eq(2)
386
+ expect(assets[2].public_url).to eq('test2')
387
+ end
248
388
  end
249
389
  end
250
390
 
@@ -385,6 +525,7 @@ RSpec.describe Tessa::Model do
385
525
  let(:model) {
386
526
  MultipleAssetModel
387
527
  }
528
+ let(:instance) { model.new(another_place: []) }
388
529
 
389
530
  before do
390
531
  instance.another_place = [2, 3]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tessa
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Powell
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2022-06-15 00:00:00.000000000 Z
12
+ date: 2022-06-27 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: faraday
@@ -151,6 +151,7 @@ files:
151
151
  - app/javascript/tessa/index.js.coffee
152
152
  - bin/rspec
153
153
  - config/routes.rb
154
+ - lib/tasks/tessa.rake
154
155
  - lib/tessa.rb
155
156
  - lib/tessa/active_storage/asset_wrapper.rb
156
157
  - lib/tessa/asset.rb
@@ -160,6 +161,7 @@ files:
160
161
  - lib/tessa/config.rb
161
162
  - lib/tessa/controller_helpers.rb
162
163
  - lib/tessa/engine.rb
164
+ - lib/tessa/jobs/migrate_assets_job.rb
163
165
  - lib/tessa/model.rb
164
166
  - lib/tessa/model/dynamic_extensions.rb
165
167
  - lib/tessa/model/field.rb
@@ -240,6 +242,7 @@ files:
240
242
  - spec/tessa/asset_spec.rb
241
243
  - spec/tessa/config_spec.rb
242
244
  - spec/tessa/controller_helpers_spec.rb
245
+ - spec/tessa/jobs/migrate_assets_job_spec.rb
243
246
  - spec/tessa/model_field_spec.rb
244
247
  - spec/tessa/model_spec.rb
245
248
  - spec/tessa/rack_upload_proxy_spec.rb
@@ -340,6 +343,7 @@ test_files:
340
343
  - spec/tessa/asset_spec.rb
341
344
  - spec/tessa/config_spec.rb
342
345
  - spec/tessa/controller_helpers_spec.rb
346
+ - spec/tessa/jobs/migrate_assets_job_spec.rb
343
347
  - spec/tessa/model_field_spec.rb
344
348
  - spec/tessa/model_spec.rb
345
349
  - spec/tessa/rack_upload_proxy_spec.rb