├── test ├── helpers │ └── .keep ├── mailers │ └── .keep ├── models │ └── .keep ├── controllers │ └── .keep ├── dummy │ ├── log │ │ └── .keep │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── public │ │ ├── favicon.ico │ │ ├── apple-touch-icon.png │ │ ├── apple-touch-icon-precomposed.png │ │ ├── 500.html │ │ ├── 422.html │ │ └── 404.html │ ├── app │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ ├── config │ │ │ │ └── manifest.js │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── models │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── user.rb │ │ │ └── application_record.rb │ │ ├── controllers │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ └── application_controller.rb │ │ ├── views │ │ │ └── layouts │ │ │ │ ├── mailer.text.erb │ │ │ │ ├── mailer.html.erb │ │ │ │ └── application.html.erb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── channels │ │ │ └── application_cable │ │ │ │ ├── channel.rb │ │ │ │ └── connection.rb │ │ ├── mailers │ │ │ └── application_mailer.rb │ │ └── jobs │ │ │ └── application_job.rb │ ├── config │ │ ├── routes.rb │ │ ├── environment.rb │ │ ├── cable.yml │ │ ├── boot.rb │ │ ├── initializers │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── permissions_policy.rb │ │ │ ├── assets.rb │ │ │ ├── inflections.rb │ │ │ └── content_security_policy.rb │ │ ├── database.yml │ │ ├── application.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── storage.yml │ │ ├── puma.rb │ │ └── environments │ │ │ ├── test.rb │ │ │ ├── development.rb │ │ │ └── production.rb │ ├── bin │ │ ├── rake │ │ ├── rails │ │ └── setup │ ├── test │ │ ├── models │ │ │ └── user_test.rb │ │ └── fixtures │ │ │ └── users.yml │ ├── config.ru │ ├── Rakefile │ └── db │ │ ├── migrate │ │ └── 20221008115344_create_users.rb │ │ └── schema.rb ├── integration │ ├── .keep │ └── navigation_test.rb ├── fixtures │ └── files │ │ └── .keep ├── test_helper.rb └── art_vandelay_test.rb ├── app ├── models │ ├── concerns │ │ └── .keep │ └── art_vandelay │ │ └── application_record.rb ├── controllers │ ├── concerns │ │ └── .keep │ └── art_vandelay │ │ └── application_controller.rb ├── assets │ ├── images │ │ └── art_vandelay │ │ │ └── .keep │ ├── config │ │ └── art_vandelay_manifest.js │ └── stylesheets │ │ └── art_vandelay │ │ └── application.css ├── helpers │ └── art_vandelay │ │ └── application_helper.rb ├── jobs │ └── art_vandelay │ │ └── application_job.rb ├── mailers │ └── art_vandelay │ │ └── application_mailer.rb └── views │ └── layouts │ └── art_vandelay │ └── application.html.erb ├── .tool-versions ├── config └── routes.rb ├── .standard.yml ├── lib ├── art_vandelay │ ├── engine.rb │ └── version.rb ├── tasks │ └── art_vandelay_tasks.rake └── art_vandelay.rb ├── CODE_OF_CONDUCT.md ├── Rakefile ├── .gitignore ├── CODEOWNERS ├── .github └── workflows │ ├── dynamic-security.yml │ └── ci.yml ├── bin ├── ci ├── setup └── rails ├── SECURITY.md ├── Gemfile ├── art_vandelay.gemspec ├── MIT-LICENSE ├── NEWS.md └── README.md /test/helpers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/controllers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/files/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.3.4 2 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/art_vandelay/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | ArtVandelay::Engine.routes.draw do 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - 'test/dummy/**/*' 3 | - 'db/migrate/*' 4 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/assets/config/art_vandelay_manifest.js: -------------------------------------------------------------------------------- 1 | //= link_directory ../stylesheets/art_vandelay .css 2 | -------------------------------------------------------------------------------- /app/helpers/art_vandelay/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ArtVandelay 2 | module ApplicationHelper 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | validates :password, presence: true 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | mount ArtVandelay::Engine => "/art_vandelay" 3 | end 4 | -------------------------------------------------------------------------------- /app/jobs/art_vandelay/application_job.rb: -------------------------------------------------------------------------------- 1 | module ArtVandelay 2 | class ApplicationJob < ActiveJob::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /lib/art_vandelay/engine.rb: -------------------------------------------------------------------------------- 1 | module ArtVandelay 2 | class Engine < ::Rails::Engine 3 | isolate_namespace ArtVandelay 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/tasks/art_vandelay_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :art_vandelay do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link art_vandelay_manifest.js 4 | -------------------------------------------------------------------------------- /app/controllers/art_vandelay/application_controller.rb: -------------------------------------------------------------------------------- 1 | module ArtVandelay 2 | class ApplicationController < ActionController::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /app/models/art_vandelay/application_record.rb: -------------------------------------------------------------------------------- 1 | module ArtVandelay 2 | class ApplicationRecord < ActiveRecord::Base 3 | self.abstract_class = true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/art_vandelay/version.rb: -------------------------------------------------------------------------------- 1 | module ArtVandelay 2 | VERSION = "0.2.0".freeze 3 | RAILS_VERSION = ">= 7.0".freeze 4 | MINIMUM_RUBY_VERSION = ">= 3.1".freeze 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /test/dummy/test/models/user_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class UserTest < ActiveSupport::TestCase 4 | # test "the truth" do 5 | # assert true 6 | # end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/test/fixtures/users.yml: -------------------------------------------------------------------------------- 1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html 2 | 3 | one: 4 | email: user@example.com 5 | password: Password 6 | -------------------------------------------------------------------------------- /test/integration/navigation_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class NavigationTest < ActionDispatch::IntegrationTest 4 | # test "the truth" do 5 | # assert true 6 | # end 7 | end 8 | -------------------------------------------------------------------------------- /app/mailers/art_vandelay/application_mailer.rb: -------------------------------------------------------------------------------- 1 | module ArtVandelay 2 | class ApplicationMailer < ActionMailer::Base 3 | default from: "from@example.com" 4 | layout "mailer" 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of conduct 2 | 3 | By participating in this project, you agree to abide by the 4 | [thoughtbot code of conduct][1]. 5 | 6 | [1]: https://thoughtbot.com/open-source-code-of-conduct 7 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | if Rails::VERSION::MAJOR < 7 3 | self.abstract_class = true 4 | else 5 | primary_abstract_class 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) 4 | load "rails/tasks/engine.rake" 5 | 6 | load "rails/tasks/statistics.rake" 7 | 8 | require "bundler/gem_tasks" 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /doc/ 3 | /log/*.log 4 | /pkg/ 5 | /tmp/ 6 | /test/dummy/db/*.sqlite3 7 | /test/dummy/db/*.sqlite3-* 8 | /test/dummy/log/*.log 9 | /test/dummy/storage/ 10 | /test/dummy/tmp/ 11 | Gemfile.lock 12 | -------------------------------------------------------------------------------- /test/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 6 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # More details are here: https://help.github.com/articles/about-codeowners/ 5 | 6 | # The '*' pattern is global owners. 7 | 8 | # Global rule: 9 | * @stevepolitodesign 10 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/views/layouts/art_vandelay/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Art vandelay 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | 8 | <%= stylesheet_link_tag "art_vandelay/application", media: "all" %> 9 | 10 | 11 | 12 | <%= yield %> 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | 6 | <%= csrf_meta_tags %> 7 | <%= csp_meta_tag %> 8 | 9 | <%= stylesheet_link_tag "application" %> 10 | 11 | 12 | 13 | <%= yield %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20221008115344_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration["#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}"] 2 | def change 3 | create_table :users do |t| 4 | t.string :email, null: false 5 | t.string :password, null: false 6 | 7 | t.timestamps 8 | end 9 | 10 | add_index :users, :email, unique: true 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be filtered from the log file. Use this to limit dissemination of 4 | # sensitive information. See the ActiveSupport::ParameterFilter documentation for supported 5 | # notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 8 | ] 9 | -------------------------------------------------------------------------------- /.github/workflows/dynamic-security.yml: -------------------------------------------------------------------------------- 1 | name: update-security 2 | 3 | on: 4 | push: 5 | paths: 6 | - SECURITY.md 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | jobs: 12 | update-security: 13 | permissions: 14 | contents: write 15 | pull-requests: write 16 | pages: write 17 | uses: thoughtbot/templates/.github/workflows/dynamic-security.yaml@main 18 | secrets: 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /bin/ci: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | green() { printf "\033[32m${1}\033[0m\n"; } 4 | red() { printf "\033[31m${1}\033[0m\n"; } 5 | 6 | set -e 7 | 8 | green "[bin/ci] Running linters..." 9 | if bundle exec standardrb 10 | then 11 | green "[bin/ci] Running test suite..." 12 | if bundle exec rails t --fail-fast 13 | then 14 | green "[bin/ci] CI Passed 🎉" 15 | else 16 | red "[bin/ci] Test suite failed. Exiting." 17 | exit 1 18 | fi 19 | else 20 | red "[bin/ci] There were linting errors. Exiting." 21 | exit 1 22 | fi 23 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | echo "[bin/setup] Installing gems" 6 | rm -f Gemfile.lock 7 | bundle install 8 | 9 | echo "[bin/setup] Dropping and recreating the database" 10 | bundle exec rails db:reset || bundle exec rails db:migrate 11 | 12 | echo 13 | echo "[bin/setup] Setup complete." 14 | echo 15 | echo "[bin/setup] If you want to build against a different version of Rails run 16 | the following before running the setup script." 17 | echo 18 | echo "export RAILS_VERSION=MAJOR.MINOR (e.g. export RAILS_VERSION=6.1)" 19 | echo 20 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in the app/assets 11 | # folder are already added. 12 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 13 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails gems 3 | # installed from the root of your application. 4 | 5 | ENGINE_ROOT = File.expand_path("..", __dir__) 6 | ENGINE_PATH = File.expand_path("../lib/art_vandelay/engine", __dir__) 7 | APP_PATH = File.expand_path("../test/dummy/config/application", __dir__) 8 | 9 | # Set up gems listed in the Gemfile. 10 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 11 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 12 | 13 | require "rails/all" 14 | require "rails/engine/commands" 15 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | # Security Policy 3 | 4 | ## Supported Versions 5 | 6 | Only the the latest version of this project is supported at a given time. If 7 | you find a security issue with an older version, please try updating to the 8 | latest version first. 9 | 10 | If for some reason you can't update to the latest version, please let us know 11 | your reasons so that we can have a better understanding of your situation. 12 | 13 | ## Reporting a Vulnerability 14 | 15 | For security inquiries or vulnerability reports, visit 16 | . 17 | 18 | If you have any suggestions to improve this policy, visit . 19 | 20 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | # Specify your gem's dependencies in art_vandelay.gemspec. 5 | gemspec 6 | 7 | # Start debugger with binding.b [https://github.com/ruby/debug] 8 | # gem "debug", ">= 1.0.0" 9 | # 10 | rails_version = ENV.fetch("RAILS_VERSION", "7.0") 11 | 12 | rails_constraint = if rails_version == "main" 13 | {github: "rails/rails"} 14 | else 15 | "~> #{rails_version}.0" 16 | end 17 | 18 | if rails_version.start_with? "6" 19 | gem "net-imap", require: false 20 | gem "net-pop", require: false 21 | gem "net-smtp", require: false 22 | gem "psych", "< 4" 23 | end 24 | gem "rails", rails_constraint 25 | gem "sprockets-rails" 26 | gem "sqlite3", "~> 1.4" 27 | gem "standardrb" 28 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Configure Rails Environment 2 | ENV["RAILS_ENV"] = "test" 3 | 4 | require_relative "../test/dummy/config/environment" 5 | ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)] 6 | ActiveRecord::Migrator.migrations_paths << File.expand_path("../db/migrate", __dir__) 7 | require "rails/test_help" 8 | 9 | # Load fixtures from the engine 10 | if ActiveSupport::TestCase.respond_to?(:fixture_path=) 11 | ActiveSupport::TestCase.fixture_path = File.expand_path("fixtures", __dir__) 12 | ActionDispatch::IntegrationTest.fixture_path = ActiveSupport::TestCase.fixture_path 13 | ActiveSupport::TestCase.file_fixture_path = ActiveSupport::TestCase.fixture_path + "/files" 14 | ActiveSupport::TestCase.fixtures :all 15 | end 16 | -------------------------------------------------------------------------------- /app/assets/stylesheets/art_vandelay/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | require "art_vandelay" 9 | 10 | module Dummy 11 | class Application < Rails::Application 12 | config.load_defaults Rails::VERSION::STRING.to_f 13 | 14 | # For compatibility with applications that use this config 15 | config.action_controller.include_all_helpers = false 16 | 17 | # Configuration for the application, engines, and railties goes here. 18 | # 19 | # These settings can be overridden in specific environments using the files 20 | # in config/environments, which are processed later. 21 | # 22 | # config.time_zone = "Central Time (US & Canada)" 23 | # config.eager_load_paths << Rails.root.join("extras") 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # "true": "foo" 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /art_vandelay.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/art_vandelay/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "art_vandelay" 5 | spec.version = ArtVandelay::VERSION 6 | spec.required_ruby_version = ArtVandelay::MINIMUM_RUBY_VERSION 7 | spec.authors = ["Steve Polito"] 8 | spec.email = ["stevepolito@hey.com"] 9 | spec.homepage = "https://github.com/thoughtbot/art_vandelay" 10 | spec.summary = "Art Vandelay is an importer/exporter for Rails" 11 | spec.description = "Art Vandelay is an importer/exporter for Rails" 12 | spec.license = "MIT" 13 | 14 | spec.metadata["homepage_uri"] = spec.homepage 15 | spec.metadata["source_code_uri"] = "https://github.com/thoughtbot/art_vandelay" 16 | spec.metadata["changelog_uri"] = "https://github.com/thoughtbot/art_vandelay/NEWS.md" 17 | 18 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 19 | Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 20 | end 21 | 22 | spec.add_dependency "rails", ArtVandelay::RAILS_VERSION 23 | end 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [pull_request] 3 | 4 | jobs: 5 | test: 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | ruby-version: 10 | - "3.1" 11 | - "3.2" 12 | - "3.3" 13 | rails-version: 14 | - "7.0" 15 | - "7.1" 16 | - "main" 17 | 18 | env: 19 | RAILS_VERSION: ${{ matrix.rails-version }} 20 | 21 | name: ${{ format('Tests (Ruby {0}, Rails {1})', matrix.ruby-version, matrix.rails-version) }} 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Set up Ruby 27 | uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: ${{ matrix.ruby-version }} 30 | - name: Install dependencies 31 | run: bundle install 32 | - name: Run linters 33 | run: bundle exec standardrb 34 | - name: Run migrations 35 | run: bundle exec rails db:migrate 36 | - name: Run tests 37 | run: bundle exec rails test 38 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Steve Polito and thoughtbot, inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2022_10_08_115344) do 14 | 15 | create_table "users", force: :cascade do |t| 16 | t.string "email", null: false 17 | t.string "password", null: false 18 | t.datetime "created_at", null: false 19 | t.datetime "updated_at", null: false 20 | t.index ["email"], name: "index_users_on_email", unique: true 21 | end 22 | 23 | end 24 | -------------------------------------------------------------------------------- /test/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | end 34 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap and inline scripts 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /test/dummy/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket-<%= Rails.env %> 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket-<%= Rails.env %> 23 | 24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name-<%= Rails.env %> 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /NEWS.md: -------------------------------------------------------------------------------- 1 | Unreleased 2 | 3 | - Drops support for Ruby 3.0, since it is EOL. 4 | - Drops support for Rails 6. 5 | - Adds `Import#json`, `Export#json`, and `Export#email` (#34). 6 | 7 | The `#json` interface for importing and exporting JSON have been designed to work the same way they already work for the CSV interfaces. For example: 8 | 9 | ```ruby 10 | json_string = [ 11 | { 12 | email: "george@vandelay_industries.com", 13 | password: "bosco" 14 | } 15 | ].to_json 16 | 17 | result = ArtVandelay::Import.new(:users).json(json_string, attributes: {email_address: :email, passcode: :password}) 18 | ``` 19 | 20 | `ArtVandelay::Export#email_csv` has been changed to a more-generic `ArtVandelay::Export#email` method that takes a new `:format` option. The new option defaults to `:csv` but can also be used with `:json`. Since the old `#email_csv` method no longer exists, you'll need to update your application code accordingly. For example: 21 | 22 | ```diff 23 | -ArtVandelay::Export.email_csv(to: my_email_address) 24 | +ArtVandelay::Export.email(to: my_email_address) 25 | ``` 26 | 27 | *Benjamin Wil* 28 | 29 | 0.2.0 (June 15, 2023) 30 | 31 | Add option that allows stripping of whitespace for all values (#19) 32 | 33 | ```ruby 34 | csv_string = CSV.generate do |csv| 35 | csv << ["email_address ", " passcode "] 36 | csv << [" george@vandelay_industries.com ", " bosco "] 37 | end 38 | 39 | result = ArtVandelay::Import.new(:users, strip: true).csv(csv_string, attributes: {email_address: :email, passcode: :password}) 40 | # => # 41 | 42 | result.rows_accepted 43 | # => [{:row=>["george@vandelay_industries.com", "bosco"], :id=>1}] 44 | ``` 45 | 46 | 0.1.0 (December 9, 2022) 47 | 48 | Initial release 🎉 49 | -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 12 | # terminating a worker in development environments. 13 | # 14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 15 | 16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 17 | # 18 | port ENV.fetch("PORT") { 3000 } 19 | 20 | # Specifies the `environment` that Puma will run in. 21 | # 22 | environment ENV.fetch("RAILS_ENV") { "development" } 23 | 24 | # Specifies the `pidfile` that Puma will use. 25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 26 | 27 | # Specifies the number of `workers` to boot in clustered mode. 28 | # Workers are forked web server processes. If using threads and workers together 29 | # the concurrency of the application would be max `threads` * `workers`. 30 | # Workers do not work on JRuby or Windows (both of which do not support 31 | # processes). 32 | # 33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 34 | 35 | # Use the `preload_app!` method when specifying a `workers` number. 36 | # This directive tells Puma to first boot the application and load code 37 | # before forking the application. This takes advantage of Copy On Write 38 | # process behavior so workers use less memory. 39 | # 40 | # preload_app! 41 | 42 | # Allow puma to be restarted by `bin/rails restart` command. 43 | plugin :tmp_restart 44 | -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # Turn false under Spring and add config.action_view.cache_template_loading = true. 12 | config.cache_classes = true 13 | 14 | # Eager loading loads your whole application. When running a single test locally, 15 | # this probably isn't necessary. It's a good idea to do in a continuous integration 16 | # system, or in some way before deploying your code. 17 | config.eager_load = ENV["CI"].present? 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = false 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | # Store uploaded files on the local file system in a temporary directory. 37 | config.active_storage.service = :test 38 | 39 | config.action_mailer.perform_caching = false 40 | 41 | # Tell Action Mailer not to deliver emails to the real world. 42 | # The :test delivery method accumulates sent emails in the 43 | # ActionMailer::Base.deliveries array. 44 | config.action_mailer.delivery_method = :test 45 | 46 | # Print deprecation notices to the stderr. 47 | config.active_support.deprecation = :stderr 48 | 49 | # Raise exceptions for disallowed deprecations. 50 | config.active_support.disallowed_deprecation = :raise 51 | 52 | # Tell Active Support which deprecation messages to disallow. 53 | config.active_support.disallowed_deprecation_warnings = [] 54 | 55 | # Raises error for missing translations. 56 | # config.i18n.raise_on_missing_translations = true 57 | 58 | # Annotate rendered view with file names. 59 | # config.action_view.annotate_rendered_view_with_filenames = true 60 | end 61 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing 18 | config.server_timing = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if Rails.root.join("tmp/caching-dev.txt").exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { 28 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 29 | } 30 | else 31 | config.action_controller.perform_caching = false 32 | 33 | config.cache_store = :null_store 34 | end 35 | 36 | # Store uploaded files on the local file system (see config/storage.yml for options). 37 | config.active_storage.service = :local 38 | 39 | # Don't care if the mailer can't send. 40 | config.action_mailer.raise_delivery_errors = false 41 | 42 | config.action_mailer.perform_caching = false 43 | 44 | # Print deprecation notices to the Rails logger. 45 | config.active_support.deprecation = :log 46 | 47 | # Raise exceptions for disallowed deprecations. 48 | config.active_support.disallowed_deprecation = :raise 49 | 50 | # Tell Active Support which deprecation messages to disallow. 51 | config.active_support.disallowed_deprecation_warnings = [] 52 | 53 | # Raise an error on page load if there are pending migrations. 54 | config.active_record.migration_error = :page_load 55 | 56 | # Highlight code that triggered database queries in logs. 57 | config.active_record.verbose_query_logs = true 58 | 59 | # Suppress logger output for asset requests. 60 | config.assets.quiet = true 61 | 62 | # Raises error for missing translations. 63 | # config.i18n.raise_on_missing_translations = true 64 | 65 | # Annotate rendered view with file names. 66 | # config.action_view.annotate_rendered_view_with_filenames = true 67 | 68 | # Uncomment if you wish to allow Action Cable access from any origin. 69 | # config.action_cable.disable_request_forgery_protection = true 70 | end 71 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? 26 | 27 | # Compress CSS using a preprocessor. 28 | # config.assets.css_compressor = :sass 29 | 30 | # Do not fallback to assets pipeline if a precompiled asset is missed. 31 | config.assets.compile = false 32 | 33 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 34 | # config.asset_host = "http://assets.example.com" 35 | 36 | # Specifies the header that your server uses for sending files. 37 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 38 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 39 | 40 | # Store uploaded files on the local file system (see config/storage.yml for options). 41 | config.active_storage.service = :local 42 | 43 | # Mount Action Cable outside main process or domain. 44 | # config.action_cable.mount_path = nil 45 | # config.action_cable.url = "wss://example.com/cable" 46 | # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] 47 | 48 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 49 | # config.force_ssl = true 50 | 51 | # Include generic and useful information about system operation, but avoid logging too much 52 | # information to avoid inadvertent exposure of personally identifiable information (PII). 53 | config.log_level = :info 54 | 55 | # Prepend all log lines with the following tags. 56 | config.log_tags = [ :request_id ] 57 | 58 | # Use a different cache store in production. 59 | # config.cache_store = :mem_cache_store 60 | 61 | # Use a real queuing backend for Active Job (and separate queues per environment). 62 | # config.active_job.queue_adapter = :resque 63 | # config.active_job.queue_name_prefix = "dummy_production" 64 | 65 | config.action_mailer.perform_caching = false 66 | 67 | # Ignore bad email addresses and do not raise email delivery errors. 68 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 69 | # config.action_mailer.raise_delivery_errors = false 70 | 71 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 72 | # the I18n.default_locale when a translation cannot be found). 73 | config.i18n.fallbacks = true 74 | 75 | # Don't log any deprecations. 76 | config.active_support.report_deprecations = false 77 | 78 | # Use default logging formatter so that PID and timestamp are not suppressed. 79 | config.log_formatter = ::Logger::Formatter.new 80 | 81 | # Use a different logger for distributed setups. 82 | # require "syslog/logger" 83 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name") 84 | 85 | if ENV["RAILS_LOG_TO_STDOUT"].present? 86 | logger = ActiveSupport::Logger.new(STDOUT) 87 | logger.formatter = config.log_formatter 88 | config.logger = ActiveSupport::TaggedLogging.new(logger) 89 | end 90 | 91 | # Do not dump schema after migrations. 92 | config.active_record.dump_schema_after_migration = false 93 | end 94 | -------------------------------------------------------------------------------- /lib/art_vandelay.rb: -------------------------------------------------------------------------------- 1 | require "art_vandelay/version" 2 | require "art_vandelay/engine" 3 | require "csv" 4 | 5 | module ArtVandelay 6 | mattr_accessor :filtered_attributes, :from_address, :in_batches_of 7 | @@filtered_attributes = [:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn] 8 | @@in_batches_of = 10000 9 | 10 | def self.setup 11 | yield self 12 | end 13 | 14 | class Error < StandardError 15 | end 16 | 17 | class Export 18 | class Result 19 | attr_reader :exports 20 | 21 | def initialize(exports) 22 | @exports = exports 23 | end 24 | end 25 | 26 | # TODO attributes: self.filtered_attributes 27 | def initialize(records, export_sensitive_data: false, attributes: [], in_batches_of: ArtVandelay.in_batches_of) 28 | @records = records 29 | @export_sensitive_data = export_sensitive_data 30 | @attributes = attributes 31 | @in_batches_of = in_batches_of 32 | end 33 | 34 | def csv 35 | csv_exports = [] 36 | 37 | if records.is_a?(ActiveRecord::Relation) 38 | records.in_batches(of: in_batches_of) do |relation| 39 | csv_exports << CSV.parse(generate_csv(relation), headers: true) 40 | end 41 | elsif records.is_a?(ActiveRecord::Base) 42 | csv_exports << CSV.parse(generate_csv(records), headers: true) 43 | end 44 | 45 | Result.new(csv_exports) 46 | end 47 | 48 | def json 49 | json_exports = [] 50 | 51 | if records.is_a?(ActiveRecord::Relation) 52 | records.in_batches(of: in_batches_of) do |relation| 53 | json_exports << relation 54 | .map { |record| row(record.attributes, format: :hash) } 55 | .to_json 56 | end 57 | elsif records.is_a?(ActiveRecord::Base) 58 | json_exports << [row(records.attributes, format: :hash)].to_json 59 | end 60 | 61 | Result.new(json_exports) 62 | end 63 | 64 | def email( 65 | to:, 66 | from: ArtVandelay.from_address, 67 | subject: "#{model_name} export", 68 | body: "#{model_name} export", 69 | format: :csv 70 | ) 71 | if from.nil? 72 | raise ArtVandelay::Error, "missing keyword: :from. Alternatively, set a value on ArtVandelay.from_address" 73 | end 74 | 75 | mailer = ActionMailer::Base.mail(to: to, from: from, subject: subject, body: body) 76 | exports = public_send(format).exports 77 | 78 | exports.each.with_index(1) do |export, index| 79 | if exports.one? 80 | mailer.attachments[file_name(format: format)] = export 81 | else 82 | file = file_name(suffix: "-#{index}", format: format) 83 | mailer.attachments[file] = export 84 | end 85 | end 86 | 87 | mailer.deliver 88 | end 89 | 90 | private 91 | 92 | attr_reader :records, :export_sensitive_data, :attributes, :in_batches_of 93 | 94 | def file_name(**options) 95 | options = options.symbolize_keys 96 | format = options[:format] 97 | suffix = options[:suffix] 98 | prefix = model_name.downcase 99 | timestamp = Time.current.in_time_zone("UTC").strftime("%Y-%m-%d-%H-%M-%S-UTC") 100 | 101 | "#{prefix}-export-#{timestamp}#{suffix}.#{format}" 102 | end 103 | 104 | def filtered_values(attributes, format:) 105 | attributes = 106 | if export_sensitive_data 107 | ActiveSupport::ParameterFilter.new([]).filter(attributes) 108 | else 109 | ActiveSupport::ParameterFilter.new(ArtVandelay.filtered_attributes).filter(attributes) 110 | end 111 | 112 | case format 113 | when :hash then attributes 114 | when :array then attributes.values 115 | end 116 | end 117 | 118 | def generate_csv(relation) 119 | CSV.generate do |csv| 120 | csv << header 121 | if relation.is_a?(ActiveRecord::Relation) 122 | relation.each do |record| 123 | csv << row(record.attributes) 124 | end 125 | elsif relation.is_a?(ActiveRecord::Base) 126 | csv << row(records.attributes) 127 | end 128 | end 129 | end 130 | 131 | def header 132 | if attributes.any? 133 | model.attribute_names.select do |column_name| 134 | standardized_attributes.include?(column_name) 135 | end 136 | else 137 | model.attribute_names 138 | end 139 | end 140 | 141 | def model 142 | model_name.constantize 143 | end 144 | 145 | def model_name 146 | records.model_name.name 147 | end 148 | 149 | def row(attributes, format: :array) 150 | if self.attributes.any? 151 | filtered_values(attributes.slice(*standardized_attributes), format:) 152 | else 153 | filtered_values(attributes, format:) 154 | end 155 | end 156 | 157 | def standardized_attributes 158 | attributes.map(&:to_s) 159 | end 160 | end 161 | 162 | class Import 163 | class Result 164 | attr_reader :rows_accepted, :rows_rejected 165 | 166 | def initialize(rows_accepted:, rows_rejected:) 167 | @rows_accepted = rows_accepted 168 | @rows_rejected = rows_rejected 169 | end 170 | end 171 | 172 | def initialize(model_name, **options) 173 | @options = options.symbolize_keys 174 | @rollback = options[:rollback] 175 | @strip = options[:strip] 176 | @model_name = model_name 177 | end 178 | 179 | def csv(csv_string, **options) 180 | options = options.symbolize_keys 181 | headers = options[:headers] || true 182 | attributes = options[:attributes] || {} 183 | rows = build_csv(csv_string, headers) 184 | 185 | if rollback 186 | # TODO: It would be nice to still return a result object during a 187 | # failure 188 | active_record.transaction do 189 | parse_rows(rows, attributes, raise_on_error: true) 190 | end 191 | else 192 | parse_rows(rows, attributes) 193 | end 194 | end 195 | 196 | def json(json_string, **options) 197 | options = options.symbolize_keys 198 | attributes = options[:attributes] || {} 199 | array = JSON.parse(json_string) 200 | 201 | if rollback 202 | active_record.transaction do 203 | parse_json_data(array, attributes, raise_on_error: true) 204 | end 205 | else 206 | parse_json_data(array, attributes) 207 | end 208 | end 209 | 210 | private 211 | 212 | attr_reader :model_name, :rollback, :strip 213 | 214 | def active_record 215 | model_name.to_s.classify.constantize 216 | end 217 | 218 | def build_csv(csv_string, headers) 219 | CSV.parse(csv_string, headers: headers) 220 | end 221 | 222 | def build_params(row, attributes) 223 | attributes = attributes.stringify_keys 224 | 225 | if strip 226 | row.to_h.stringify_keys.transform_keys do |key| 227 | attributes[key.strip] || key.strip 228 | end.tap do |new_params| 229 | new_params.transform_values!(&:strip) 230 | end 231 | else 232 | row.to_h.stringify_keys.transform_keys do |key| 233 | attributes[key] || key 234 | end 235 | end 236 | end 237 | 238 | def parse_json_data(array, attributes, **options) 239 | raise_on_error = options[:raise_on_error] || false 240 | result = Result.new(rows_accepted: [], rows_rejected: []) 241 | 242 | array.each do |entry| 243 | params = build_params(entry, attributes) 244 | record = active_record.new(params) 245 | 246 | if raise_on_error ? record.save! : record.save 247 | result.rows_accepted << {row: entry, id: record.id} 248 | else 249 | result.rows_rejected << {row: entry, errors: record.errors.messages} 250 | end 251 | end 252 | 253 | result 254 | end 255 | 256 | def parse_rows(rows, attributes, **options) 257 | options = options.symbolize_keys 258 | raise_on_error = options[:raise_on_error] || false 259 | result = Result.new(rows_accepted: [], rows_rejected: []) 260 | 261 | rows.each do |row| 262 | params = build_params(row, attributes) 263 | record = active_record.new(params) 264 | 265 | if raise_on_error ? record.save! : record.save 266 | result.rows_accepted << {row: row.fields, id: record.id} 267 | else 268 | result.rows_rejected << {row: row.fields, errors: record.errors.messages} 269 | end 270 | end 271 | 272 | result 273 | end 274 | end 275 | end 276 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🌐 Art Vandelay 2 | [![GitHub Actions 3 | Demo](https://github.com/thoughtbot/art_vandelay/actions/workflows/ci.yml/badge.svg)](https://github.com/thoughtbot/art_vandelay/actions/workflows/ci.yml) 4 | 5 | Art Vandelay is an importer/exporter for Rails 7.0 and higher. 6 | 7 | Have you ever been on a project where, out of nowhere, someone asks you to send them a CSV of data? You think to yourself, “Ok, cool. No big deal. Just gimme five minutes”, but then that five minutes turns into a few hours. Art Vandelay can help. 8 | 9 | **At a high level, here’s what Art Vandelay can do:** 10 | 11 | - 🕶 Automatically [filters out sensitive information](#%EF%B8%8F-configuration). 12 | - 🔁 Export data [in batches](#exporting-in-batches). 13 | - 📧 [Email](#artvandelayexportemail) exported data. 14 | - 📥 [Import data](#-importing) from a CSV or JSON file. 15 | 16 | ## ✅ Installation 17 | 18 | Add this line to your application's Gemfile: 19 | 20 | ```ruby 21 | gem "art_vandelay" 22 | ``` 23 | 24 | And then execute: 25 | ```bash 26 | $ bundle 27 | ``` 28 | 29 | ## ⚙️ Configuration 30 | 31 | ```ruby 32 | # config/initializers/art_vandelay.rb 33 | ArtVandelay.setup do |config| 34 | config.filtered_attributes = [:credit_card, :birthday] 35 | config.from_address = "no-reply-export@example.com" 36 | config.in_batches_of = 5000 37 | end 38 | ``` 39 | #### Default Values 40 | 41 | |Attribute|Value|Description| 42 | |---------|-----|-----------| 43 | |`filtered_attributes`|`[:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn]`|Attributes that will be automatically filtered when exported| 44 | |`from_address`|`nil`|The email address used when sending an email of exports| 45 | |`in_batches_of`|`10000`|The number of records that will be exported into each CSV| 46 | 47 | ## 🧰 Usage 48 | 49 | ### 📤 Exporting 50 | 51 | Art Vandelay supports exporting CSVs and JSON files. 52 | 53 | ```ruby 54 | ArtVandelay::Export.new(records, export_sensitive_data: false, attributes: [], in_batches_of: ArtVandelay.in_batches_of) 55 | ``` 56 | 57 | |Argument|Description| 58 | |--------|-----------| 59 | |`records`|An [Active Record Relation](https://api.rubyonrails.org/classes/ActiveRecord/Relation.html) or an instance of an Active Record. E.g. `User.all`, `User.first`, `User.where(...)`, `User.find_by`| 60 | |`export_sensitive_data`|Export sensitive data. Defaults to `false`. Can be configured with `ArtVandelay.filtered_attributes`.| 61 | |`attributes`|An array attributes to export. Default to all.| 62 | |`in_batches_of`|The number of records that will be exported into each file. Defaults to 10,000. Can be configured with `ArtVandelay.in_batches_of`| 63 | 64 | #### ArtVandelay::Export#csv 65 | 66 | Returns an instance of `ArtVandelay::Export::Result`. 67 | 68 | ```ruby 69 | result = ArtVandelay::Export.new(User.all).csv 70 | # => # 71 | 72 | csv_exports = result.csv_exports 73 | # => [#, #, ...] 74 | 75 | csv = csv_exports.first.to_a 76 | # => [["id", "email", "password", "created_at", "updated_at"], ["1", "user@example.com", "[FILTERED]", "2022-10-25 09:20:28 UTC", "2022-10-25 09:20:28 UTC"]] 77 | ``` 78 | 79 | #### ArtVandelay::Export#json 80 | 81 | Returns an instance of `ArtVandelay::Export::Result`. 82 | 83 | ```ruby 84 | result = ArtVandelay::Export.new(User.all).json 85 | # => # 86 | 87 | json_exports = result.json_exports 88 | # => [#, #, ...] 89 | 90 | json = JSON.parse(json_exports.first) 91 | # => [{"id"=>1, "email"=>"user@example.com", "password"=>"[FILTERED]", "created_at"=>"2022-10-25 09:20:28.123Z", "updated_at"=>"2022-10-25 09:20:28.123Z"}] 92 | ``` 93 | 94 | ##### Exporting Sensitive Data 95 | 96 | ```ruby 97 | result = ArtVandelay::Export.new(User.all, export_sensitive_data: true).csv 98 | # => # 99 | 100 | password = result.csv_exports.first["password"] 101 | # => ["bosco"] 102 | ``` 103 | 104 | ##### Exporting Specific Attributes 105 | 106 | ```ruby 107 | result = ArtVandelay::Export.new(User.all, attributes: [:email]).csv 108 | # => # 109 | 110 | csv = result.csv_exports.first.to_a 111 | # => [["email"], ["george@vandelay_industries.com"]] 112 | ``` 113 | 114 | ##### Exporting in Batches 115 | 116 | ```ruby 117 | result = ArtVandelay::Export.new(User.all, in_batches_of: 100).csv 118 | # => # 119 | 120 | csv_size = result.csv_exports.first.size 121 | # => 100 122 | ``` 123 | 124 | #### ArtVandelay::Export#email 125 | 126 | Emails the recipient(s) exports as attachments. 127 | 128 | ```ruby 129 | email(to:, from: ArtVandelay.from_address, subject: "#{model_name} export", body: "#{model_name} export") 130 | ``` 131 | 132 | |Argument|Description| 133 | |---------|-----| 134 | |`to`|An array of email addresses representing who should receive the email.| 135 | |`from`|The email address of the sender.| 136 | |`subject`|The email subject. Defaults to the following pattern: "User export"| 137 | |`body`|The email body. Defaults to the following pattern: "User export"| 138 | |`format`|The format of the export file. Either `:csv` or `:json`.| 139 | 140 | ```ruby 141 | ArtVandelay::Export 142 | .new(User.where.not(confirmed: nil)) 143 | .email( 144 | to: ["george@vandelay_industries.com", "kel_varnsen@vandelay_industries.com"], 145 | from: "noreply@vandelay_industries.com", 146 | subject: "List of confirmed users", 147 | body: "Here's an export of all confirmed users in our database.", 148 | format: :json 149 | ) 150 | # => ActionMailer::Base#mail: processed outbound mail in... 151 | ``` 152 | 153 | ### 📥 Importing 154 | 155 | ```ruby 156 | ArtVandelay::Import.new(model_name, **options) 157 | ``` 158 | 159 | |Argument|Description| 160 | |--------|-----------| 161 | |`model_name`|The name of the model being imported. E.g. `:users`, `:user`, `"users"` or `"user"`| 162 | |`**options`|A hash of options. Available options are `rollback:`, `strip:`| 163 | 164 | #### Options 165 | 166 | |Option|Description| 167 | |------|-----------| 168 | |`rollback:`|Whether the import should rollback if any of the records fails to save.| 169 | |`strip:`|Strips leading and trailing whitespace from all values, including headers.| 170 | 171 | #### ArtVandelay::Import#csv 172 | 173 | Imports records from the supplied CSV. Returns an instance of `ArtVandelay::Import::Result`. 174 | 175 | ```ruby 176 | csv_string = CSV.generate do |csv| 177 | csv << ["email", "password"] 178 | csv << ["george@vandelay_industries.com", "bosco"] 179 | csv << ["kel_varnsen@vandelay_industries.com", nil] 180 | end 181 | 182 | result = ArtVandelay::Import.new(:users).csv(csv_string) 183 | # => # 184 | 185 | result.rows_accepted 186 | # => [{:row=>["george@vandelay_industries.com", "bosco"], :id=>1}] 187 | 188 | result.rows_rejected 189 | # => [{:row=>["kel_varnsen@vandelay_industries.com", nil], :errors=>{:password=>["can't be blank"]}}] 190 | ``` 191 | 192 | ```ruby 193 | csv(csv_string, **options) 194 | ``` 195 | 196 | |Argument|Description| 197 | |--------|-----------| 198 | |`csv_string`|Data in the form of a CSV string.| 199 | |`**options`|A hash of options. Available options are `headers:` and `attributes:`| 200 | 201 | ##### Options 202 | 203 | |Option|Description| 204 | |------|-----------| 205 | |`headers:`|The CSV headers. Use when the supplied CSV string does not have headers.| 206 | |`attributes:`|The attributes the headers should map to. Useful if the headers do not match the model's attributes.| 207 | 208 | ##### Setting headers 209 | 210 | ```ruby 211 | csv_string = CSV.generate do |csv| 212 | csv << ["george@vandelay_industries.com", "bosco"] 213 | end 214 | 215 | result = ArtVandelay::Import.new(:users).csv(csv_string, headers: [:email, :password]) 216 | # => # 217 | ``` 218 | 219 | #### ArtVandelay::Import#json 220 | 221 | Imports records from the supplied JSON. Returns an instance of `ArtVandelay::Import::Result`. 222 | 223 | ```ruby 224 | json_string = [ 225 | { 226 | email: "george@vandelay_industries.com", 227 | password: "bosco" 228 | }, 229 | { 230 | email: "kel_varnsen@vanderlay_industries.com", 231 | password: nil 232 | } 233 | ].to_json 234 | 235 | result = ArtVandelay::Import.new(:users).json(json_string) 236 | # => # 237 | 238 | result.rows_accepted 239 | # => [{:row=>[{"email"=>"george@vandelay_industries.com", "password"=>"bosco"}], :id=>1}] 240 | 241 | result.rows_rejected 242 | # => [{:row=>[{"email"=>"kel_varnsen@vandelay_industries.com", "password"=>nil}], :errors=>{:password=>["can't be blank"]}}] 243 | ``` 244 | 245 | ```ruby 246 | json(json_string, **options) 247 | ``` 248 | 249 | ##### Options 250 | 251 | |Option|Description| 252 | |------|-----------| 253 | |`attributes:`|The attributes the JSON object keys should map to. Useful if the headers do not match the model's attributes.| 254 | 255 | #### Rolling back if a record fails to save 256 | 257 | `ArtVandelay::Import.new` supports a `:rollback` keyword argument. It imports all rows as a single transaction and does not persist any records if one record fails due to an exception. 258 | 259 | ```ruby 260 | csv_string = CSV.generate do |csv| 261 | csv << ["email", "password"] 262 | csv << ["george@vandelay_industries.com", "bosco"] 263 | csv << ["kel_varnsen@vandelay_industries.com", nil] 264 | end 265 | 266 | result = ArtVandelay::Import.new(:users, rollback: true).csv(csv_string) 267 | # => rollback transaction 268 | ``` 269 | 270 | #### Mapping custom headers 271 | 272 | Both `ArtVandelay::Import#csv` and `#json` support an `:attributes` keyword argument. This lets you map fields in the import document to your Active Record model's attributes. 273 | 274 | ```ruby 275 | csv_string = CSV.generate do |csv| 276 | csv << ["email_address", "passcode"] 277 | csv << ["george@vandelay_industries.com", "bosco"] 278 | end 279 | 280 | result = ArtVandelay::Import.new(:users).csv(csv_string, attributes: {email_address: :email, passcode: :password}) 281 | # => # 282 | ``` 283 | 284 | #### Stripping whitespace 285 | 286 | `ArtVandelay::Import.new` supports a `:strip` keyword argument to strip whitespace from values in the import document. 287 | 288 | ```ruby 289 | csv_string = CSV.generate do |csv| 290 | csv << ["email_address ", " passcode "] 291 | csv << [" george@vandelay_industries.com ", " bosco "] 292 | end 293 | 294 | result = ArtVandelay::Import.new(:users, strip: true).csv(csv_string, attributes: {email_address: :email, passcode: :password}) 295 | # => # 296 | 297 | result.rows_accepted 298 | # => [{:row=>["george@vandelay_industries.com", "bosco"], :id=>1}] 299 | ``` 300 | 301 | ## 🙏 Contributing 302 | 303 | 1. Run `./bin/setup`. 304 | 2. Make your changes. 305 | 3. Ensure `./bin/ci` passes. 306 | 4. Create a [pull request](https://github.com/thoughtbot/art_vandelay/compare). 307 | 308 | ## 📜 License 309 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 310 | -------------------------------------------------------------------------------- /test/art_vandelay_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "csv" 3 | 4 | class ArtVandelayTest < ActiveSupport::TestCase 5 | class VERSION < ArtVandelayTest 6 | test "it has a version number" do 7 | assert ArtVandelay::VERSION 8 | end 9 | 10 | test "it has a supported Rails version" do 11 | assert_equal ">= 7.0", ArtVandelay::RAILS_VERSION 12 | end 13 | 14 | test "it has a supported Ruby version" do 15 | assert_equal ">= 3.1", ArtVandelay::MINIMUM_RUBY_VERSION 16 | end 17 | end 18 | 19 | class Setup < ArtVandelayTest 20 | test "it has the correct default values" do 21 | filtered_attributes = ArtVandelay.filtered_attributes 22 | from_address = ArtVandelay.from_address 23 | in_batches_of = ArtVandelay.in_batches_of 24 | 25 | assert_equal( 26 | [:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn], 27 | filtered_attributes 28 | ) 29 | assert_nil from_address 30 | assert_equal 10000, in_batches_of 31 | end 32 | end 33 | 34 | class Export < ArtVandelayTest 35 | include ActionMailer::TestHelper 36 | 37 | test "CSV files return a ArtVandelay::Export::Result instance" do 38 | User.create!(email: "user@xample.com", password: "password") 39 | 40 | result = ArtVandelay::Export.new(User.all).csv 41 | 42 | assert_instance_of ArtVandelay::Export::Result, result 43 | end 44 | 45 | test "JSON files return a ArtVandelay::Export::Result instance" do 46 | User.create!(email: "user@xample.com", password: "password") 47 | 48 | result = ArtVandelay::Export.new(User.all).json 49 | 50 | assert_instance_of ArtVandelay::Export::Result, result 51 | end 52 | 53 | test "it creates a CSV containing the correct data" do 54 | user = User.create!(email: "user@xample.com", password: "password") 55 | 56 | result = ArtVandelay::Export.new(User.all).csv 57 | csv = result.exports.first 58 | 59 | assert_equal( 60 | [ 61 | ["id", "email", "password", "created_at", "updated_at"], 62 | [user.id.to_s, user.email.to_s, "[FILTERED]", user.created_at.to_s, user.updated_at.to_s] 63 | ], 64 | csv.to_a 65 | ) 66 | assert_equal( 67 | ["id", "email", "password", "created_at", "updated_at"], 68 | csv.headers 69 | ) 70 | end 71 | 72 | test "it creates a JSON file containing the correct data" do 73 | user_1 = User.create!(email: "user_1@example.com", password: "password") 74 | user_2 = User.create!(email: "user_2@example.com", password: "password") 75 | 76 | result = ArtVandelay::Export.new(User.all).json 77 | json = result.exports.first 78 | 79 | assert_equal( 80 | [ 81 | { 82 | "id" => user_1.id, 83 | "email" => "user_1@example.com", 84 | "password" => "[FILTERED]", 85 | "created_at" => user_1.created_at.iso8601(3), 86 | "updated_at" => user_1.updated_at.iso8601(3) 87 | }, 88 | { 89 | "id" => user_2.id, 90 | "email" => "user_2@example.com", 91 | "password" => "[FILTERED]", 92 | "created_at" => user_2.created_at.iso8601(3), 93 | "updated_at" => user_2.updated_at.iso8601(3) 94 | } 95 | ], 96 | JSON.parse(json) 97 | ) 98 | end 99 | 100 | test "it creates a CSV when passed one record" do 101 | user = User.create!(email: "user@xample.com", password: "password") 102 | 103 | result = ArtVandelay::Export.new(User.first).csv 104 | csv = result.exports.first 105 | 106 | assert_equal( 107 | [ 108 | ["id", "email", "password", "created_at", "updated_at"], 109 | [user.id.to_s, user.email.to_s, "[FILTERED]", user.created_at.to_s, user.updated_at.to_s] 110 | ], 111 | csv.to_a 112 | ) 113 | assert_equal( 114 | ["id", "email", "password", "created_at", "updated_at"], 115 | csv.headers 116 | ) 117 | end 118 | 119 | test "it creates a JSON file when passed one record" do 120 | user = User.create!(email: "user@xample.com", password: "password") 121 | result = ArtVandelay::Export.new(User.first).json 122 | json = result.exports.first 123 | 124 | assert_equal( 125 | [ 126 | { 127 | "id" => user.id, 128 | "email" => user.email.to_s, 129 | "password" => "[FILTERED]", 130 | "created_at" => user.created_at.iso8601(3), 131 | "updated_at" => user.updated_at.iso8601(3) 132 | } 133 | ], 134 | JSON.parse(json) 135 | ) 136 | end 137 | 138 | test "it controls what data is filtered from CSV output" do 139 | user = User.create!(email: "user@xample.com", password: "password") 140 | ArtVandelay.setup do |config| 141 | config.filtered_attributes << :email 142 | end 143 | 144 | csv = ArtVandelay::Export.new(User.all).csv.exports.first 145 | 146 | assert_equal( 147 | [ 148 | ["id", "email", "password", "created_at", "updated_at"], 149 | [user.id.to_s, "[FILTERED]", "[FILTERED]", user.created_at.to_s, user.updated_at.to_s] 150 | ], 151 | csv.to_a 152 | ) 153 | ArtVandelay.filtered_attributes.delete(:email) 154 | end 155 | 156 | test "it controls what data is filtered from JSON output" do 157 | user = User.create!(email: "user@example.com", password: "password") 158 | ArtVandelay.setup do |config| 159 | config.filtered_attributes << :email 160 | end 161 | 162 | json = ArtVandelay::Export.new(User.all).json.exports.first 163 | 164 | assert_equal( 165 | [ 166 | { 167 | "id" => user.id, 168 | "email" => "[FILTERED]", 169 | "password" => "[FILTERED]", 170 | "created_at" => user.created_at.iso8601(3), 171 | "updated_at" => user.updated_at.iso8601(3) 172 | } 173 | ], 174 | JSON.parse(json) 175 | ) 176 | 177 | ArtVandelay.filtered_attributes.delete(:email) 178 | end 179 | 180 | test "it allows for unfiltered CSV exports" do 181 | user = User.create!(email: "user@xample.com", password: "password") 182 | 183 | csv = ArtVandelay::Export.new(User.all, export_sensitive_data: true).csv.exports.first 184 | 185 | assert_equal( 186 | [ 187 | ["id", "email", "password", "created_at", "updated_at"], 188 | [user.id.to_s, user.email.to_s, "password", user.created_at.to_s, user.updated_at.to_s] 189 | ], 190 | csv.to_a 191 | ) 192 | end 193 | 194 | test "it allows for unfiltered JSON exports" do 195 | user = User.create!(email: "user@example.com", password: "password") 196 | json = ArtVandelay::Export.new(User.all, export_sensitive_data: true) 197 | .json 198 | .exports 199 | .first 200 | 201 | assert_equal( 202 | [ 203 | { 204 | "id" => user.id, 205 | "email" => user.email, 206 | "password" => "password", 207 | "created_at" => user.created_at.iso8601(3), 208 | "updated_at" => user.updated_at.iso8601(3) 209 | } 210 | ], 211 | JSON.parse(json) 212 | ) 213 | end 214 | 215 | test "it controls what attributes are exported to CSVs" do 216 | user = User.create!(email: "user@xample.com", password: "password") 217 | 218 | csv = ArtVandelay::Export.new(User.all, attributes: [:id, "email"]).csv.exports.first 219 | 220 | assert_equal( 221 | [ 222 | ["id", "email"], 223 | [user.id.to_s, user.email.to_s] 224 | ], 225 | csv.to_a 226 | ) 227 | end 228 | 229 | test "controls what attributes are exported to JSON" do 230 | user = User.create!(email: "user@xample.com", password: "password") 231 | json = ArtVandelay::Export.new(User.all, attributes: [:id, "email"]) 232 | .json 233 | .exports 234 | .first 235 | 236 | assert_equal [{"id" => user.id, "email" => user.email}], 237 | JSON.parse(json) 238 | end 239 | 240 | test "it batches CSV exports" do 241 | User.create!(email: "one@xample.com", password: "password") 242 | User.create!(email: "two@xample.com", password: "password") 243 | 244 | result = ArtVandelay::Export.new(User.all, in_batches_of: 1).csv 245 | csv_1 = result.exports.first 246 | csv_2 = result.exports.last 247 | 248 | assert "one@example.com", csv_1.first["email"] 249 | assert "two@example.com", csv_2.first["email"] 250 | end 251 | 252 | test "it batches JSON exports" do 253 | User.create!(email: "one@example.com", password: "password") 254 | User.create!(email: "two@example.com", password: "password") 255 | 256 | result = ArtVandelay::Export.new(User.all, in_batches_of: 1).json 257 | json_1 = result.exports.first 258 | json_2 = result.exports.last 259 | 260 | assert "one@example.com", json_1.first["email"] 261 | assert "two@example.com", json_2.first["email"] 262 | end 263 | 264 | test "it can set the default batch size" do 265 | User.create!(email: "one@xample.com", password: "password") 266 | User.create!(email: "two@xample.com", password: "password") 267 | ArtVandelay.setup do |config| 268 | config.in_batches_of = 1 269 | end 270 | 271 | result = ArtVandelay::Export.new(User.all).csv 272 | csv_1 = result.exports.first 273 | csv_2 = result.exports.last 274 | 275 | assert "one@example.com", csv_1.first["email"] 276 | assert "two@example.com", csv_2.first["email"] 277 | 278 | ArtVandelay.in_batches_of = 10000 279 | end 280 | 281 | test "it emails a CSV" do 282 | travel_to Date.new(1989, 12, 31).beginning_of_day 283 | user = User.create!(email: "user@xample.com", password: "password") 284 | 285 | assert_emails 1 do 286 | ArtVandelay::Export.new(User.all).email( 287 | to: ["recipient_1@examaple.com", "recipient_2@example.com"], 288 | from: "sender@example.com" 289 | ) 290 | end 291 | 292 | email = ActionMailer::Base.deliveries.last 293 | csv = email.attachments.first 294 | 295 | assert_equal( 296 | ["recipient_1@examaple.com", "recipient_2@example.com"], 297 | email.to 298 | ) 299 | assert_equal( 300 | [ 301 | ["id", "email", "password", "created_at", "updated_at"], 302 | [user.id.to_s, user.email.to_s, "[FILTERED]", user.created_at.to_s, user.updated_at.to_s] 303 | ], 304 | CSV.parse(csv.body.raw_source) 305 | ) 306 | assert_equal "user-export-1989-12-31-00-00-00-UTC.csv", csv.filename 307 | end 308 | 309 | test "it emails a JSON file" do 310 | travel_to Date.new(1989, 12, 31).beginning_of_day 311 | user = User.create!(email: "user@xample.com", password: "password") 312 | 313 | assert_emails 1 do 314 | ArtVandelay::Export.new(User.all).email( 315 | to: ["recipient_1@examaple.com", "recipient_2@example.com"], 316 | from: "sender@example.com", 317 | format: :json 318 | ) 319 | end 320 | 321 | email = ActionMailer::Base.deliveries.last 322 | json = email.attachments.first 323 | 324 | assert_equal( 325 | ["recipient_1@examaple.com", "recipient_2@example.com"], 326 | email.to 327 | ) 328 | assert_equal( 329 | [ 330 | { 331 | "id" => user.id, 332 | "email" => user.email, 333 | "password" => "[FILTERED]", 334 | "created_at" => user.created_at.iso8601(3), 335 | "updated_at" => user.updated_at.iso8601(3) 336 | } 337 | ], 338 | JSON.parse(json.body.raw_source) 339 | ) 340 | assert_equal "user-export-1989-12-31-00-00-00-UTC.json", json.filename 341 | end 342 | 343 | test "it requires a from address" do 344 | User.create!(email: "user@xample.com", password: "password") 345 | 346 | assert_raises ArtVandelay::Error do 347 | ArtVandelay::Export.new(User.all).email( 348 | to: ["recipient_1@examaple.com"] 349 | ) 350 | end 351 | end 352 | 353 | test "it emails a CSV when one record is passed" do 354 | travel_to Date.new(1989, 12, 31).beginning_of_day 355 | user = User.create!(email: "user@xample.com", password: "password") 356 | 357 | assert_emails 1 do 358 | ArtVandelay::Export.new(User.first).email( 359 | to: ["recipient_1@examaple.com", "recipient_2@example.com"], 360 | from: "sender@example.com" 361 | ) 362 | end 363 | 364 | email = ActionMailer::Base.deliveries.last 365 | csv = email.attachments.first 366 | 367 | assert_equal( 368 | ["recipient_1@examaple.com", "recipient_2@example.com"], 369 | email.to 370 | ) 371 | assert_equal( 372 | [ 373 | ["id", "email", "password", "created_at", "updated_at"], 374 | [user.id.to_s, user.email.to_s, "[FILTERED]", user.created_at.to_s, user.updated_at.to_s] 375 | ], 376 | CSV.parse(csv.body.raw_source) 377 | ) 378 | assert_equal "user-export-1989-12-31-00-00-00-UTC.csv", csv.filename 379 | end 380 | 381 | test "it emails multiple CSV attachments" do 382 | travel_to Date.new(1989, 12, 31).beginning_of_day 383 | User.create!(email: "one@example.com", password: "password") 384 | User.create!(email: "two@example.com", password: "password") 385 | 386 | assert_emails 1 do 387 | ArtVandelay::Export.new(User.all, in_batches_of: 1).email( 388 | to: ["recipient_1@examaple.com"], 389 | from: "sender@example.com" 390 | ) 391 | end 392 | 393 | email = ActionMailer::Base.deliveries.last 394 | csv_1 = email.attachments.first 395 | csv_2 = email.attachments.last 396 | 397 | assert_match "one@example.com", csv_1.body.raw_source 398 | assert_match "two@example.com", csv_2.body.raw_source 399 | assert_equal "user-export-1989-12-31-00-00-00-UTC-1.csv", csv_1.filename 400 | assert_equal "user-export-1989-12-31-00-00-00-UTC-2.csv", csv_2.filename 401 | end 402 | 403 | test "it raises an error if there is no data to export" do 404 | skip 405 | end 406 | 407 | test "it has a default subject" do 408 | User.create!(email: "user@xample.com", password: "password") 409 | 410 | ArtVandelay::Export.new(User.all).email( 411 | to: ["recipient_1@examaple.com", "recipient_2@example.com"], 412 | from: "sender@example.com" 413 | ) 414 | email = ActionMailer::Base.deliveries.last 415 | 416 | assert_equal "User export", email.subject 417 | end 418 | 419 | test "it can set the subject" do 420 | User.create!(email: "user@xample.com", password: "password") 421 | 422 | ArtVandelay::Export.new(User.all).email( 423 | to: ["recipient_1@examaple.com", "recipient_2@example.com"], 424 | from: "sender@example.com", 425 | subject: "CUSTOM SUBJECT" 426 | ) 427 | email = ActionMailer::Base.deliveries.last 428 | 429 | assert_equal "CUSTOM SUBJECT", email.subject 430 | end 431 | 432 | test "it can set a from address" do 433 | User.create!(email: "user@xample.com", password: "password") 434 | 435 | ArtVandelay::Export.new(User.all).email( 436 | to: ["recipient_1@examaple.com", "recipient_2@example.com"], 437 | from: "FROM@EMAIL.COM" 438 | ) 439 | email = ActionMailer::Base.deliveries.last 440 | 441 | assert_equal "FROM@EMAIL.COM", email.from.first 442 | end 443 | 444 | test "it can set a default from address" do 445 | User.create!(email: "user@xample.com", password: "password") 446 | ArtVandelay.setup do |config| 447 | config.from_address = "DEFAULT@EMAIL.COM" 448 | end 449 | ArtVandelay::Export.new(User.all).email( 450 | to: ["recipient_1@examaple.com", "recipient_2@example.com"] 451 | ) 452 | email = ActionMailer::Base.deliveries.last 453 | 454 | assert_equal "DEFAULT@EMAIL.COM", email.from.first 455 | 456 | ArtVandelay.from_address = nil 457 | end 458 | 459 | test "it has a default body" do 460 | User.create!(email: "user@xample.com", password: "password") 461 | ArtVandelay::Export.new(User.all).email( 462 | to: ["recipient_1@examaple.com", "recipient_2@example.com"], 463 | from: "sender@example.com" 464 | ) 465 | email = ActionMailer::Base.deliveries.last 466 | 467 | assert_equal "User export", email.body.raw_source 468 | end 469 | 470 | test "it can set the body" do 471 | User.create!(email: "user@xample.com", password: "password") 472 | ArtVandelay::Export.new(User.all).email( 473 | to: ["recipient_1@examaple.com", "recipient_2@example.com"], 474 | from: "sender@example.com", 475 | body: "CUSTOM BODY" 476 | ) 477 | email = ActionMailer::Base.deliveries.last 478 | 479 | assert_equal "CUSTOM BODY", email.body.raw_source 480 | end 481 | end 482 | 483 | class Import < ArtVandelayTest 484 | test "it imports data from a CSV string" do 485 | csv_string = CSV.generate do |csv| 486 | csv << %w[email password] 487 | csv << %w[email_1@example.com s3krit] 488 | csv << %w[email_2@example.com s3kure!] 489 | end 490 | 491 | assert_difference("User.count", 2) do 492 | ArtVandelay::Import.new(:users).csv(csv_string) 493 | end 494 | 495 | user_1 = User.find_by!(email: "email_1@example.com") 496 | user_2 = User.find_by!(email: "email_2@example.com") 497 | 498 | assert_equal "email_1@example.com", user_1.email 499 | assert_equal "s3krit", user_1.password 500 | assert_equal "email_2@example.com", user_2.email 501 | assert_equal "s3kure!", user_2.password 502 | end 503 | 504 | test "it imports data from a JSON string" do 505 | json_string = [ 506 | {email: "email_1@example.com", password: "s3krit"}, 507 | {email: "email_2@example.com", password: "s3kure!"} 508 | ].to_json 509 | 510 | assert_difference("User.count", 2) do 511 | ArtVandelay::Import.new(:users).json(json_string) 512 | end 513 | 514 | user_1 = User.find_by!(email: "email_1@example.com") 515 | user_2 = User.find_by!(email: "email_2@example.com") 516 | 517 | assert_equal "email_1@example.com", user_1.email 518 | assert_equal "s3krit", user_1.password 519 | assert_equal "email_2@example.com", user_2.email 520 | assert_equal "s3kure!", user_2.password 521 | end 522 | 523 | test "it strips whitespace from CSVs when strip configuration is passed" do 524 | csv_string = CSV.generate do |csv| 525 | csv << [" email ", " password "] 526 | csv << [" email_1@example.com ", " s3krit "] 527 | csv << [" email_2@example.com ", " s3kure! "] 528 | end 529 | 530 | assert_difference("User.count", 2) do 531 | ArtVandelay::Import.new(:users, strip: true).csv(csv_string) 532 | end 533 | 534 | user_1 = User.find_by!(email: "email_1@example.com") 535 | user_2 = User.find_by!(email: "email_2@example.com") 536 | 537 | assert_equal "email_1@example.com", user_1.email 538 | assert_equal "s3krit", user_1.password 539 | assert_equal "email_2@example.com", user_2.email 540 | assert_equal "s3kure!", user_2.password 541 | end 542 | 543 | test "it strips whitespace from JSON when strip configuration is passed" do 544 | json_string = [ 545 | {email: " email_1@example.com ", password: " s3krit "}, 546 | {email: " email_2@example.com ", password: " s3kure! "} 547 | ].to_json 548 | 549 | assert_difference("User.count", 2) do 550 | ArtVandelay::Import.new(:users, strip: true).json(json_string) 551 | end 552 | 553 | user_1 = User.find_by!(email: "email_1@example.com") 554 | user_2 = User.find_by!(email: "email_2@example.com") 555 | 556 | assert_equal "email_1@example.com", user_1.email 557 | assert_equal "s3krit", user_1.password 558 | assert_equal "email_2@example.com", user_2.email 559 | assert_equal "s3kure!", user_2.password 560 | end 561 | 562 | test "it sets the CSV headers" do 563 | csv_string = CSV.generate do |csv| 564 | csv << %w[email_1@example.com s3krit] 565 | csv << %w[email_2@example.com s3kure!] 566 | end 567 | 568 | assert_difference("User.count", 2) do 569 | ArtVandelay::Import.new(:users).csv(csv_string, headers: [:email, :password]) 570 | end 571 | 572 | user_1 = User.find_by!(email: "email_1@example.com") 573 | user_2 = User.find_by!(email: "email_2@example.com") 574 | 575 | assert_equal "email_1@example.com", user_1.email 576 | assert_equal "s3krit", user_1.password 577 | assert_equal "email_2@example.com", user_2.email 578 | assert_equal "s3kure!", user_2.password 579 | end 580 | 581 | test "it maps CSV headers to Active Record attributes" do 582 | csv_string = CSV.generate do |csv| 583 | csv << %w[email_address passcode] 584 | csv << %w[email_1@example.com s3krit] 585 | csv << %w[email_2@example.com s3kure!] 586 | end 587 | 588 | assert_difference("User.count", 2) do 589 | ArtVandelay::Import.new(:users).csv(csv_string, attributes: {:email_address => :email, "passcode" => "password"}) 590 | end 591 | 592 | user_1 = User.find_by!(email: "email_1@example.com") 593 | user_2 = User.find_by!(email: "email_2@example.com") 594 | 595 | assert_equal "email_1@example.com", user_1.email 596 | assert_equal "s3krit", user_1.password 597 | assert_equal "email_2@example.com", user_2.email 598 | assert_equal "s3kure!", user_2.password 599 | end 600 | 601 | test "it maps JSON keys to Active Record attributes" do 602 | json_string = [ 603 | {email_address: "email_1@example.com", passcode: "s3krit"}, 604 | {email_address: "email_2@example.com", passcode: "s3kure!"} 605 | ].to_json 606 | 607 | assert_difference("User.count", 2) do 608 | ArtVandelay::Import 609 | .new(:users) 610 | .json( 611 | json_string, 612 | attributes: {:email_address => :email, "passcode" => "password"} 613 | ) 614 | end 615 | 616 | user_1 = User.find_by!(email: "email_1@example.com") 617 | user_2 = User.find_by!(email: "email_2@example.com") 618 | 619 | assert_equal "email_1@example.com", user_1.email 620 | assert_equal "s3krit", user_1.password 621 | assert_equal "email_2@example.com", user_2.email 622 | assert_equal "s3kure!", user_2.password 623 | end 624 | 625 | test "strips whitespace from CSVs if strip configuration is passed when using custom attributes" do 626 | csv_string = CSV.generate do |csv| 627 | csv << ["email_address ", " passcode "] 628 | csv << [" email_1@example.com ", " s3krit "] 629 | csv << [" email_2@example.com", " s3kure! "] 630 | end 631 | 632 | assert_difference("User.count", 2) do 633 | ArtVandelay::Import 634 | .new(:users, strip: true) 635 | .csv(csv_string, attributes: {:email_address => :email, "passcode" => "password"}) 636 | end 637 | 638 | user_1 = User.find_by!(email: "email_1@example.com") 639 | user_2 = User.find_by!(email: "email_2@example.com") 640 | 641 | assert_equal "email_1@example.com", user_1.email 642 | assert_equal "s3krit", user_1.password 643 | assert_equal "email_2@example.com", user_2.email 644 | assert_equal "s3kure!", user_2.password 645 | end 646 | 647 | test "strips whitespace from JSON if the strip configuration is passed when using custom attributes" do 648 | json_string = [ 649 | {email_address: " email_1@example.com ", passcode: " s3krit "}, 650 | {email_address: " email_2@example.com", passcode: " s3kure! "} 651 | ].to_json 652 | 653 | assert_difference("User.count", 2) do 654 | ArtVandelay::Import 655 | .new(:users, strip: true) 656 | .json( 657 | json_string, 658 | attributes: {:email_address => :email, "passcode" => "password"} 659 | ) 660 | end 661 | 662 | user_1 = User.find_by!(email: "email_1@example.com") 663 | user_2 = User.find_by!(email: "email_2@example.com") 664 | 665 | assert_equal "email_1@example.com", user_1.email 666 | assert_equal "s3krit", user_1.password 667 | assert_equal "email_2@example.com", user_2.email 668 | assert_equal "s3kure!", user_2.password 669 | end 670 | 671 | test "it no-ops if one record fails to save and 'rollback' is enabled" do 672 | csv_string = CSV.generate do |csv| 673 | csv << %w[email password] 674 | csv << %w[valid@example.com s3kure!] 675 | csv << %w[invalid@example.com] 676 | csv << %w[valid@example.com s3kure!] 677 | end 678 | 679 | json_string = [ 680 | {email: "valid@example.com", password: "s3kure!"}, 681 | {email: "invalid@example.com"}, 682 | {email: "invalid2@example.com", password: nil} 683 | ].to_json 684 | 685 | assert_no_difference("User.count") do 686 | assert_raises ActiveRecord::RecordInvalid do 687 | ArtVandelay::Import.new(:users, rollback: true).csv(csv_string) 688 | end 689 | end 690 | 691 | assert_no_difference("User.count") do 692 | assert_raises ActiveRecord::RecordInvalid do 693 | ArtVandelay::Import.new(:users, rollback: true).json(json_string) 694 | end 695 | end 696 | end 697 | 698 | test "it saves other records if another fails to save" do 699 | csv_string = CSV.generate do |csv| 700 | csv << %w[email password] 701 | csv << %w[valid_1@example.com s3kure!] 702 | csv << %w[invalid@example.com] 703 | csv << %w[valid_2@example.com s3kure!] 704 | end 705 | 706 | assert_difference("User.count", 2) do 707 | ArtVandelay::Import.new(:users).csv(csv_string) 708 | end 709 | 710 | json_string = [ 711 | {email: "valid_3@example.com", password: "s3kure!"}, 712 | {email: "invalid@example.com"}, 713 | {email: "invalid2@example.com", password: nil} 714 | ].to_json 715 | 716 | assert_difference("User.count", 1) do 717 | ArtVandelay::Import.new(:users).json(json_string) 718 | end 719 | end 720 | 721 | test "returns results" do 722 | csv_string = CSV.generate do |csv| 723 | csv << %w[email password] 724 | csv << %w[valid_1@example.com s3krit] 725 | csv << %w[invalid@example.com] 726 | csv << %w[valid_2@example.com s3krit] 727 | end 728 | 729 | csv_result = ArtVandelay::Import.new(:users).csv(csv_string) 730 | 731 | assert_equal( 732 | [ 733 | { 734 | row: ["valid_1@example.com", "s3krit"], 735 | id: User.find_by!(email: "valid_1@example.com").id 736 | }, 737 | { 738 | row: ["valid_2@example.com", "s3krit"], 739 | id: User.find_by!(email: "valid_2@example.com").id 740 | } 741 | ], 742 | csv_result.rows_accepted 743 | ) 744 | assert_equal( 745 | [ 746 | row: ["invalid@example.com", nil], 747 | errors: {password: [I18n.t("errors.messages.blank")]} 748 | ], 749 | csv_result.rows_rejected 750 | ) 751 | 752 | json_string = [ 753 | {email: "valid_3@example.com", password: "s3kure!"}, 754 | {email: "invalid@example.com"}, 755 | {email: "invalid2@example.com", password: nil} 756 | ].to_json 757 | 758 | json_result = ArtVandelay::Import.new(:users).json(json_string) 759 | 760 | assert_equal( 761 | [ 762 | { 763 | row: {"email" => "valid_3@example.com", "password" => "s3kure!"}, 764 | id: User.find_by!(email: "valid_3@example.com").id 765 | } 766 | ], 767 | json_result.rows_accepted 768 | ) 769 | 770 | assert_equal( 771 | [ 772 | { 773 | row: {"email" => "invalid@example.com"}, 774 | errors: {password: [I18n.t("errors.messages.blank")]} 775 | }, 776 | { 777 | row: {"email" => "invalid2@example.com", "password" => nil}, 778 | errors: {password: [I18n.t("errors.messages.blank")]} 779 | } 780 | ], 781 | json_result.rows_rejected 782 | ) 783 | end 784 | 785 | test "it returns results when rollback is enabled" do 786 | csv_string = CSV.generate do |csv| 787 | csv << %w[email password] 788 | csv << %w[valid_1@example.com s3krit] 789 | csv << %w[valid_2@example.com s3krit] 790 | end 791 | 792 | csv_result = ArtVandelay::Import.new(:users, rollback: true).csv(csv_string) 793 | 794 | assert_equal( 795 | [ 796 | { 797 | row: ["valid_1@example.com", "s3krit"], 798 | id: User.find_by!(email: "valid_1@example.com").id 799 | }, 800 | { 801 | row: ["valid_2@example.com", "s3krit"], 802 | id: User.find_by!(email: "valid_2@example.com").id 803 | } 804 | ], 805 | csv_result.rows_accepted 806 | ) 807 | assert_empty csv_result.rows_rejected 808 | 809 | json_string = [ 810 | {email: "valid_3@example.com", password: "s3kure!"} 811 | ].to_json 812 | 813 | json_result = 814 | ArtVandelay::Import.new(:users, rollback: true).json(json_string) 815 | 816 | assert_equal( 817 | [ 818 | { 819 | row: {"email" => "valid_3@example.com", "password" => "s3kure!"}, 820 | id: User.find_by!(email: "valid_3@example.com").id 821 | } 822 | ], 823 | json_result.rows_accepted 824 | ) 825 | assert_empty json_result.rows_rejected 826 | end 827 | 828 | test "it updates existing records" do 829 | # TODO: This requires more thought. We need a way to identify the 830 | # record(s) and declare we want to update them. This seems like it could 831 | # be a responsibility of a different class? 832 | skip 833 | end 834 | end 835 | end 836 | --------------------------------------------------------------------------------