csv-importer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 452e11ff0f75dce583a2034fde100790e0327508
4
+ data.tar.gz: 09230194e20902779a053687122249ea7fd612ef
5
+ SHA512:
6
+ metadata.gz: 0960dc9c1f6a8880c7ef7bf6ce83f2f38bd0c297be95890a22982fd0e6789f6fbcb4925f078538162c5cfcef0efdcc89bd06b7ff8f74c6e5e40b7b8c6bdb050d
7
+ data.tar.gz: 419168d5231b7a08a827090fdbed9c0bfe95df8d5d6e5900f024d7e3f736e1671803913ef22ffef3fbba9b8e409bb66852136c70da76b13b1908f179a5d6b6ee
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ .DS_Store
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
@@ -0,0 +1,6 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.2.1
4
+ addons:
5
+ code_climate:
6
+ repo_token: 0e005c563813539e6d0cdca9a3e95b3c0f1c79f32284f8eaa93b54c57303d427
@@ -0,0 +1,13 @@
1
+ # Contributor Code of Conduct
2
+
3
+ As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.
4
+
5
+ We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion.
6
+
7
+ Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct.
8
+
9
+ Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team.
10
+
11
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers.
12
+
13
+ This Code of Conduct is adapted from the [Contributor Covenant](http:contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/)
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in csv-importer.gemspec
4
+ gemspec
5
+
6
+ gem 'rspec'
7
+ gem 'guard-rspec'
8
+ gem 'activemodel'
9
+ gem 'simplecov', require: nil
10
+ gem "codeclimate-test-reporter", require: nil
@@ -0,0 +1,53 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ ## Uncomment and set this to only include directories you want to watch
5
+ # directories %w(app lib config test spec features)
6
+
7
+ ## Uncomment to clear the screen before every task
8
+ # clearing :on
9
+
10
+ ## Guard internally checks for changes in the Guardfile and exits.
11
+ ## If you want Guard to automatically start up again, run guard in a
12
+ ## shell loop, e.g.:
13
+ ##
14
+ ## $ while bundle exec guard; do echo "Restarting Guard..."; done
15
+ ##
16
+ ## Note: if you are using the `directories` clause above and you are not
17
+ ## watching the project directory ('.'), then you will want to move
18
+ ## the Guardfile to a watched dir and symlink it back, e.g.
19
+ #
20
+ # $ mkdir config
21
+ # $ mv Guardfile config/
22
+ # $ ln -s config/Guardfile .
23
+ #
24
+ # and, you'll have to watch "config/Guardfile" instead of "Guardfile"
25
+
26
+ # Note: The cmd option is now required due to the increasing number of ways
27
+ # rspec may be run, below are examples of the most common uses.
28
+ # * bundler: 'bundle exec rspec'
29
+ # * bundler binstubs: 'bin/rspec'
30
+ # * spring: 'bin/rspec' (This will use spring if running and you have
31
+ # installed the spring binstubs per the docs)
32
+ # * zeus: 'zeus rspec' (requires the server to be started separately)
33
+ # * 'just' rspec: 'rspec'
34
+
35
+ guard :rspec, cmd: "bundle exec rspec" do
36
+ require "guard/rspec/dsl"
37
+ dsl = Guard::RSpec::Dsl.new(self)
38
+
39
+ # Feel free to open issues for suggestions and improvements
40
+
41
+ # RSpec files
42
+ rspec = dsl.rspec
43
+ watch(rspec.spec_helper) { rspec.spec_dir }
44
+ watch(rspec.spec_support) { rspec.spec_dir }
45
+ watch(rspec.spec_files)
46
+
47
+ # Ruby files
48
+ ruby = dsl.ruby
49
+ dsl.watch_spec_files_for(ruby.lib_files)
50
+
51
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
52
+
53
+ end
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Philippe Creux
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,117 @@
1
+ # CSVImporter
2
+
3
+ Importing a CSV file is easy to code until real users attempt to import
4
+ real data.
5
+
6
+ CSVImporter aims to handle validations, column mapping, actual import
7
+ and reporting.
8
+
9
+ [![Build
10
+ Status](https://travis-ci.org/BrewhouseTeam/csv-importer.svg)](https://travis-ci.org/BrewhouseTeam/csv-importer)
11
+ [![Code
12
+ Climate](https://codeclimate.com/github/BrewhouseTeam/csv-importer/badges/gpa.svg)](https://codeclimate.com/github/BrewhouseTeam/csv-importer)
13
+ [![Test
14
+ Coverage](https://codeclimate.com/github/BrewhouseTeam/csv-importer/badges/coverage.svg)](https://codeclimate.com/github/BrewhouseTeam/csv-importer/coverage)
15
+
16
+ ## Usage
17
+
18
+ **This is still a work in progress**
19
+
20
+ Define your CSVImporter:
21
+
22
+ ```ruby
23
+ class ImportUserCSV
24
+ include CSVImporter
25
+
26
+ model User
27
+
28
+ column :email, to: ->(email) { email.downcase }, required: true
29
+ column :first_name, as: [ /first.?name/i, /pr(é|e)nom/i ]
30
+ column :last_name, to: :l_name
31
+ column :published, to: ->(published, model) { model.published_at = published ? Time.now : nil }
32
+
33
+ identifier :email # will find_or_update via :email
34
+
35
+ when_invalid :skip # or :abort
36
+ end
37
+ ```
38
+
39
+ Let's run an new import:
40
+
41
+ ```ruby
42
+ # Import a file (IOStream or file path) and from CSV content
43
+
44
+ import = ImportUserCSV.new(file: InputStream)
45
+ import = ImportUserCSV.new(path: String)
46
+ import = ImportUserCSV.new(content: String)
47
+
48
+ # Validate header
49
+
50
+ import.header.valid?
51
+ # => true if header is valid
52
+
53
+ import.header
54
+ # => returns an instance of `CSVImporter::Header`
55
+
56
+ import.header.missing_required_columns # => ["email"]
57
+ import.header.missing_columns # => ["email", "first_name"]
58
+ import.header.extra_columns # => ["zip_code"]
59
+ import.header.columns # => ["last_name", "zip_code"]
60
+
61
+ # Manipulate rows
62
+
63
+ import.rows
64
+ # => return a (lazy?) Array of Rows
65
+ row = rows.first
66
+
67
+ row.raw_string # => "bob@example.com,bob,,extra"
68
+ row.raw_array # => [ "bob@example.com", "bob", "", "extra" ]
69
+ row.csv_attributes # => { email: "bob@example.com", first_name: "bob" }
70
+ row.model # => User<email: "bob@example.com", f_name: "bob", id: nil>
71
+ row.valid? # delegate to model.valid?
72
+
73
+ # Time to run the import!
74
+
75
+ report = import.run!
76
+
77
+ # The following methods return arrays of `Row`
78
+ report.valid_rows
79
+ report.invalid_rows
80
+ report.created_rows
81
+ report.updated_rows
82
+ report.failed_to_create_rows
83
+ report.failed_to_update_rows
84
+
85
+ report.success? # => true
86
+ report.message # => "Import completed. 4 created, 2 updated, 1 failed to update"
87
+ ```
88
+
89
+ ## Installation
90
+
91
+ Add this line to your application's Gemfile:
92
+
93
+ ```ruby
94
+ gem 'csv-importer'
95
+ ```
96
+
97
+ And then execute:
98
+
99
+ $ bundle
100
+
101
+ Or install it yourself as:
102
+
103
+ $ gem install csv-importer
104
+
105
+ ## Development
106
+
107
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/console` for an interactive prompt that will allow you to experiment.
108
+
109
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release` to create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
110
+
111
+ ## Contributing
112
+
113
+ 1. Fork it ( https://github.com/BrewhouseTeam/csv-importer/fork )
114
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
115
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
116
+ 4. Push to the branch (`git push origin my-new-feature`)
117
+ 5. Create a new Pull Request
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ desc "Run specs"
4
+ task :test do
5
+ system("bundle exec rspec spec") || exit(-1)
6
+ end
7
+
8
+ task default: :test
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "csv_importer"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
6
+
7
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,25 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'csv_importer/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "csv-importer"
8
+ spec.version = CSVImporter::VERSION
9
+ spec.authors = ["Philippe Creux"]
10
+ spec.email = ["pcreux@gmail.com"]
11
+
12
+ spec.summary = %q{CSV Import for humans}
13
+ spec.homepage = "https://github.com/BrewhouseTeam/csv-importer"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
17
+ spec.bindir = "exe"
18
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "virtus"
22
+
23
+ spec.add_development_dependency "bundler", "~> 1.8"
24
+ spec.add_development_dependency "rake", "~> 10.0"
25
+ end
@@ -0,0 +1 @@
1
+ require "csv_importer"
@@ -0,0 +1,79 @@
1
+ require "csv"
2
+ require "virtus"
3
+
4
+ require "csv_importer/version"
5
+ require "csv_importer/csv_reader"
6
+ require "csv_importer/column_definition"
7
+ require "csv_importer/column"
8
+ require "csv_importer/header"
9
+ require "csv_importer/row"
10
+ require "csv_importer/report"
11
+ require "csv_importer/report_message"
12
+ require "csv_importer/runner"
13
+ require "csv_importer/config"
14
+ require "csv_importer/dsl"
15
+
16
+ # A class that includes CSVImporter inherit its DSL and methods.
17
+ #
18
+ # Example:
19
+ # class ImportUserCSV
20
+ # include CSVImporter
21
+ #
22
+ # model User
23
+ #
24
+ # column :email
25
+ # end
26
+ #
27
+ # report = ImportUserCSV.new(file: my_csv).run!
28
+ # puts report.message
29
+ #
30
+ module CSVImporter
31
+ class Error < StandardError; end
32
+
33
+ def self.included(klass)
34
+ klass.extend(Dsl)
35
+ klass.define_singleton_method(:csv_importer_config) do
36
+ @csv_importer_config ||= Config.new
37
+ end
38
+ end
39
+
40
+ # Defines the path, file or content of the csv file.
41
+ # Also allows you to overwrite the configuration at runtime.
42
+ #
43
+ # Example:
44
+ #
45
+ # .new(file: my_csv_file)
46
+ # .new(path: "subscribers.csv", model: newsletter.subscribers)
47
+ #
48
+ def initialize(*args)
49
+ @csv = CSVReader.new(*args)
50
+ @config = self.class.csv_importer_config.dup
51
+ @config.attributes = args.last
52
+ end
53
+
54
+ attr_reader :csv, :report, :config
55
+
56
+ # Initialize and return the `Header` for the current CSV file
57
+ def header
58
+ @header ||= Header.new(column_definitions: config.column_definitions, column_names: csv.header)
59
+ end
60
+
61
+ # Initialize and return the `Row`s for the current CSV file
62
+ def rows
63
+ csv.rows.map { |row_array| Row.new(header: header, row_array: row_array,
64
+ model_klass: config.model, identifier: config.identifier) }
65
+ end
66
+
67
+ # Run the import. Return a Report.
68
+ def run!
69
+ if header.valid?
70
+ @report = Runner.call(rows: rows, when_invalid: config.when_invalid)
71
+ else
72
+ @report = Report.new(status: :invalid_header, missing_columns: header.missing_required_columns)
73
+ end
74
+
75
+ rescue CSV::MalformedCSVError => e
76
+ @report = Report.new(status: :invalid_csv_file, parser_error: e.message)
77
+ end
78
+ end
79
+
@@ -0,0 +1,10 @@
1
+ module CSVImporter
2
+ # A Column from a CSV file with a `name` (from the csv file) and a matching
3
+ # `ColumnDefinition` if any.
4
+ class Column
5
+ include Virtus.model
6
+
7
+ attribute :name, String
8
+ attribute :definition, ColumnDefinition
9
+ end
10
+ end
@@ -0,0 +1,64 @@
1
+ module CSVImporter
2
+ # Define a column. Called from the DSL via `column.
3
+ #
4
+ # Examples
5
+ #
6
+ # # the csv column "email" will be assigned to the `email` attribute
7
+ # column :email
8
+ #
9
+ # # the csv column matching /email/i will be assigned to the `email` attribute
10
+ # column :email, as: /email/i
11
+ #
12
+ # # the csv column matching "First name" or "Prénom" will be assigned to the `first_name` attribute
13
+ # column :first_name, as: [/first ?name/i, /pr(é|e)nom/i]
14
+ #
15
+ # # the csv column "first_name" will be assigned to the `f_name` attribute
16
+ # column :first_name, to: :f_name
17
+ #
18
+ # # email will be downcased
19
+ # column :email, to: ->(email) { email.downcase }
20
+ #
21
+ # # transform `confirmed` to `confirmed_at`
22
+ # column :confirmed, to: ->(confirmed, model) do
23
+ # model.confirmed_at = confirmed == "true" ? Time.new(2012) : nil
24
+ # end
25
+ #
26
+ class ColumnDefinition
27
+ include Virtus.model
28
+
29
+ attribute :name, Symbol
30
+ attribute :to # Symbol or Proc
31
+ attribute :as # Symbol, String, Regexp, Array
32
+ attribute :required, Boolean
33
+
34
+ # The model attribute that this column targets
35
+ def attribute
36
+ if to.is_a?(Symbol)
37
+ to
38
+ else
39
+ name
40
+ end
41
+ end
42
+
43
+ # Return true if column definition matches the column name passed in.
44
+ def match?(column_name, search_query=(as || name))
45
+ return false if column_name.nil?
46
+
47
+ downcased_column_name = column_name.downcase
48
+ underscored_column_name = downcased_column_name.gsub(/\s+/, '_')
49
+
50
+ case search_query
51
+ when Symbol
52
+ underscored_column_name == search_query.to_s
53
+ when String
54
+ downcased_column_name == search_query.downcase
55
+ when Regexp
56
+ column_name =~ search_query
57
+ when Array
58
+ search_query.any? { |query| match?(column_name, query) }
59
+ else
60
+ raise Error, "Invalid `as`. Should be a Symbol, String, Regexp or Array - was #{as.inspect}"
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,12 @@
1
+ module CSVImporter
2
+ # The configuration of a CSVImporter
3
+ class Config
4
+ include Virtus.model
5
+
6
+ attribute :model
7
+ attribute :column_definitions, Array[ColumnDefinition], default: proc { [] }
8
+ attribute :identifier, Symbol
9
+ attribute :when_invalid, Symbol, default: proc { :skip }
10
+ end
11
+ end
12
+
@@ -0,0 +1,65 @@
1
+ module CSVImporter
2
+
3
+ # Reads, sanitize and parse a CSV file
4
+ class CSVReader
5
+ include Virtus.model
6
+
7
+ attribute :content, String
8
+ attribute :file # IO
9
+ attribute :path, String
10
+
11
+ def csv_rows
12
+ @csv_rows ||= begin
13
+ sane_content = sanitize_content(read_content)
14
+ separator = detect_separator(sane_content)
15
+ cells = CSV.parse(sane_content, col_sep: separator)
16
+ sanitize_cells(cells)
17
+ end
18
+ end
19
+
20
+ # Returns the header as an Array of Strings
21
+ def header
22
+ @header ||= csv_rows.first
23
+ end
24
+
25
+ # Returns the rows as an Array of Arrays of Strings
26
+ def rows
27
+ @rows ||= csv_rows[1..-1]
28
+ end
29
+
30
+ private
31
+
32
+ def read_content
33
+ if content
34
+ content
35
+ elsif file
36
+ file.read
37
+ elsif path
38
+ File.open(path).read
39
+ else
40
+ raise Error, "Please provide content, file, or path"
41
+ end
42
+ end
43
+
44
+ def sanitize_content(csv_content)
45
+ csv_content
46
+ .encode(Encoding.find('UTF-8'), {invalid: :replace, undef: :replace, replace: ''}) # Remove invalid byte sequences
47
+ .gsub(/\r\r?\n?/, "\n") # Replaces windows line separators with "\n"
48
+ end
49
+
50
+ SEPARATORS = [",", ";", "\t"]
51
+
52
+ def detect_separator(csv_content)
53
+ SEPARATORS.sort_by { |separator| csv_content.count(separator) }.last
54
+ end
55
+
56
+ # Strip cells
57
+ def sanitize_cells(rows)
58
+ rows.map do |cells|
59
+ cells.map do |cell|
60
+ cell.strip if cell
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,21 @@
1
+ module CSVImporter
2
+ # This Dsl extends a class that includes CSVImporter
3
+ # It is a thin proxy to the Config object
4
+ module Dsl
5
+ def model(model_klass)
6
+ csv_importer_config.model = model_klass
7
+ end
8
+
9
+ def column(name, options={})
10
+ csv_importer_config.column_definitions << options.merge(name: name)
11
+ end
12
+
13
+ def identifier(identifier)
14
+ csv_importer_config.identifier = identifier
15
+ end
16
+
17
+ def when_invalid(action)
18
+ csv_importer_config.when_invalid = action
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,61 @@
1
+ module CSVImporter
2
+
3
+ # The CSV Header
4
+ class Header
5
+ include Virtus.model
6
+
7
+ attribute :column_definitions, Array[ColumnDefinition]
8
+ attribute :column_names, Array[String]
9
+
10
+ def columns
11
+ column_names.map do |column_name|
12
+ Column.new(
13
+ name: column_name,
14
+ definition: find_column_definition(column_name)
15
+ )
16
+ end
17
+ end
18
+
19
+ def column_name_for_model_attribute(attribute)
20
+ if column = columns.find { |column| column.definition.attribute == attribute if column.definition }
21
+ column.name
22
+ end
23
+ end
24
+
25
+ def valid?
26
+ missing_required_columns.empty?
27
+ end
28
+
29
+ # Returns Array[String]
30
+ def required_columns
31
+ column_definitions.select(&:required?).map(&:name)
32
+ end
33
+
34
+ # Returns Array[String]
35
+ def extra_columns
36
+ columns.reject(&:definition).map(&:name).map(&:to_s)
37
+ end
38
+
39
+ # Returns Array[String]
40
+ def missing_required_columns
41
+ (column_definitions.select(&:required?) - columns.map(&:definition)).map(&:name).map(&:to_s)
42
+ end
43
+
44
+ # Returns Array[String]
45
+ def missing_columns
46
+ (column_definitions - columns.map(&:definition)).map(&:name).map(&:to_s)
47
+ end
48
+
49
+ private
50
+
51
+ def find_column_definition(name)
52
+ column_definitions.find do |column_definition|
53
+ column_definition.match?(name)
54
+ end
55
+ end
56
+
57
+ def column_definition_names
58
+ column_definitions.map(&:name).map(&:to_s)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,60 @@
1
+ module CSVImporter
2
+ # The Report you get back from an import.
3
+ #
4
+ # * It has a status (pending, invalid_csv_file, invalid_header, in_progress, done, aborted)
5
+ # * It lists out missing columns
6
+ # * It reports parser_error
7
+ # * It lists out (created / updated) * (success / failed) records
8
+ # * It provides a human readable message
9
+ #
10
+ class Report
11
+ include Virtus.model
12
+
13
+ attribute :status, Symbol, default: proc { :pending }
14
+
15
+ attribute :missing_columns, Array[Symbol], default: proc { [] }
16
+
17
+ attribute :parser_error, String
18
+
19
+ attribute :created_rows, Array[Row], default: proc { [] }
20
+ attribute :updated_rows, Array[Row], default: proc { [] }
21
+ attribute :failed_to_create_rows, Array[Row], default: proc { [] }
22
+ attribute :failed_to_update_rows, Array[Row], default: proc { [] }
23
+
24
+ attribute :message_generator, Class, default: proc { ReportMessage }
25
+
26
+ def valid_rows
27
+ created_rows + updated_rows
28
+ end
29
+
30
+ def invalid_rows
31
+ failed_to_create_rows + failed_to_update_rows
32
+ end
33
+
34
+ def all_rows
35
+ valid_rows + invalid_rows
36
+ end
37
+
38
+ def success?
39
+ done? && invalid_rows.empty?
40
+ end
41
+
42
+ def pending?; status == :pending; end
43
+ def in_progress?; status == :in_progress; end
44
+ def done?; status == :done; end
45
+ def aborted?; status == :aborted; end
46
+ def invalid_header?; status == :invalid_header; end
47
+ def invalid_csv_file?; status == :invalid_csv_file; end
48
+
49
+ def pending!; self.status = :pending; self; end
50
+ def in_progress!; self.status = :in_progress; self; end
51
+ def done!; self.status = :done; self; end
52
+ def aborted!; self.status = :aborted; self; end
53
+ def invalid_header!; self.status = :invalid_header; self; end
54
+ def invalid_csv_file!; self.status = :invalid_csv_file; self; end
55
+
56
+ def message
57
+ message_generator.call(self)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,54 @@
1
+ module CSVImporter
2
+ # Generate a human readable message for the given report.
3
+ class ReportMessage
4
+ def self.call(report)
5
+ new(report).to_s
6
+ end
7
+
8
+ def initialize(report)
9
+ @report = report
10
+ end
11
+
12
+ attr_accessor :report
13
+
14
+ def to_s
15
+ send("report_#{report.status}")
16
+ end
17
+
18
+ private
19
+
20
+ def report_pending
21
+ "Import hasn't started yet"
22
+ end
23
+
24
+ def report_in_progress
25
+ "Import in progress"
26
+ end
27
+
28
+ def report_done
29
+ "Import completed: " + import_details
30
+ end
31
+
32
+ def report_invalid_header
33
+ "The following columns are required: #{report.missing_columns.join(", ")}"
34
+ end
35
+
36
+ def report_invalid_csv_file
37
+ report.parser_error
38
+ end
39
+
40
+ def report_aborted
41
+ "Import aborted"
42
+ end
43
+
44
+ # Generate something like: "3 created. 4 updated. 1 failed to create. 2 failed to update."
45
+ def import_details
46
+ report.attributes
47
+ .select { |name, _| name["_rows"] }
48
+ .select { |_, instances| instances.size > 0 }
49
+ .map { |bucket, instances| "#{instances.size} #{bucket.to_s.gsub('_rows', '').gsub('_', ' ')}" }
50
+ .join(", ")
51
+ end
52
+
53
+ end # class ReportMessage
54
+ end
@@ -0,0 +1,91 @@
1
+ module CSVImporter
2
+ # A Row from the CSV file.
3
+ #
4
+ # Using the header, the model_klass and the identifier it builds the model
5
+ # to be persisted.
6
+ class Row
7
+ include Virtus.model
8
+
9
+ attribute :header, Header
10
+ attribute :row_array, Array[String]
11
+ attribute :model_klass
12
+ attribute :identifier
13
+
14
+ # The model to be persisted
15
+ def model
16
+ @model ||= begin
17
+ model = if identifier
18
+ find_or_build_model
19
+ else
20
+ build_model
21
+ end
22
+
23
+ set_attributes(model)
24
+ end
25
+ end
26
+
27
+ # A hash with this row's attributes
28
+ def csv_attributes
29
+ @csv_attributes ||= Hash[header.column_names.zip(row_array)]
30
+ end
31
+
32
+ # Set attributes
33
+ def set_attributes(model)
34
+ header.columns.each do |column|
35
+ value = csv_attributes[column.name]
36
+ column_definition = column.definition
37
+
38
+ next if column_definition.nil?
39
+
40
+ set_attribute(model, column_definition, value)
41
+ end
42
+
43
+ model
44
+ end
45
+
46
+ # Set the attribute using the column_definition and the csv_value
47
+ def set_attribute(model, column_definition, csv_value)
48
+ if column_definition.to && column_definition.to.is_a?(Proc)
49
+ to_proc = column_definition.to
50
+
51
+ case to_proc.arity
52
+ when 1 # to: ->(email) { email.downcase }
53
+ model.public_send("#{column_definition.name}=", to_proc.call(csv_value))
54
+ when 2 # to: ->(published, post) { post.published_at = Time.now if published == "true" }
55
+ to_proc.call(csv_value, model)
56
+ else
57
+ raise ArgumentError, "`to` proc can only have 1 or 2 arguments"
58
+ end
59
+ else
60
+ attribute = column_definition.attribute
61
+ model.public_send("#{attribute}=", csv_value)
62
+ end
63
+
64
+ model
65
+ end
66
+
67
+ # Error from the model mapped back to the CSV header if we can
68
+ def errors
69
+ Hash[
70
+ model.errors.map do |attribute, errors|
71
+ if column_name = header.column_name_for_model_attribute(attribute)
72
+ [column_name, errors]
73
+ else
74
+ [attribute, errors]
75
+ end
76
+ end
77
+ ]
78
+ end
79
+
80
+ def find_or_build_model
81
+ model = build_model
82
+ set_attributes(model)
83
+ value = model.public_send(identifier)
84
+ model_klass.public_send("find_by_#{identifier}", value) || build_model
85
+ end
86
+
87
+ def build_model
88
+ model_klass.new
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,88 @@
1
+ module CSVImporter
2
+ # Do the actual import.
3
+ #
4
+ # It iterates over the rows' models and persist them. It returns a `Report`.
5
+ class Runner
6
+ def self.call(*args)
7
+ new(*args).call
8
+ end
9
+
10
+ include Virtus.model
11
+
12
+ attribute :rows, Array[Row]
13
+ attribute :when_invalid, Symbol
14
+
15
+ attribute :report, Report, default: proc { Report.new }
16
+
17
+ ImportAborted = Class.new(StandardError)
18
+
19
+ # Persist the rows' model and return a `Report`
20
+ def call
21
+ if rows.empty?
22
+ report.done!
23
+ return report
24
+ end
25
+
26
+ report.in_progress!
27
+
28
+ persist_rows!
29
+
30
+ report.done!
31
+ report
32
+ rescue ImportAborted
33
+ report.aborted!
34
+ report
35
+ end
36
+
37
+ private
38
+
39
+ def abort_when_invalid?
40
+ when_invalid == :abort
41
+ end
42
+
43
+ def persist_rows!
44
+ transaction do
45
+ rows.each do |row|
46
+ tags = []
47
+
48
+ if row.model.persisted?
49
+ tags << :update
50
+ else
51
+ tags << :create
52
+ end
53
+
54
+ if row.model.save
55
+ tags << :success
56
+ else
57
+ tags << :failure
58
+ end
59
+
60
+ add_to_report(row, tags)
61
+ end
62
+ end
63
+ end
64
+
65
+ def add_to_report(row, tags)
66
+ bucket = case tags
67
+ when [ :create, :success ]
68
+ report.created_rows
69
+ when [ :create, :failure ]
70
+ report.failed_to_create_rows
71
+ when [ :update, :success ]
72
+ report.updated_rows
73
+ when [ :update, :failure ]
74
+ report.failed_to_update_rows
75
+ else
76
+ raise "Invalid tags #{tags.inspect}"
77
+ end
78
+
79
+ bucket << row
80
+
81
+ raise ImportAborted if abort_when_invalid? && tags[1] == :failure
82
+ end
83
+
84
+ def transaction(&block)
85
+ rows.first.model.class.transaction(&block)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,3 @@
1
+ module CSVImporter
2
+ VERSION = "0.1.0"
3
+ end
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: csv-importer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Philippe Creux
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2015-06-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: virtus
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.8'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.8'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ description:
56
+ email:
57
+ - pcreux@gmail.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - ".gitignore"
63
+ - ".rspec"
64
+ - ".travis.yml"
65
+ - CODE_OF_CONDUCT.md
66
+ - Gemfile
67
+ - Guardfile
68
+ - LICENSE.txt
69
+ - README.md
70
+ - Rakefile
71
+ - bin/console
72
+ - bin/setup
73
+ - csv-importer.gemspec
74
+ - lib/csv-importer.rb
75
+ - lib/csv_importer.rb
76
+ - lib/csv_importer/column.rb
77
+ - lib/csv_importer/column_definition.rb
78
+ - lib/csv_importer/config.rb
79
+ - lib/csv_importer/csv_reader.rb
80
+ - lib/csv_importer/dsl.rb
81
+ - lib/csv_importer/header.rb
82
+ - lib/csv_importer/report.rb
83
+ - lib/csv_importer/report_message.rb
84
+ - lib/csv_importer/row.rb
85
+ - lib/csv_importer/runner.rb
86
+ - lib/csv_importer/version.rb
87
+ homepage: https://github.com/BrewhouseTeam/csv-importer
88
+ licenses:
89
+ - MIT
90
+ metadata: {}
91
+ post_install_message:
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubyforge_project:
107
+ rubygems_version: 2.4.5
108
+ signing_key:
109
+ specification_version: 4
110
+ summary: CSV Import for humans
111
+ test_files: []