Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
An Active Storage service upload/download plugin that stores files in a PostgreSQL or MySQL database.

Main features:
- supports Rails 6.0, 6.1 and 7.0;
- supports Rails _6.0_, _6.1_ and _7.0_;
- all service methods implemented;
- attachment data stored in a binary field (or blob).

Expand All @@ -30,10 +30,16 @@ db:

## Misc

Some rake tasks are available:
Some utility tasks are available:

- `asdb:list`: list the stored attachments
- `asdb:get`: download an attachment (ex. `bin/rails "asdb:get[ruby-logo.png,/tmp]"`)
```sh
# list attachments ordered by blob id desc (with limit 100):
bin/rails 'asdb:list'
# search attachments by filename (or part of it)
bin/rails 'asdb:search[some_filename]'
# download attachment by blob id (retrieved with list or search tasks) - the second argument is the destination:
bin/rails 'asdb:download[123,/tmp]'
```

## Do you like it? Star it!

Expand Down
71 changes: 52 additions & 19 deletions lib/tasks/active_storage_db_tasks.rake
Original file line number Diff line number Diff line change
@@ -1,31 +1,64 @@
# frozen_string_literal: true

module ActiveStorage
module Tasks
module_function

def print_blob_header(digits: 0)
puts ['Size'.rjust(8), 'Date'.rjust(18), 'Id'.rjust(digits + 2), ' Filename'].join
end

def print_blob(blob, digits: 0)
size = (blob.byte_size / 1024).to_s.rjust(7)
date = blob.created_at.strftime('%Y-%m-%d %H:%M')
puts "#{size}K #{date} #{blob.id.to_s.rjust(digits)} #{blob.filename}"
end
end
end

namespace :asdb do
desc 'ActiveStorageDB: list attachments'
desc 'ActiveStorageDB: list attachments ordered by blob id desc'
task list: [:environment] do |_t, _args|
::ActiveStorage::Blob.order(:filename).pluck(:byte_size, :created_at, :filename).each do |size, dt, filename|
size_k = (size / 1024).to_s.rjust(7)
date = dt.strftime('%Y-%m-%d %H:%M')
puts "#{size_k}K #{date} #{filename}"
query = ::ActiveStorage::Blob.order(id: :desc).limit(100)
digits = Math.log(query.maximum(:id), 10).to_i + 1

::ActiveStorage::Tasks.print_blob_header(digits: digits)
query.each do |blob|
::ActiveStorage::Tasks.print_blob(blob, digits: digits)
end
end

desc 'ActiveStorageDB: download attachment'
task :get, [:src, :dst] => [:environment] do |_t, args|
src = args[:src]&.strip
dst = args[:dst]&.strip
abort('Required arguments: source file, destination file') if src.blank? || dst.blank?

dst = "#{dst}/#{src}" if Dir.exist?(dst)
dir = File.dirname(dst)
abort("Can't write on: #{dir}") unless File.writable?(dir)
desc 'ActiveStorageDB: download attachment by blob id'
task :download, [:blob_id, :destination] => [:environment] do |_t, args|
blob_id = args[:blob_id]&.strip
destination = args[:destination]&.strip || Dir.pwd
abort('Required arguments: source blob id, destination path') if blob_id.blank? || destination.blank?

blob = ::ActiveStorage::Blob.order(created_at: :desc).find_by(filename: src)
blob = ::ActiveStorage::Blob.find_by(id: blob_id)
abort('Source file not found') unless blob

ret = File.binwrite(dst, blob.download)
puts "#{ret} bytes written"
rescue StandardError => e
puts e
destination = "#{destination}/#{blob.filename}" if Dir.exist?(destination)
dir = File.dirname(destination)
abort("Can't write on path: #{dir}") unless File.writable?(dir)

ret = File.binwrite(destination, blob.download)
puts "#{ret} bytes written - #{destination}"
end

desc 'ActiveStorageDB: search attachment by filename (or part of it)'
task :search, [:filename] => [:environment] do |_t, args|
filename = args[:filename]&.strip
abort('Required arguments: filename') if filename.blank?

blobs = ::ActiveStorage::Blob.where('filename LIKE ?', "%#{filename}%").order(id: :desc)
if blobs.any?
digits = Math.log(blobs.first.id, 10).to_i + 1
::ActiveStorage::Tasks.print_blob_header(digits: digits)
blobs.each do |blob|
::ActiveStorage::Tasks.print_blob(blob, digits: digits)
end
else
puts 'No results'
end
end
end
90 changes: 58 additions & 32 deletions spec/tasks/active_storage_db_tasks_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,45 +6,52 @@
describe 'asdb:list' do
subject(:task) { execute_task('asdb:list') }

let(:file1) { create(:active_storage_blob, filename: 'file1', created_at: Time.now - 1.hour) }
let(:file2) { create(:active_storage_blob, filename: 'file2', created_at: Time.now - 5.hour) }
let(:file3) { create(:active_storage_blob, filename: 'file3', created_at: Time.now - 3.hour) }
let(:file1) { create(:active_storage_blob, filename: 'some file 1', created_at: Time.now - 1.hour) }
let(:file2) { create(:active_storage_blob, filename: 'some file 2', created_at: Time.now - 5.hour) }
let(:file3) { create(:active_storage_blob, filename: 'some file 3', created_at: Time.now - 3.hour) }

before do
[file1, file2, file3]
end

it { is_expected.to match /file1.+file2.+file3/m }

it 'prints the list of 3 files' do
expect(task.split("\n").size).to be 3
it 'prints the columns header + the list of 3 files', :aggregate_failures do
pattern = /#{file3.id} #{file3.filename}.+#{file2.id} #{file2.filename}.+#{file1.id} #{file1.filename}/m
expect(task).to match pattern
expect(task.split("\n").size).to be 4
end
end

describe 'asdb:get' do
subject(:task) { execute_task('asdb:get') }
describe 'asdb:download' do
subject(:task) { execute_task('asdb:download') }

it 'exits showing the required arguments' do
with_captured_stderr do
expect { task }.to raise_exception(SystemExit, /Required arguments/)
expect { task }.to raise_exception(SystemExit, /Required arguments: source blob id, destination path/)
end
end

context 'with only the source specified' do
subject(:task) { execute_task('asdb:get', src: 'some_file') }
context 'with a missing source' do
subject(:task) { execute_task('asdb:download', blob_id: 'some_file') }

before do
allow(File).to receive(:writable?).and_return(true)
end

it 'exits showing the required arguments' do
it 'exits showing a not found error' do
with_captured_stderr do
expect { task }.to raise_exception(SystemExit, /Required arguments/)
expect { task }.to raise_exception(SystemExit, /Source file not found/)
end
end
end

context 'with an invalid destination' do
subject(:task) { execute_task('asdb:get', src: 'some_file', dst: 'some_path') }
subject(:task) { execute_task('asdb:download', blob_id: 'some_file', destination: 'some_path') }

let(:blob) { build_stubbed(:active_storage_blob) }

before do
allow(File).to receive(:writable?).and_return(false)
allow(ActiveStorage::Blob).to receive(:find_by).and_return(blob)
end

it 'exits showing a write error' do
Expand All @@ -54,35 +61,54 @@
end
end

context 'with a missing source' do
subject(:task) { execute_task('asdb:get', src: 'some_file', dst: 'some_path') }
context 'with valid arguments' do
subject(:task) { execute_task('asdb:download', blob_id: 'some_file', destination: 'some_path') }

let(:blob) { build_stubbed(:active_storage_blob) }

before do
allow(File).to receive(:writable?).and_return(true)
allow(File).to receive_messages(binwrite: 1000, writable?: true)
allow(ActiveStorage::Blob).to receive(:find_by).and_return(blob)
allow(blob).to receive(:download).and_return('some data')
end

it 'exits showing a not found error' do
with_captured_stderr do
expect { task }.to raise_exception(SystemExit, /Source file not found/)
end
it 'prints the number of bytes written' do
expect(task).to eq "1000 bytes written - some_path\n"
end
end
end

context 'with valid arguments' do
subject(:task) { execute_task('asdb:get', src: blob.filename.to_s, dst: 'some_path') }
describe 'asdb:search' do
subject(:task) { execute_task('asdb:search') }

let(:blob) { build_stubbed(:active_storage_blob) }
let(:blobs) { instance_double(ActiveRecord::Relation) }
let(:blob) { build_stubbed(:active_storage_blob) }

it 'exits showing the required arguments' do
with_captured_stderr do
expect { task }.to raise_exception(SystemExit, /Required arguments/)
end
end

context 'when no files are found' do
subject(:task) { execute_task('asdb:search', filename: 'just ') }

it 'prints "No results" message' do
expect(task).to eq "No results\n"
end
end

context 'with there are some results' do
subject(:task) { execute_task('asdb:search', filename: 'just ') }

before do
allow(File).to receive_messages(binwrite: 1000, writable?: true)
allow(ActiveStorage::Blob).to receive(:order).and_return(blobs)
allow(blobs).to receive(:find_by).and_return(blob)
allow(blob).to receive(:download).and_return('some data')
create(:active_storage_blob, filename: 'just a file', created_at: Time.now - 1.hour)
create(:active_storage_blob, filename: 'just another file', created_at: Time.now - 5.hour)
create(:active_storage_blob, filename: 'the last file', created_at: Time.now - 3.hour)
end

it 'prints the number of bytes written' do
expect(task).to eq "1000 bytes written\n"
it 'prints the files that matches', :aggregate_failures do
expect(task).to match /just another file.+just a file/m
expect(task.split("\n").size).to be 3
end
end
end
Expand Down