csv_row_model 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.rspec +2 -0
  4. data/.ruby-gemset +1 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +10 -0
  7. data/.yardopts +10 -0
  8. data/CONTRIBUTING.md +7 -0
  9. data/Gemfile +20 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +369 -0
  12. data/Rakefile +1 -0
  13. data/bin/console +14 -0
  14. data/bin/setup +7 -0
  15. data/csv_row_model.gemspec +23 -0
  16. data/lib/csv_row_model.rb +46 -0
  17. data/lib/csv_row_model/concerns/deep_class_var.rb +86 -0
  18. data/lib/csv_row_model/concerns/inspect.rb +10 -0
  19. data/lib/csv_row_model/engine.rb +5 -0
  20. data/lib/csv_row_model/export.rb +70 -0
  21. data/lib/csv_row_model/export/csv.rb +43 -0
  22. data/lib/csv_row_model/export/single_model.rb +47 -0
  23. data/lib/csv_row_model/import.rb +125 -0
  24. data/lib/csv_row_model/import/attributes.rb +105 -0
  25. data/lib/csv_row_model/import/csv.rb +136 -0
  26. data/lib/csv_row_model/import/csv/row.rb +17 -0
  27. data/lib/csv_row_model/import/file.rb +133 -0
  28. data/lib/csv_row_model/import/file/callbacks.rb +16 -0
  29. data/lib/csv_row_model/import/file/validations.rb +39 -0
  30. data/lib/csv_row_model/import/presenter.rb +160 -0
  31. data/lib/csv_row_model/import/single_model.rb +41 -0
  32. data/lib/csv_row_model/model.rb +68 -0
  33. data/lib/csv_row_model/model/children.rb +79 -0
  34. data/lib/csv_row_model/model/columns.rb +73 -0
  35. data/lib/csv_row_model/model/csv_string_model.rb +16 -0
  36. data/lib/csv_row_model/model/single_model.rb +16 -0
  37. data/lib/csv_row_model/validators/boolean_format.rb +11 -0
  38. data/lib/csv_row_model/validators/date_format.rb +9 -0
  39. data/lib/csv_row_model/validators/default_change.rb +6 -0
  40. data/lib/csv_row_model/validators/float_format.rb +9 -0
  41. data/lib/csv_row_model/validators/integer_format.rb +9 -0
  42. data/lib/csv_row_model/validators/number_validator.rb +16 -0
  43. data/lib/csv_row_model/validators/validate_attributes.rb +27 -0
  44. data/lib/csv_row_model/version.rb +3 -0
  45. metadata +116 -0
@@ -0,0 +1,105 @@
1
+ require 'csv_row_model/validators/boolean_format'
2
+
3
+ module CsvRowModel
4
+ module Import
5
+ module Attributes
6
+ extend ActiveSupport::Concern
7
+ # Classes with a validations associated with them in csv_row_model/validators
8
+ PARSE_VALIDATION_CLASSES = [Boolean, Integer, Float, Date].freeze
9
+
10
+ # Mapping of column type classes to a parsing lambda. These are applied after {Import.format_cell}.
11
+ # Can pass custom Proc with :parse option.
12
+ CLASS_TO_PARSE_LAMBDA = {
13
+ nil => ->(s) { s },
14
+ Boolean => ->(s) { s =~ BooleanFormatValidator::FALSE_BOOLEAN_REGEX ? false : true },
15
+ String => ->(s) { s },
16
+ Integer => ->(s) { s.to_i },
17
+ Float => ->(s) { s.to_f },
18
+ Date => ->(s) { s.present? ? Date.parse(s) : s }
19
+ }.freeze
20
+
21
+ # @return [Hash] a map of `column_name => original_attribute(column_name)`
22
+ def original_attributes
23
+ @original_attributes ||= begin
24
+ values = self.class.column_names.map { |column_name| original_attribute(column_name) }
25
+ self.class.column_names.zip(values).to_h
26
+ end
27
+ end
28
+
29
+ # @return [Object] the column's attribute before override
30
+ def original_attribute(column_name)
31
+ @default_changes ||= {}
32
+
33
+ csv_string_model.valid?
34
+ return nil unless csv_string_model.errors[column_name].blank?
35
+
36
+ value = self.class.format_cell(mapped_row[column_name], column_name, self.class.index(column_name))
37
+ if value.present?
38
+ instance_exec(value, &self.class.parse_lambda(column_name))
39
+ elsif self.class.options(column_name)[:default]
40
+ original_value = value
41
+ value = instance_exec(value, &self.class.default_lambda(column_name))
42
+ @default_changes[column_name] = [original_value, value]
43
+ value
44
+ end
45
+ end
46
+
47
+ # return [Hash] a map changes from {.column}'s default option': `column_name -> [value_before_default, default_set]`
48
+ def default_changes
49
+ original_attributes
50
+ @default_changes
51
+ end
52
+
53
+ class_methods do
54
+ # Safe to override. Method applied to each cell by default
55
+ #
56
+ # @param cell [String] the cell's string
57
+ # @param column_name [Symbol] the cell's column_name
58
+ # @param column_index [Integer] the column_name's index
59
+ def format_cell(cell, column_name, column_index)
60
+ cell
61
+ end
62
+
63
+ # @return [Lambda] returns a Lambda: ->(original_value) { default_exists? ? default : original_value }
64
+ def default_lambda(column_name)
65
+ default = options(column_name)[:default]
66
+ default.is_a?(Proc) ? ->(s) { instance_exec(&default) } : ->(s) { default.nil? ? s : default }
67
+ end
68
+
69
+ # @return [Lambda, Proc] returns the Lambda/Proc given in the parse option or:
70
+ # ->(original_value) { parse_proc_exists? ? parsed_value : original_value }
71
+ def parse_lambda(column_name)
72
+ options = options(column_name)
73
+
74
+ raise ArgumentError.new("You need either :parse OR :type but not both of them") if options[:parse] && options[:type]
75
+
76
+ parse_lambda = options[:parse] || CLASS_TO_PARSE_LAMBDA[options[:type]]
77
+ return parse_lambda if parse_lambda
78
+ raise ArgumentError.new("type must be #{CLASS_TO_PARSE_LAMBDA.keys.reject(:nil?).join(", ")}")
79
+ end
80
+
81
+ protected
82
+ # Define default attribute method for a column
83
+ # @param column_name [Symbol] the cell's column_name
84
+ def define_attribute_method(column_name)
85
+ add_type_validation(column_name)
86
+ define_method(column_name) { original_attribute(column_name) }
87
+ end
88
+
89
+ # Adds the type validation based on :validate_type option
90
+ def add_type_validation(column_name)
91
+ options = options(column_name)
92
+ validate_type = options[:validate_type]
93
+
94
+ return unless validate_type
95
+
96
+ type = options[:type]
97
+ raise ArgumentError.new("invalid :type given for :validate_type for column") unless PARSE_VALIDATION_CLASSES.include? type
98
+ validate_type = Proc.new { validates column_name, "#{type.name.underscore}_format".to_sym => true, allow_blank: true }
99
+
100
+ csv_string_model(&validate_type)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,136 @@
1
+ module CsvRowModel
2
+ module Import
3
+ # Abstraction of Ruby's CSV library. Keeps current row and index, skips empty rows, handles errors.
4
+ class Csv
5
+ # @return [String] the file path of the CSV
6
+ attr_reader :file_path
7
+ # @return [Integer, nil] return `-1` at start of file, `0 to infinity` is index of row_model, `nil` is end of file (row is also `nil`)
8
+ attr_reader :index
9
+ # @return [Array, nil] the current row, or nil at the beginning or end of file
10
+ attr_reader :current_row
11
+ # @return [Hash{Integer => Symbol}] hash of skipped rows from last change in position, `index => :reason`
12
+ attr_reader :skipped_rows
13
+
14
+ include ActiveModel::Validations
15
+
16
+ validate do
17
+ begin; _ruby_csv
18
+ rescue => e; errors.add(:ruby_csv, e.message) end
19
+ end
20
+
21
+ def initialize(file_path)
22
+ @file_path = file_path
23
+ reset
24
+ end
25
+
26
+ # http://stackoverflow.com/questions/2650517/count-the-number-of-lines-in-a-file-without-reading-entire-file-into-memory
27
+ # @return [Integer] the number of rows in the file, including empty new lines
28
+ def size
29
+ @size ||= `wc -l #{file_path}`.split[0].to_i + 1
30
+ end
31
+
32
+ # If the current position is at the header, skip it and return it. Otherwise, only return false.
33
+ # @return [Boolean, Array] returns false, if header is already skipped, otherwise returns the header
34
+ def skip_header
35
+ start_of_file? ? (@header = read_row) : false
36
+ end
37
+
38
+ # Returns the header __without__ changing the position of the CSV
39
+ # @return [Array, nil] the header
40
+ def header
41
+ return unless valid?
42
+ return @header if @header
43
+
44
+ ruby_csv = _ruby_csv
45
+ @header = _read_row({}, 0, ruby_csv)
46
+ ruby_csv.close
47
+ @header
48
+ end
49
+
50
+ # Resets the file to the start of file
51
+ def reset
52
+ return unless valid?
53
+
54
+ @index = -1
55
+ @current_row = nil
56
+ @ruby_csv = _ruby_csv
57
+ true
58
+ end
59
+
60
+ # @return [Boolean] true, if the current position is at the start of the file
61
+ def start_of_file?
62
+ index == -1
63
+ end
64
+
65
+ # @return [Boolean] true, if the current position is at the end of the file
66
+ def end_of_file?
67
+ index.nil?
68
+ end
69
+
70
+ # Returns the next row __without__ changing the position of the CSV
71
+ # @return [Array, nil] the next row, or `nil` at the end of file
72
+ def next_row
73
+ @next_skipped_rows = {}
74
+ @next_row ||= _read_row(@next_skipped_rows)
75
+ end
76
+
77
+ # Returns the next row, while changing the position of the CSV
78
+ # @return [Array, nil] the changed current row, or `nil` at the end of file
79
+ def read_row
80
+ if @next_row
81
+ @current_row, @skipped_rows = @next_row, @next_skipped_rows
82
+ @next_row = nil
83
+ increment_index(@current_row)
84
+ else
85
+ @skipped_rows = {}
86
+ @current_row = _read_row(@skipped_rows) do |row|
87
+ increment_index(row)
88
+ end
89
+ end
90
+ current_row
91
+ end
92
+
93
+ protected
94
+ def set_end_of_file
95
+ @current_row = @index = nil
96
+ end
97
+
98
+ def _ruby_csv
99
+ CSV.open(file_path)
100
+ end
101
+
102
+ def _read_row(skipped_rows={}, index=@index, ruby_csv=@ruby_csv)
103
+ return unless valid?
104
+
105
+ loop do
106
+ row = ruby_csv.readline
107
+
108
+ raise "empty?" if row.try(:empty?)
109
+
110
+ index += 1 if index
111
+
112
+ yield row if block_given?
113
+ return row
114
+ end
115
+ rescue Exception => e
116
+ index += 1 if index
117
+ yield [] if block_given?
118
+
119
+ # thanks to the ruby CSV library, quotes can still escape to the next line
120
+ reason = case e.message
121
+ when "empty?"; :empty
122
+ when /Illegal quoting/i; :illegal_quote
123
+ when /Unclosed quoted/i; :unclosed_quote
124
+ end
125
+
126
+ skipped_rows.merge!(index => reason)
127
+
128
+ retry
129
+ end
130
+
131
+ def increment_index(current_row)
132
+ current_row.nil? ? set_end_of_file : @index += 1
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,17 @@
1
+ module CsvRowModel
2
+ module Import
3
+ class Csv
4
+ class Row
5
+ attr_reader :row, :skipped_rows
6
+
7
+ def initialize(row, index, skipped_rows)
8
+ @row, @index, @skipped_rows = row, index, skipped_rows
9
+ end
10
+
11
+ def empty?
12
+ !!row.try(:empty?)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,133 @@
1
+ require 'csv_row_model/import/file/callbacks'
2
+ require 'csv_row_model/import/file/validations'
3
+
4
+ module CsvRowModel
5
+ module Import
6
+ # Represents a csv file and handles parsing to return `Import` or `Mapper`
7
+ class File
8
+ include Callbacks
9
+ include Validations
10
+
11
+ # @return [Csv]
12
+ attr_reader :csv
13
+ # @return [Input,Mapper] model class returned for importing
14
+ attr_reader :row_model_class
15
+
16
+ # Current index of the row model
17
+ # @return [Integer] returns -1 = start of file, 0 to infinity = index of row_model, nil = end of file, no row_model
18
+ attr_reader :index
19
+ # @return [Input, Mapper] the current row model set by {#next}
20
+ attr_reader :current_row_model
21
+ # @return [Input, Mapper] the previous row model set by {#next}
22
+ attr_reader :previous_row_model
23
+
24
+ delegate :header, :size, :skipped_rows, to: :csv
25
+
26
+ # @param [String] file_path path of csv file
27
+ # @param [Import, Mapper] row_model_class model class returned for importing
28
+ def initialize(file_path, row_model_class)
29
+ @csv, @row_model_class = Csv.new(file_path), row_model_class
30
+ reset
31
+ end
32
+
33
+ # Resets the file back to the top
34
+ def reset
35
+ csv.reset
36
+ @index = -1
37
+ @current_row_model = nil
38
+ end
39
+
40
+ # Gets the next row model based on the context
41
+ #
42
+ # @param context [Hash] context passed to the {Import}
43
+ def next(context={})
44
+ if is_single_model?
45
+ return set_end_of_file if end_of_file?
46
+ set_single_model(context)
47
+ else
48
+ next_collection_model(context)
49
+ end
50
+ end
51
+
52
+ # @return [Boolean] returns true, if the object is at the end of file
53
+ def end_of_file?
54
+ csv.end_of_file?
55
+ end
56
+
57
+ # Iterates through the entire csv file and provides the `current_row_model` in a block, while handing aborts and skips
58
+ # via. calling {Model#abort?} and {Model#skip?}
59
+ #
60
+ # @param context [Hash] context passed to the {Import}
61
+ def each(context={})
62
+ return to_enum(__callee__, context) unless block_given?
63
+ return false if _abort?
64
+
65
+ while self.next(context)
66
+ return false if _abort?
67
+ next if _skip?
68
+
69
+ run_callbacks :yield do
70
+ yield current_row_model
71
+ end
72
+ end
73
+ end
74
+
75
+ protected
76
+
77
+ # @return [boolean] if type of model is collection_model
78
+ def is_single_model?
79
+ @is_single_model ||= begin
80
+ row_model_class.respond_to?(:type) ? (row_model_class.type == :single_model) : false
81
+ end
82
+ end
83
+
84
+ def set_current_collection_model(context)
85
+ @current_row_model = row_model_class.new(csv.current_row, index: csv.index, context: context, source_header: header, previous: previous_row_model)
86
+ @index += 1
87
+ end
88
+
89
+ def set_single_model(context={})
90
+ source_row = Array.new(row_model_class.header_matchers.size)
91
+ while !end_of_file?
92
+ csv.read_row
93
+ update_source_row(source_row)
94
+ end
95
+ @current_row_model = row_model_class.new(source_row, context: context)
96
+ end
97
+
98
+ def update_source_row(source_row)
99
+ current_row = csv.current_row
100
+ return unless current_row
101
+ current_row.each_with_index do |cell, position|
102
+ next if cell.blank?
103
+ index = row_model_class.index_header_match(cell)
104
+ next unless index
105
+ source_row[index] = current_row[position + 1]
106
+ break
107
+ end
108
+ end
109
+
110
+ def next_collection_model(context)
111
+ csv.skip_header
112
+
113
+ next_row_is_parent = true
114
+ loop do
115
+ @previous_row_model = current_row_model if next_row_is_parent
116
+
117
+ csv.read_row
118
+ return set_end_of_file if csv.end_of_file?
119
+
120
+ set_current_collection_model(context) if next_row_is_parent
121
+
122
+ next_row_is_parent = !current_row_model.append_child(csv.next_row)
123
+ return current_row_model if next_row_is_parent
124
+ end
125
+ end
126
+
127
+ def set_end_of_file
128
+ # please return nil
129
+ @current_row_model = @index = nil
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,16 @@
1
+ module CsvRowModel
2
+ module Import
3
+ class File
4
+ module Callbacks
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ extend ActiveModel::Callbacks
9
+
10
+ define_model_callbacks :yield
11
+ define_model_callbacks :abort, :skip, only: :before
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,39 @@
1
+ module CsvRowModel
2
+ module Import
3
+ class File
4
+ module Validations
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ include ActiveModel::Validations
9
+ include Validators::ValidateAttributes
10
+
11
+ validate_attributes :csv
12
+ end
13
+
14
+ # @return [Boolean] returns true, if the file should abort reading
15
+ def abort?
16
+ !valid? || !!current_row_model.try(:abort?)
17
+ end
18
+
19
+ # @return [Boolean] returns true, if the file should skip `current_row_model`
20
+ def skip?
21
+ !!current_row_model.try(:skip?)
22
+ end
23
+
24
+ protected
25
+ def _abort?
26
+ abort = abort?
27
+ run_callbacks(:abort) if abort
28
+ abort
29
+ end
30
+
31
+ def _skip?
32
+ skip = skip?
33
+ run_callbacks(:skip) if skip
34
+ skip
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end