tessa 1.0.0 → 1.1.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 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