active_record_importer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 43724c0cedd35917d14e3439f96482c8f2357943
4
+ data.tar.gz: 7db97349cd0eafa87a2296906f56e6543c58395f
5
+ SHA512:
6
+ metadata.gz: 22f7c0e2eb463b49e0a717d1ee4da685c111424681f20cbc2f578ebe5bbbab57277384f05715aca3abf22ffb25fe957efe8820ee916d6add08cc8ba82fa82e84
7
+ data.tar.gz: e90f8ab8ce49228e47441a3df8adcd214547fec041583912760e0792b1b6eff631f8378723d9cc73af23fbcc4605b8c58ee59578ca433a1c8a6070f413026502
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ /projectFilesBackup
11
+ /.idea
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.3.0
4
+ before_install: gem install bundler -v 1.10.6
@@ -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, ethnicity, 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,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in active_record_importer.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 TODO: Write your name
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.
data/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # ActiveRecordImporter
2
+
3
+ Supports only Rails 4... (I'll release soon an update to support Rails 5)
4
+
5
+ This gem helps you insert/update records easily. For now, it only accepts CSV file.
6
+ This also helps you monitor how many rows are imported, and how many rows failed.
7
+ I'll release an update to enable this on background job.
8
+ This gem allows you to easily import to any model with few configurations.
9
+
10
+ ## Installation
11
+
12
+ Add this line to your application's Gemfile:
13
+
14
+ ```ruby
15
+ gem 'active_record_importer'
16
+ ```
17
+
18
+ And then execute:
19
+
20
+ $ bundle
21
+
22
+ Or install it yourself as:
23
+
24
+ $ gem install active_record_importer
25
+
26
+ ## Usage
27
+
28
+ Simple usage for now:
29
+
30
+ ### Create Import table/model
31
+ I'll add a generator on my next release
32
+ #### DB Migration:
33
+ ```ruby
34
+ class ActiveRecordImporterMigration < ActiveRecord::Migration
35
+ def change
36
+ create_table :imports do |t|
37
+ t.attachment :file
38
+ t.attachment :failed_file
39
+ t.text :properties
40
+ t.string :resource, null: false
41
+ t.integer :imported_rows, default: 0
42
+ t.integer :failed_rows, default: 0
43
+ t.timestamps
44
+ end
45
+ end
46
+ end
47
+ ```
48
+
49
+ #### Import Model:
50
+ ```ruby
51
+ class Import < ActiveRecord::Base
52
+ extend Enumerize
53
+ store :properties, accessors: %i(insert_method find_options batch_size)
54
+
55
+ enumerize :insert_method,
56
+ in: %w(insert upsert error_duplicate),
57
+ default: :upsert
58
+
59
+ has_attached_file :file
60
+
61
+ attr_accessor :execute_on_create
62
+
63
+ validates :resource, presence: true
64
+ validate :check_presence_of_find_options
65
+ validates_attachment :file,
66
+ content_type: {
67
+ content_type: %w(text/plain text/csv)
68
+ }
69
+
70
+ after_create :execute, if: :execute_on_create
71
+
72
+ # I'll add import options in the next release
73
+ # accepts_nested_attributes_for :import_options, allow_destroy: true
74
+
75
+ def execute
76
+ resource_class.import!(self, execute_on_create)
77
+ end
78
+
79
+ def resource_class
80
+ resource.safe_constantize
81
+ end
82
+
83
+ def batch_size
84
+ super.to_i
85
+ end
86
+
87
+ ##
88
+ # Override this if you prefer have
89
+ # a private permissions or you have
90
+ # private methods for reading files
91
+ ##
92
+ def import_file
93
+ local_path? ? file.path : file.url
94
+ end
95
+
96
+ private
97
+
98
+ def check_presence_of_find_options
99
+ return if insert_method.insert?
100
+ errors.add(:find_options, "can't be blank") if find_options.blank?
101
+ end
102
+
103
+ def local_path?
104
+ File.exist? import_file.file.path
105
+ end
106
+ end
107
+ ```
108
+
109
+ Add `acts_as_importable` to a model to make it importable
110
+ ```ruby
111
+ class User < ActiveRecord::Base
112
+ acts_as_importable
113
+ end
114
+ ```
115
+
116
+ You may also add import options:
117
+ ```ruby
118
+ class User < ActiveRecord::Base
119
+ acts_as_importable default_attributes: { first_name: 'Juan',
120
+ last_name: 'dela Cruz' },
121
+ find_options: %i(email),
122
+ before_save: Proc.new { |user| user.password = 'temporarypassword123' }
123
+ end
124
+ ```
125
+
126
+ You may also add some options from the SmarterCSV gem:
127
+
128
+ | Option | Default
129
+ --------------------------------------------------------------
130
+ | :convert_values_to_metric | nil
131
+ | :value_converters | nil
132
+ | :remove_empty_values | false
133
+ | :comment_regexp | Regexp.new(/^#=>/)
134
+ | :force_utf8 | true
135
+ | :chunk_size | 500
136
+ | :col_sep | ","
137
+
138
+ #### I'll add more options SOON!
139
+
140
+ #### Imports Controller:
141
+ ```ruby
142
+ class ImportsController < ApplicationController
143
+
144
+ def create
145
+ @import = Import.create!(import_params)
146
+ @import.execute_on_create = true
147
+ @import.execute
148
+ end
149
+
150
+ end
151
+ ```
152
+
153
+ You may include execute_on_create as a checkbox field in your Import form so you don't have to
154
+ include `@import.execute_on_create = true` in your controller.
155
+
156
+
157
+
158
+ ## Development
159
+
160
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
161
+
162
+ 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`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
163
+
164
+ ## Contributing
165
+
166
+ Bug reports and pull requests are welcome on GitHub at https://github.com/michaelnera/active_record_importer. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](contributor-covenant.org) code of conduct.
167
+
168
+
169
+ ## License
170
+
171
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
172
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,30 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'active_record_importer/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'active_record_importer'
8
+ spec.version = ActiveRecordImporter::VERSION
9
+ spec.authors = ['Michael Nera']
10
+ spec.email = ['kapitan_03@yahoo.com']
11
+
12
+ spec.summary = 'Active Record Importer'
13
+ spec.description = 'Smart gem for importing rails models'
14
+ spec.homepage = 'https://github.com/michaelnera/active_record_importer'
15
+ spec.license = 'MIT'
16
+
17
+ spec.files = `git ls-files`.split($/)
18
+ spec.bindir = 'exe'
19
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
+ spec.require_paths = ['lib']
21
+
22
+ spec.add_development_dependency 'rspec', '~> 2.99'
23
+
24
+ spec.add_runtime_dependency 'activerecord', '>= 4.0'
25
+ spec.add_runtime_dependency 'activesupport', '>= 4.0'
26
+ spec.add_runtime_dependency 'enumerize', '>= 2.0', '~> 2.0.1'
27
+ spec.add_runtime_dependency 'virtus', '>= 1.0', '~> 1.0'
28
+ spec.add_runtime_dependency 'smarter_csv', '>= 1.0', '~> 1.0'
29
+ spec.add_runtime_dependency 'paperclip', '>= 4.0', '~> 4.0'
30
+ end
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "active_record_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
data/bin/setup ADDED
@@ -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,13 @@
1
+ class ActiveRecordImporterMigration < ActiveRecord::Migration
2
+ def change
3
+ create_table :imports do |t|
4
+ t.attachment :file
5
+ t.attachment :failed_file
6
+ t.text :properties
7
+ t.string :resource, null: false
8
+ t.integer :imported_rows, default: 0
9
+ t.integer :failed_rows, default: 0
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,27 @@
1
+ require 'active_record_importer/version'
2
+ require 'active_support/core_ext/module'
3
+ require 'virtus'
4
+ require 'enumerize'
5
+ require 'smarter_csv'
6
+ require 'paperclip'
7
+
8
+ module ActiveRecordImporter
9
+ extend ActiveSupport::Autoload
10
+
11
+ ::IMPORTABLES = []
12
+
13
+ autoload :BatchImporter, 'active_record_importer/batch_importer'
14
+ autoload :DataProcessor, 'active_record_importer/data_processor'
15
+ autoload :Dispatcher, 'active_record_importer/dispatcher'
16
+ autoload :Errors, 'active_record_importer/errors'
17
+ autoload :Importable, 'active_record_importer/importable'
18
+ autoload :InstanceBuilder, 'active_record_importer/instance_builder'
19
+ autoload :OptionsBuilder, 'active_record_importer/options_builder'
20
+ autoload :AttributesBuilder, 'active_record_importer/attributes_builder'
21
+ autoload :FindOptionsBuilder, 'active_record_importer/find_options_builder'
22
+ autoload :TransitionProcessor, 'active_record_importer/transition_processor'
23
+ autoload :ImportCallbacker, 'active_record_importer/import_callbacker'
24
+ autoload :Helpers, 'active_record_importer/helpers'
25
+
26
+ require 'active_record_importer/railtie' if defined?(Rails) && Rails::VERSION::MAJOR >= 3
27
+ end
@@ -0,0 +1,67 @@
1
+ module ActiveRecordImporter
2
+ class AttributesBuilder
3
+ include Helpers
4
+
5
+ attr_reader :importable, :row_attrs, :processed_attrs
6
+
7
+ delegate :importer_options, to: :importable
8
+ delegate :importable_columns,
9
+ :default_attributes,
10
+ :find_assoc_opts,
11
+ to: :importer_options
12
+
13
+ def initialize(importable, row_attrs)
14
+ @importable = importable
15
+ @row_attrs = row_attrs
16
+ @processed_attrs = {}
17
+ end
18
+
19
+ def build
20
+ force_encode_attributes
21
+ fetch_time_attributes
22
+ processed_attrs
23
+ end
24
+
25
+ private
26
+
27
+ def default_attrs
28
+ def_attrs = { importing: true }
29
+ default_attributes.each do |key, value|
30
+ def_attrs[key] = fetch_value(value)
31
+ end
32
+ end
33
+
34
+ def force_encode_attributes
35
+ @processed_attrs = force_utf8_encode(merged_attributes)
36
+ end
37
+
38
+ def fetch_time_attributes
39
+ @processed_attrs.merge!(time_attributes(processed_attrs))
40
+ end
41
+
42
+ def fetch_value(value)
43
+ case value
44
+ when Proc
45
+ value.call(row_attrs)
46
+ when Symbol
47
+ importable.send(value, row_attrs)
48
+ else
49
+ value
50
+ end
51
+ end
52
+
53
+ def merged_attributes
54
+ attributes = row_attrs.slice(*importable_columns)
55
+ row_attrs = attributes.inject({}) do |row_attrs, key_value|
56
+ row_attrs[key_value.first] = key_value.last if key_value.last.present?
57
+ row_attrs
58
+ end
59
+ default_attrs.merge(row_attrs)
60
+ end
61
+
62
+ def has_column?(column)
63
+ return if column.blank?
64
+ importable_columns.include?(column.to_sym)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,40 @@
1
+ module ActiveRecordImporter
2
+ class BatchImporter
3
+
4
+ attr_reader :data, :import
5
+
6
+ def initialize(import, data)
7
+ @import = import
8
+ @data = data
9
+ end
10
+
11
+ def process!
12
+ @imported_count, @failed_count = 0, 0
13
+
14
+ data.each do |row|
15
+ next if row.blank?
16
+ processor = DataProcessor.new(import, row.symbolize_keys!)
17
+ processor.process ? @imported_count += 1 : @failed_count += 1
18
+ end
19
+
20
+ set_import_count
21
+ end
22
+
23
+ private
24
+
25
+ def set_import_count
26
+ Import.update_counters(import.id, imported_rows: @imported_count)
27
+ Import.update_counters(import.id, failed_rows: @failed_count)
28
+ end
29
+
30
+ def importable
31
+ import.resource.safe_constantize
32
+ end
33
+
34
+ delegate :importer_options, to: :importable
35
+
36
+ def csv_options
37
+ importer_options.csv_opts.to_hash
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,101 @@
1
+ module ActiveRecordImporter
2
+ class DataProcessor
3
+ attr_reader :importable, :import, :instance, :attributes,
4
+ :row_errors, :row_attrs, :find_attributes
5
+
6
+ delegate :importer_options,
7
+ to: :importable
8
+
9
+ def initialize(import, row_attrs)
10
+ @import = import
11
+ @importable = import.resource.safe_constantize
12
+ @row_attrs = row_attrs
13
+ end
14
+
15
+ def process
16
+ fetch_instance_attributes
17
+ fetch_find_attributes
18
+ create_instance
19
+ end
20
+
21
+ private
22
+
23
+ def create_instance
24
+ ActiveRecord::Base.transaction do
25
+ begin
26
+ @instance =
27
+ InstanceBuilder.new(
28
+ import, find_attributes,
29
+ attributes_without_state_machine_attrs
30
+ ).build
31
+
32
+ methods_after_upsert
33
+ true
34
+ rescue => exception
35
+ append_errors(exception, true)
36
+ end
37
+ end
38
+ end
39
+
40
+ def fetch_instance_attributes
41
+ @attributes = AttributesBuilder.new(
42
+ importable, row_attrs
43
+ ).build
44
+ rescue => exception
45
+ append_errors(exception)
46
+ end
47
+
48
+ def fetch_find_attributes
49
+ @find_attributes = FindOptionsBuilder.new(
50
+ resource: import.resource,
51
+ find_options: import.find_options,
52
+ attrs: attributes
53
+ ).build
54
+ rescue => exception
55
+ append_errors(exception)
56
+ end
57
+
58
+ def methods_after_upsert
59
+ return if instance.blank? || !instance.persisted?
60
+
61
+ state_transitions
62
+ run_after_save_callbacks
63
+ end
64
+
65
+ delegate :after_save,
66
+ :state_machine_attr,
67
+ to: :importer_options
68
+
69
+ def state_transitions
70
+ return if state_machine_attr.blank?
71
+
72
+ state_machine_attr.each do |attr|
73
+ state = row_attrs[attr.to_sym]
74
+ next if state.blank? || state == instance.send(attr)
75
+ TransitionProcessor.new(instance, state, attr).transit
76
+ end
77
+ end
78
+
79
+ def attributes_without_state_machine_attrs
80
+ attributes.except(*state_machine_attr)
81
+ end
82
+
83
+ def skip_callbacks?
84
+ after_save.blank? || instance.blank? || instance.new_record?
85
+ end
86
+
87
+ def run_after_save_callbacks
88
+ return if skip_callbacks?
89
+
90
+ ImportCallbacker.new(instance, after_save).call
91
+ end
92
+
93
+ def append_errors(error, rollback = false)
94
+ return if error.blank?
95
+
96
+ message = error.is_a?(Exception) ? error.message : error
97
+ @row_errors = message
98
+ fail ActiveRecord::Rollback if rollback
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,48 @@
1
+ module ActiveRecordImporter
2
+ class Dispatcher
3
+ attr_reader :import, :execute
4
+
5
+ def initialize(import_id, execute = true)
6
+ @import = Import.find(import_id)
7
+ @execute = execute
8
+ end
9
+
10
+ def call
11
+ divide_and_conquer
12
+ end
13
+
14
+ private
15
+
16
+ def divide_and_conquer
17
+ File.open(import.import_file, 'r:bom|utf-8') do |file|
18
+ SmarterCSV.process(file, csv_options) do |collection|
19
+ queue_or_execute(collection)
20
+ end
21
+ end
22
+ end
23
+
24
+ def csv_options
25
+ klass = import.resource.safe_constantize
26
+ opts = klass_csv_opts(klass)
27
+ return opts if import.batch_size.blank? || import.batch_size < 1
28
+ opts.merge(chunk_size: import.batch_size)
29
+ end
30
+
31
+ def klass_csv_opts(klass)
32
+ klass.importer_options.csv_opts.to_hash
33
+ end
34
+
35
+ def queue_or_execute(collection)
36
+ process_import(collection) if execute
37
+ queue(collection)
38
+ end
39
+
40
+ def process_import(collection)
41
+ BatchImporter.new(import, collection).process!
42
+ end
43
+
44
+ def queue(collection)
45
+ # To follow
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,4 @@
1
+ module ActiveRecordImporter
2
+ class Engine < Rails::Engine
3
+ end
4
+ end
@@ -0,0 +1,17 @@
1
+ module ActiveRecordImporter
2
+ module Errors
3
+ class InvalidTransition < StandardError
4
+ def initialize
5
+ super 'Transition is invalid'
6
+ end
7
+ end
8
+
9
+ class MissingFindByOption < StandardError
10
+ def initialize
11
+ super 'Find by option for your csv is missing or incorrect.'
12
+ end
13
+ end
14
+
15
+ class DuplicateRecord < StandardError; end
16
+ end
17
+ end
@@ -0,0 +1,52 @@
1
+ module ActiveRecordImporter
2
+ class FindOptionsBuilder
3
+ include Virtus.model
4
+ include Helpers
5
+
6
+ attribute :resource, String
7
+ attribute :attrs, Hash, default: {}
8
+ attribute :find_options, String
9
+ attribute :prefix, String
10
+
11
+ def build
12
+ get_find_opts
13
+ slice_attributes
14
+ end
15
+
16
+ private
17
+
18
+ def klass
19
+ resource.safe_constantize
20
+ end
21
+
22
+ delegate :importer_options, to: :klass
23
+
24
+ delegate :required_attributes, to: :importer_options
25
+
26
+ def get_find_opts
27
+ @options = strip_and_symbolize
28
+ @options ||= importer_options.find_options || required_attributes
29
+ @options
30
+ end
31
+
32
+ def slice_attributes
33
+ return attrs.slice(*@options).compact if prefix.blank?
34
+
35
+ @options.inject({}) do |attr, key|
36
+ attr[key] = attrs[prefixed_key(key)].presence
37
+ attr
38
+ end.compact
39
+ end
40
+
41
+ def prefixed_key(key)
42
+ "#{prefix}#{key}".to_sym
43
+ end
44
+
45
+ def strip_and_symbolize
46
+ return if find_options.blank?
47
+ find_options.split(',').map do |key|
48
+ key.strip.to_sym
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,26 @@
1
+ module ActiveRecordImporter
2
+ module Helpers
3
+
4
+ def parse_datetime(datetime = nil)
5
+ return if datetime.blank?
6
+ Time.parse(datetime)
7
+ end
8
+
9
+ def force_utf8_encode(data = {})
10
+ return data if data.blank?
11
+
12
+ data.keys.each do |key|
13
+ data[key] = data[key].force_encoding('UTF-8') if data[key].is_a?(String)
14
+ end
15
+
16
+ data
17
+ end
18
+
19
+ def time_attributes(data = {})
20
+ attrs = {}
21
+ attrs[:created_at] = parse_datetime(data[:created_at]) || Time.now
22
+ attrs[:updated_at] = parse_datetime(data[:updated_at]) || attrs[:created_at]
23
+ attrs
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,30 @@
1
+ module ActiveRecordImporter
2
+ class ImportCallbacker
3
+ attr_reader :object, :callback_methods
4
+
5
+ def initialize(object, callback_methods)
6
+ @object = object
7
+ @callback_methods = callback_methods
8
+ end
9
+
10
+ def call
11
+ case callback_methods
12
+ when Array
13
+ run_each_callbacks
14
+ when Symbol
15
+ object.send(callback)
16
+ when Proc
17
+ callback.call(object)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def run_each_callbacks
24
+ callback_methods.each do |callback|
25
+ object.send(callback) if callback.is_a?(Symbol)
26
+ callback.call(object) if callback.is_a?(Proc)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,76 @@
1
+ module ActiveRecordImporter
2
+ module Importable
3
+ def self.included(base)
4
+ base.extend ClassMethods
5
+ end
6
+
7
+ module ClassMethods
8
+ ##
9
+ # Make a model importable
10
+ # This will allow a model to use the importer
11
+ #
12
+ # class User < ActiveRecord::Base
13
+ # acts_as_importable
14
+ # end
15
+ #
16
+ # You may also add options:
17
+ # class User < ActiveRecord::Base
18
+ # acts_as_importable default_attributes: {first_name: 'Michael', surname: 'Nera'}
19
+ # end
20
+ ##
21
+ def acts_as_importable(options = {})
22
+ @@importer_options = OptionsBuilder.build(options.merge(allowed_columns_hash(options)))
23
+ ::IMPORTABLES << self.name unless ::IMPORTABLES.include?(self.name)
24
+
25
+ include ActiveRecordImporter::Importable::InstanceMethods
26
+ end
27
+
28
+ def importer_options
29
+ @@importer_options
30
+ end
31
+
32
+ def import!(import_object, execute = true)
33
+ ActiveRecordImporter::Dispatcher.new(
34
+ import_object.id, execute).call
35
+ end
36
+
37
+ private
38
+
39
+ def allowed_columns_hash(options = {})
40
+ {
41
+ importable_columns: allowed_columns(options)
42
+ }
43
+ end
44
+
45
+ def allowed_columns(options = {})
46
+ all_columns + store_accessors(options) - excluded_columns(options)
47
+ end
48
+
49
+ def all_columns
50
+ return [] unless (self.respond_to?(:table_exists?) && self.table_exists?)
51
+ self.columns.map { |column| column.name.to_sym }
52
+ end
53
+
54
+ def store_accessors(options = {})
55
+ options.fetch(:store_accessors, [])
56
+ end
57
+
58
+ def importable_columns
59
+ importer_options.importable_columns
60
+ end
61
+
62
+ def excluded_columns(options = {})
63
+ columns = [:id]
64
+ columns + options.fetch(:exclude_columns, [])
65
+ end
66
+ end
67
+
68
+ module InstanceMethods
69
+ attr_accessor :importing
70
+
71
+ def importing?
72
+ @importing ||= false
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,56 @@
1
+ module ActiveRecordImporter
2
+ class InstanceBuilder
3
+ attr_reader :attributes, :find_attributes, :import
4
+
5
+ def initialize(import, find_attributes, attributes)
6
+ @import = import
7
+ @find_attributes = find_attributes
8
+ @attributes = attributes
9
+ end
10
+
11
+ def build
12
+ instance = initialize_instance
13
+ process_data(instance)
14
+ end
15
+
16
+ private
17
+
18
+ delegate :insert_method, to: :import
19
+
20
+ def initialize_instance
21
+ return klass.new if insert_method.insert?
22
+
23
+ fail Errors::MissingFindByOption if find_attributes.blank?
24
+ klass.find_or_initialize_by(find_attributes)
25
+ end
26
+
27
+ def klass
28
+ import.resource.safe_constantize
29
+ end
30
+
31
+ delegate :importer_options, to: :klass
32
+ delegate :before_save, to: :importer_options
33
+
34
+ def process_data(instance)
35
+ fail Errors::DuplicateRecord if error_duplicate?(instance)
36
+
37
+ before_save_callback(instance)
38
+ assign_attrs_and_save!(instance)
39
+ end
40
+
41
+ def before_save_callback(instance)
42
+ return if before_save.blank?
43
+ ImportCallbacker.new(instance, before_save).call
44
+ end
45
+
46
+ def assign_attrs_and_save!(instance)
47
+ instance.attributes = attributes
48
+ instance.save!
49
+ instance
50
+ end
51
+
52
+ def error_duplicate?(instance)
53
+ instance.persisted? && insert_method.error_duplicate?
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,45 @@
1
+ module ActiveRecordImporter
2
+ class OptionsBuilder
3
+ def self.build(options = {})
4
+ ImporterOptions.new(options)
5
+ end
6
+ end
7
+
8
+ #
9
+ # SmarterCSV is used to process the csv file
10
+ # https://github.com/tilo/smarter_csv
11
+ # I used some of it's options
12
+ # Please refer to it's documentation
13
+ #
14
+ class CsvOptions
15
+ include Virtus.value_object
16
+
17
+ values do
18
+ attribute :convert_values_to_numeric
19
+ attribute :value_converters, Hash, default: nil
20
+ attribute :remove_empty_values, Boolean, default: false
21
+ attribute :comment_regexp, Regexp, default: Regexp.new(/^#=>/)
22
+ attribute :force_utf8, Boolean, default: true
23
+ attribute :chunk_size, Integer, default: 500
24
+ attribute :col_sep, String, default: ','
25
+ end
26
+ end
27
+
28
+ #
29
+ # Import Option for every IMPORTABLE model
30
+ #
31
+ class ImporterOptions
32
+ include Virtus.model
33
+
34
+ attribute :find_options, Array
35
+ attribute :exclude_from_find_options, Array
36
+ attribute :scope, Symbol
37
+ attribute :importable_columns, Array
38
+ attribute :default_attributes, Hash
39
+ attribute :csv_opts, CsvOptions, default: CsvOptions.new
40
+ attribute :find_assoc_opts, Hash
41
+ attribute :before_save
42
+ attribute :after_save
43
+ attribute :state_machine_attr, Array
44
+ end
45
+ end
@@ -0,0 +1,13 @@
1
+ require 'rails'
2
+
3
+ module ActiveRecordImporter
4
+ class Railtie < Rails::Railtie
5
+
6
+ initializer "active_record_importer.active_record" do |_app|
7
+ ActiveSupport.on_load :active_record do
8
+ include ActiveRecordImporter::Importable
9
+ end
10
+ end
11
+
12
+ end
13
+ end
@@ -0,0 +1,39 @@
1
+ module ActiveRecordImporter
2
+ class TransitionProcessor
3
+
4
+ attr_reader :object, :column, :new_state
5
+
6
+ def initialize(object, new_state, column = :state)
7
+ @object = object
8
+ @new_state = new_state
9
+ @column = column
10
+ end
11
+
12
+ def transit
13
+ fire_event!(transit_event)
14
+ true
15
+ end
16
+
17
+ private
18
+
19
+ def fire_event!(event)
20
+ fail Errors::InvalidTransition if event.blank?
21
+ object.send(event)
22
+ end
23
+
24
+ def transit_event
25
+ state_transitions.each do |trans|
26
+ return trans.event if trans.from == state_was && trans.to == new_state
27
+ end
28
+ nil
29
+ end
30
+
31
+ def state_transitions
32
+ object.send("#{column}_transitions")
33
+ end
34
+
35
+ def state_was
36
+ object.send("#{column}_was")
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,3 @@
1
+ module ActiveRecordImporter
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,58 @@
1
+ module ActiveRecordImporter
2
+ class Import < ActiveRecord::Base
3
+ extend Enumerize
4
+ store :properties, accessors: %i(insert_method find_options batch_size)
5
+
6
+ enumerize :insert_method,
7
+ in: %w(insert upsert error_duplicate),
8
+ default: :upsert
9
+
10
+ has_attached_file :file
11
+
12
+ attr_accessor :execute_on_create
13
+
14
+ validates :resource, presence: true
15
+ validate :check_presence_of_find_options
16
+ validates_attachment :file,
17
+ content_type: {
18
+ content_type: %w(text/plain text/csv)
19
+ }
20
+
21
+ after_create :execute, if: :execute_on_create
22
+
23
+ # I'll add import options in the next release
24
+ # accepts_nested_attributes_for :import_options, allow_destroy: true
25
+
26
+ def execute
27
+ resource_class.import!(self, execute_on_create)
28
+ end
29
+
30
+ def resource_class
31
+ resource.safe_constantize
32
+ end
33
+
34
+ def batch_size
35
+ super.to_i
36
+ end
37
+
38
+ ##
39
+ # Override this if you prefer have
40
+ # a private permissions or you have
41
+ # private methods for reading files
42
+ ##
43
+ def import_file
44
+ local_path? ? file.path : file.url
45
+ end
46
+
47
+ private
48
+
49
+ def check_presence_of_find_options
50
+ return if insert_method.insert?
51
+ errors.add(:find_options, "can't be blank") if find_options.blank?
52
+ end
53
+
54
+ def local_path?
55
+ File.exist? import_file.file.path
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,11 @@
1
+ require 'spec_helper'
2
+
3
+ describe ActiveRecordImporter do
4
+ it 'has a version number' do
5
+ expect(ActiveRecordImporter::VERSION).not_to be nil
6
+ end
7
+
8
+ it 'does something useful' do
9
+ expect(false).to eq(true)
10
+ end
11
+ end
@@ -0,0 +1,2 @@
1
+ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
2
+ require 'active_record_importer'
metadata ADDED
@@ -0,0 +1,197 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_record_importer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Michael Nera
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2017-02-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.99'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.99'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '4.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '4.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activesupport
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '4.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '4.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: enumerize
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '2.0'
62
+ - - "~>"
63
+ - !ruby/object:Gem::Version
64
+ version: 2.0.1
65
+ type: :runtime
66
+ prerelease: false
67
+ version_requirements: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '2.0'
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 2.0.1
75
+ - !ruby/object:Gem::Dependency
76
+ name: virtus
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '1.0'
82
+ - - "~>"
83
+ - !ruby/object:Gem::Version
84
+ version: '1.0'
85
+ type: :runtime
86
+ prerelease: false
87
+ version_requirements: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '1.0'
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '1.0'
95
+ - !ruby/object:Gem::Dependency
96
+ name: smarter_csv
97
+ requirement: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '1.0'
102
+ - - "~>"
103
+ - !ruby/object:Gem::Version
104
+ version: '1.0'
105
+ type: :runtime
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '1.0'
112
+ - - "~>"
113
+ - !ruby/object:Gem::Version
114
+ version: '1.0'
115
+ - !ruby/object:Gem::Dependency
116
+ name: paperclip
117
+ requirement: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '4.0'
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '4.0'
125
+ type: :runtime
126
+ prerelease: false
127
+ version_requirements: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '4.0'
132
+ - - "~>"
133
+ - !ruby/object:Gem::Version
134
+ version: '4.0'
135
+ description: Smart gem for importing rails models
136
+ email:
137
+ - kapitan_03@yahoo.com
138
+ executables: []
139
+ extensions: []
140
+ extra_rdoc_files: []
141
+ files:
142
+ - ".gitignore"
143
+ - ".rspec"
144
+ - ".travis.yml"
145
+ - CODE_OF_CONDUCT.md
146
+ - Gemfile
147
+ - LICENSE.txt
148
+ - README.md
149
+ - Rakefile
150
+ - active_record_importer.gemspec
151
+ - bin/console
152
+ - bin/setup
153
+ - db/migrate/1_active_record_importer_migration.rb
154
+ - lib/active_record_importer.rb
155
+ - lib/active_record_importer/attributes_builder.rb
156
+ - lib/active_record_importer/batch_importer.rb
157
+ - lib/active_record_importer/data_processor.rb
158
+ - lib/active_record_importer/dispatcher.rb
159
+ - lib/active_record_importer/engine.rb
160
+ - lib/active_record_importer/errors.rb
161
+ - lib/active_record_importer/find_options_builder.rb
162
+ - lib/active_record_importer/helpers.rb
163
+ - lib/active_record_importer/import_callbacker.rb
164
+ - lib/active_record_importer/importable.rb
165
+ - lib/active_record_importer/instance_builder.rb
166
+ - lib/active_record_importer/options_builder.rb
167
+ - lib/active_record_importer/railtie.rb
168
+ - lib/active_record_importer/transition_processor.rb
169
+ - lib/active_record_importer/version.rb
170
+ - lib/app/models/import.rb
171
+ - spec/active_record_importer_spec.rb
172
+ - spec/spec_helper.rb
173
+ homepage: https://github.com/michaelnera/active_record_importer
174
+ licenses:
175
+ - MIT
176
+ metadata: {}
177
+ post_install_message:
178
+ rdoc_options: []
179
+ require_paths:
180
+ - lib
181
+ required_ruby_version: !ruby/object:Gem::Requirement
182
+ requirements:
183
+ - - ">="
184
+ - !ruby/object:Gem::Version
185
+ version: '0'
186
+ required_rubygems_version: !ruby/object:Gem::Requirement
187
+ requirements:
188
+ - - ">="
189
+ - !ruby/object:Gem::Version
190
+ version: '0'
191
+ requirements: []
192
+ rubyforge_project:
193
+ rubygems_version: 2.4.6
194
+ signing_key:
195
+ specification_version: 4
196
+ summary: Active Record Importer
197
+ test_files: []