imp_exp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +59 -0
  4. data/Rakefile +5 -0
  5. data/app/services/imp_exp/data_validators/base.rb +92 -0
  6. data/app/services/imp_exp/exporters/base.rb +28 -0
  7. data/app/services/imp_exp/exporters/files.rb +41 -0
  8. data/app/services/imp_exp/exporters/xlsx.rb +34 -0
  9. data/app/services/imp_exp/importers/base.rb +191 -0
  10. data/app/services/imp_exp/importers/xlsx.rb +81 -0
  11. data/app/services/imp_exp/models/base.rb +143 -0
  12. data/app/services/imp_exp/ping_observer.rb +22 -0
  13. data/app/services/imp_exp/serializers/array_like.rb +21 -0
  14. data/app/services/imp_exp/serializers/errors/base.rb +12 -0
  15. data/app/services/imp_exp/serializers/errors/record_not_found.rb +24 -0
  16. data/app/services/imp_exp/serializers/errors/wrong_format.rb +20 -0
  17. data/app/services/imp_exp/serializers/polymorphic_relation_one_to_one.rb +37 -0
  18. data/app/services/imp_exp/serializers/relation_one_to_many.rb +61 -0
  19. data/app/services/imp_exp/serializers/relation_one_to_one.rb +38 -0
  20. data/app/services/imp_exp/serializers/time.rb +17 -0
  21. data/app/services/imp_exp/serializers/to_stable_uuid.rb +25 -0
  22. data/app/services/imp_exp/serializers/whole_records.rb +46 -0
  23. data/app/services/imp_exp/service.rb +9 -0
  24. data/app/services/imp_exp/utils/relation_scoper.rb +26 -0
  25. data/app/services/imp_exp/utils/xlsx_reader.rb +59 -0
  26. data/config/locales/en.yml +10 -0
  27. data/config/locales/fr.yml +10 -0
  28. data/lib/imp_exp/version.rb +5 -0
  29. data/lib/imp_exp.rb +10 -0
  30. data/lib/tasks/imp_exp.rake +8 -0
  31. metadata +130 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b947ce095504a9bc4f41b60310670a169259363d84af4518b281b099590770fa
4
+ data.tar.gz: d959c36041d86fee8c74190434ccc2d23f93259b29ce627ceaabc72a002454eb
5
+ SHA512:
6
+ metadata.gz: 361153041533b315f3e2277366511738aade7726a802652b19da6a54590f3f2d36b4bd6ff7a13e28fd1690c13791e6e8e3bb6355e425ac299d6f829fe3ddee71
7
+ data.tar.gz: 4129a1910c806240b85dfbb1d5bf0e0cf07afe8321d23b47dac4da37da9361ee2189ee2427c6122147302553e2f9571ec17cc040f40518f6a652a29d35e8829b
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2023 Nicolas Florentin
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.
data/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # ImpExp
2
+
3
+ ## Fonctionnement
4
+
5
+ Features:
6
+ - Export de données brutes (ImpExp::Exporters::Base) et d'un fichier xlsx (ImpExp::Exporters::Xlsx)
7
+ - Import de données brutes (ImpExp::Importers::Base) et depuis un fichier xlsx (ImpExp::Importers::Xlsx) ainsi que les fichiers attachés (facultatifs)
8
+ - Exporter des fichiers (ImpExp::Exporters::Files)
9
+
10
+ Configuration:
11
+ - un fichier de configuration par modèle (= un onglet dans un classeur xlsx)/ La configuration d'un modèle liste les informations exportés et comment elles doivent être exportées (`serializers`)
12
+ - les imports qui listent les modèles à exporter pour chaque type d'export (voir exemple ImpExp::Imports::Account)
13
+ - les exports qui listent les modèles à exporter pour chaque type d'export (voir exemple ImpExp::Exports::Account)
14
+
15
+ Extensions:
16
+ - Des serializers peuvent être ajoutés, ils sont des services simples implémentant une méthod `load` et une méthode `dump` permettant de sérialiser et déserialiser une donnée d'un modèle (= une cellule d'une feuille de classeur)
17
+ - On peut ajouter un service de validation pour l'import Xlsx (voir exemple ImpExp::AccountSheetValidator), cette validation est bloquante pour l'import
18
+ - On peut ajouter un service de validation des données brutes (voir exemple ImpExp::DummyDataValidator), cette validation est bloquante pour l'import
19
+ - On peut facilement rajouter un format d'export ou d'import en s'inspirant de ImpExp::Exporters::Xlsx et ImpExp::Importers::Xlsx, en héritant de ImpExp::Exporters::Base et ImpExp::Importers::Base
20
+
21
+ Autres:
22
+ - la gem définit un service `Utils::XlsxReader` permettant d'abstraire la lecture d'un xlsx (et switcher de librairie facilement)
23
+ - la gem définit un service `PingObserver`, qui permet d'implémenter le pattern observer pour les imports donc suivre l'avancement de l'import
24
+
25
+ Les fichiers les plus importants à comprendre sont :
26
+ - `imp_exp/models/base.rb` : classe parente de tous les modèles
27
+ - `imp_exp/importers/base.rb` : service contenant la logique d'import
28
+
29
+ ## Bon à savoir pour les développeurs
30
+
31
+ ### Générer un export xlsx à partir des fixtures
32
+
33
+ ```bash
34
+ cd test/dummy
35
+ RAILS_ENV=test bundle exec rake imp_exp:generate_account_export
36
+ ```
37
+
38
+ ### Charger les fixtures dans la base
39
+
40
+ ```bash
41
+ cd test/dummy
42
+ FIXTURES_PATH=../fixtures RAILS_ENV=test bundle exec rails db:fixtures:load
43
+ ```
44
+
45
+ ### Créer des nouveaux modèles pour l'app dummy
46
+
47
+ Lorsqu'on créé des nouveaux modèles pour la base dummy, les fichiers (tests et fixtures) sont générés au mauvaise endroit (dans le dossier dummy),
48
+ il faut les déplacement manuellement dans le répertoire parent (imp_exp/test).
49
+
50
+ ### Exécuter les tests
51
+
52
+ ```bash
53
+ cd imp_exp
54
+ bin/test
55
+ ```
56
+
57
+ ## License
58
+
59
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ require "bundler/gem_tasks"
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module DataValidators
5
+ class Base
6
+ attr_reader :active_record_class, :columns, :row_offset
7
+
8
+ def initialize(model, row_offset = 0)
9
+ @active_record_class = model.active_record_class
10
+ @columns = model.active_record_class.columns.index_by(&:name).with_indifferent_access
11
+ @row_offset = row_offset
12
+ end
13
+
14
+ def call(attributes = [])
15
+ errors = []
16
+ attributes.each_with_index do |attrs, i|
17
+ attrs.each do |attr_name, attr_value|
18
+ column = columns[attr_name]
19
+
20
+ next if column&.array?
21
+
22
+ column_type = column&.type || :string
23
+
24
+ enums = active_record_class.defined_enums[column&.name]
25
+
26
+ if enums.present?
27
+ possible_values = enums.keys + enums.values
28
+ unless attr_value.in? possible_values
29
+ errors << error_hash(row_offset + i + 1, attr_name, attr_value, possible_values)
30
+ end
31
+ else
32
+ unless valid?(attr_value, column_type)
33
+ errors << error_hash(row_offset + i + 1, attr_name, attr_value, column_type)
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ errors
40
+ end
41
+
42
+ def valid?(attribute_value, column_type)
43
+ return true if attribute_value.to_s.blank?
44
+
45
+ case column_type
46
+ when :string
47
+ return true
48
+ when :boolean
49
+ return false unless valid_boolean?(attribute_value)
50
+ when :bigint, :integer
51
+ return false unless valid_integer?(attribute_value)
52
+ when :decimal, :float
53
+ return false unless valid_decimal?(attribute_value)
54
+ when :date, :datetime
55
+ return false unless valid_datetime?(attribute_value)
56
+ when :time
57
+ return false unless valid_time?(attribute_value)
58
+ end
59
+
60
+ true
61
+ end
62
+
63
+ private
64
+
65
+ def error_hash(line, column, value, expected)
66
+ { line: line, column: column.to_s, value: value, expected: expected }
67
+ end
68
+
69
+ def valid_boolean?(value)
70
+ value.in? [true, false, "true", "false", "t", "f", 0, 1, "0", "1"]
71
+ end
72
+
73
+ def valid_integer?(value)
74
+ value.is_a?(Integer) || (value.is_a?(String) && value.to_i.to_s == value)
75
+ end
76
+
77
+ def valid_decimal?(value)
78
+ BigDecimal(value.to_s)
79
+ rescue ArgumentError
80
+ false
81
+ end
82
+
83
+ def valid_datetime?(value)
84
+ value.is_a?(Date) || value.is_a?(Time) || value.is_a?(DateTime)
85
+ end
86
+
87
+ def valid_time?(value)
88
+ Time.zone.parse(value.to_s) != nil
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Exporters
5
+ class Base
6
+ attr_reader :scoping_parent, :models, :logger
7
+
8
+ def initialize(scoping_parent, models, logger: nil)
9
+ @scoping_parent = scoping_parent
10
+ @models = models
11
+ @logger = logger || Logger.new('log/exports.log', 1)
12
+ end
13
+
14
+ def call
15
+ logger.info "Starting export #{self.class.name} for #{scoping_parent.model_name.singular} " \
16
+ "#{scoping_parent.name} (#{scoping_parent.id})"
17
+ begin
18
+ export_result = yield
19
+ logger.info "Export completed\n---------"
20
+ export_result
21
+ rescue StandardError => e
22
+ logger.error "Export interrupted with error : #{e.class.name} #{e.message}"
23
+ raise e
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zip'
4
+
5
+ module ImpExp
6
+ module Exporters
7
+ class Files < Base
8
+ def call
9
+ super do
10
+ tmp_file = Tempfile.new("exporters_files")
11
+ zip = Zip::OutputStream.write_buffer(tmp_file) do |output_stream|
12
+ models.each do |model|
13
+ files = model.files(scoping_parent)
14
+ next if files.empty?
15
+
16
+ files.each do |active_storage_file|
17
+ full_path = "#{model.files_directory_path}/#{model.filename(active_storage_file)}"
18
+ add_entry_to_zip(output_stream, active_storage_file, full_path)
19
+ end
20
+ end
21
+ end
22
+ zip.flush
23
+ tmp_file.rewind
24
+ { zip_stream: tmp_file }
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def add_entry_to_zip(output_stream, active_storage_file, full_path)
31
+ output_stream.put_next_entry(full_path)
32
+ output_stream.write active_storage_file.download
33
+ rescue ActiveStorage::FileNotFoundError
34
+ record = active_storage_file.record
35
+ logger.error "File not found for entity #{record.class} #{record.id} " \
36
+ "attribute #{active_storage_file.name}"
37
+ logger.info "Continuing..."
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'axlsx'
4
+
5
+ module ImpExp
6
+ module Exporters
7
+ class Xlsx < Base
8
+ def call
9
+ super do
10
+ rows_count = 0
11
+ package = Axlsx::Package.new
12
+ models.each do |model|
13
+ worksheet = package.workbook.add_worksheet(name: model.model_name)
14
+ worksheet.add_row model.headers
15
+ worksheet.add_row model.headers_translated
16
+ model.rows(scoping_parent).each do |row|
17
+ worksheet.add_row row, style: row_style(worksheet, model)
18
+ rows_count += 1
19
+ end
20
+ end
21
+ { xlsx_stream: package.to_stream, rows_count: rows_count }
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def row_style(worksheet, model)
28
+ {
29
+ datetime: worksheet.styles.add_style(num_fmt: Axlsx::NUM_FMT_YYYYMMDDHHMMSS)
30
+ }.values_at(*model.attribute_types)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "observer"
4
+
5
+ module ImpExp
6
+ module Importers
7
+ class Base
8
+ include Observable
9
+
10
+ attr_reader :scoping_parent, :models, :logger, :exception_notifier, :errors, :imported
11
+
12
+ ROW_OFFSET = 2
13
+
14
+ def initialize(scoping_parent, models, logger: nil, exception_notifier: nil)
15
+ @scoping_parent = scoping_parent
16
+ @models = models
17
+ @logger = logger || Logger.new('log/imports.log', 1)
18
+ @exception_notifier = exception_notifier
19
+ end
20
+
21
+ def call(data, files_directory_path = nil)
22
+ @errors = []
23
+ @imported = data.keys.map(&:model_name).index_with { 0 }
24
+ logger.info "Starting import #{self.class.name} for #{scoping_parent.model_name.singular} " \
25
+ "#{scoping_parent.name} (#{scoping_parent.id})"
26
+ begin
27
+ ping(stage: 'validation')
28
+ import(data, files_directory_path)
29
+ logger.info "Import completed\n---------"
30
+ import_result
31
+ rescue StandardError => e
32
+ logger.error "Import interrupted with error : #{e.class.name} #{e.message}:"
33
+ logger.error e.backtrace.join("\n")
34
+
35
+ raise e if raise_rescued_standard_error?
36
+
37
+ exception_notifier&.call(e)
38
+ import_result.merge(exception_raised: "#{e.class.name} #{e.message}")
39
+ end
40
+ end
41
+
42
+ def import(data, files_directory_path)
43
+ data.each do |model, model_data|
44
+ ping(forced: true, stage: 'import', model_name: model.model_name)
45
+ import_model_data(model, model_data, files_directory_path)
46
+ end
47
+ end
48
+
49
+ def import_result
50
+ { imported: imported, import_errors: errors }
51
+ end
52
+
53
+ def import_model_data(model, model_data, files_directory_path)
54
+ model_data.each_with_index do |row, i|
55
+ line = ROW_OFFSET + i + 1
56
+ model_name = model.model_name
57
+
58
+ relation_attributes = model.relation_names.index_with do |relation_name|
59
+ relation_attribute(model, relation_name, row[relation_name])
60
+ end
61
+
62
+ relation_attributes = process_errors(relation_attributes, model, line, row.to_s)
63
+
64
+ attrs = attributes_from_row(row, model).merge!(relation_attributes)
65
+
66
+ record = find_or_initialize_record(row.merge(relation_attributes), scoping_parent, model)
67
+
68
+ upsert_no_record_error(row, model, line) && next unless record
69
+
70
+ assign_attributes(record, attrs, model_name, line, row)
71
+
72
+ record.assign_attributes(model.extra_attributes_imported_at_runtime(scoping_parent, record, row))
73
+
74
+ serialize_attributes_if_needed(record, row, model)
75
+
76
+ attach_files(record, files_directory_path, model, row, line) if files_directory_path.present?
77
+
78
+ if record.save(context: :import)
79
+ @imported[model.model_name] += 1
80
+ next
81
+ end
82
+
83
+ upsert_error_messages(model_name, line, row.to_s, record.errors.full_messages)
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def raise_rescued_standard_error?
90
+ Rails.env.test? || Rails.env.development?
91
+ end
92
+
93
+ def attributes_from_row(row, model)
94
+ row.slice(*model.attribute_to_import_names)
95
+ end
96
+
97
+ def find_or_initialize_record(attributes, scoping_parent, model)
98
+ record = model.find_record(attributes, scoping_parent)
99
+ return if !record && model.strict_find_by_attribute && attributes[model.strict_find_by_attribute.to_s].present?
100
+
101
+ record || model.active_record_class.new
102
+ end
103
+
104
+ def upsert_no_record_error(row, model, line)
105
+ conditions = row.slice(model.strict_find_by_attribute).map { |k, v| "#{k}=#{v}" }.join(' ')
106
+ message = I18n.t('imp_exp.importer.record_not_found', model_name: model.active_record_class.model_name,
107
+ conditions: conditions)
108
+ upsert_error_messages(model.model_name, line, row.to_s, message)
109
+ end
110
+
111
+ def assign_attributes(record, attributes, model_name, line, row)
112
+ record.assign_attributes(attributes)
113
+ rescue ActiveRecord::RecordNotSaved => e
114
+ upsert_error_messages(model_name, line, row.to_s, e.message)
115
+ end
116
+
117
+ def relation_attribute(model, relation_name, relation_datum)
118
+ serializer = model.relation_options(relation_name)[:serializer]
119
+ serializer.load(relation_datum, model: model, relation_name: relation_name, scoping_parent: scoping_parent)
120
+ end
121
+
122
+ def attach_files(record, files_directory_path, model, row, line)
123
+ model.file_attribute_names.each do |file_attribute_name|
124
+ file_path = row[file_attribute_name]
125
+ next if file_path.blank?
126
+
127
+ full_file_path = File.join(files_directory_path, file_path.strip)
128
+ begin
129
+ file = File.open(full_file_path)
130
+ record.send(file_attribute_name).attach(io: file, filename: File.basename(file_path))
131
+ rescue Errno::ENOENT
132
+ error_msg = I18n.t('imp_exp.importer.file_not_found', file_path: file_path)
133
+ upsert_error_messages(model.model_name, line, row.to_s, error_msg)
134
+ end
135
+ end
136
+ end
137
+
138
+ def process_errors(relation_attributes, model, line, row_str)
139
+ relation_attributes.transform_values do |data|
140
+ if data.is_a?(Array)
141
+ data.delete_if do |datum|
142
+ if data_is_an_error?(datum)
143
+ upsert_error_messages(model.model_name, line, row_str, datum.message)
144
+ true
145
+ end
146
+ end
147
+ elsif data_is_an_error?(data)
148
+ upsert_error_messages(model.model_name, line, row_str, data.message)
149
+ nil
150
+ else
151
+ data
152
+ end
153
+ end
154
+ end
155
+
156
+ def data_is_an_error?(data)
157
+ data.is_a?(ImpExp::Serializers::Errors::Base)
158
+ end
159
+
160
+ def upsert_error_messages(model_name, line, row_str, messages)
161
+ error = @errors.find { |e| e[:model_name] == model_name && e[:line] == line }
162
+ if error
163
+ error[:messages] = error[:messages] + Array(messages)
164
+ else
165
+ @errors << { model_name: model_name, line: line, row: row_str, messages: Array(messages) }
166
+ end
167
+ end
168
+
169
+ def serialize_attributes_if_needed(record, row, model)
170
+ model.attribute_to_import_names.each do |attribute_name|
171
+ serializer = model.attribute_options(attribute_name)[:serializer]
172
+ serializer && record.assign_attributes(attribute_name => serializer.load(row[attribute_name],
173
+ model: model,
174
+ record: record,
175
+ scoping_parent: scoping_parent))
176
+ end
177
+ end
178
+
179
+ def ping(stage: 'import', model_name: nil, forced: false)
180
+ if forced
181
+ changed
182
+ notify_observers({ stage: stage, model_name: model_name })
183
+ elsif @last_ping.nil? || @last_ping + ::Import::PING_TIMEOUT < Time.zone.now
184
+ changed
185
+ notify_observers({ stage: stage, model_name: model_name })
186
+ @last_ping = Time.zone.now
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Importers
5
+ class Xlsx < Base
6
+ attr_reader :xlsx_reader, :format_errors, :data_validation_errors,
7
+ :extra_format_validator_klass, :extra_data_validator_klass
8
+
9
+ # rubocop : disable Metrics/ParameterLists
10
+ def initialize(scoping_parent, models, logger: nil, extra_format_validator_klass: nil, exception_notifier: nil,
11
+ extra_data_validator_klass: nil)
12
+ # rubocop : enable Metrics/ParameterLists
13
+ super(scoping_parent, models, logger: logger, exception_notifier: exception_notifier)
14
+ @extra_format_validator_klass = extra_format_validator_klass
15
+ @extra_data_validator_klass = extra_data_validator_klass
16
+ @format_errors = []
17
+ @data_validation_errors = []
18
+ end
19
+
20
+ def call(xlsx_stream, files_directory_path = nil)
21
+ ping(forced: true, stage: "validation")
22
+ logger.info "validating xlsx import file"
23
+
24
+ @xlsx_reader = ImpExp::Utils::XlsxReader.new(xlsx_stream)
25
+
26
+ logger.info "validating sheets..."
27
+ validate_presence_of_sheets(xlsx_reader.sheet_names)
28
+
29
+ logger.info "validating columns..."
30
+ validate_presence_of_columns(xlsx_reader)
31
+
32
+ format_errors.concat extra_format_validator_klass.new(self).call if extra_format_validator_klass
33
+
34
+ logger.info "validating data..."
35
+ validate_data(xlsx_reader)
36
+
37
+ import_result = if format_errors.none? && data_validation_errors.none?
38
+ data = models.index_with { |model| xlsx_reader.sheet_data(model.model_name) }
39
+ super(data, files_directory_path)
40
+ else
41
+ { imported: {}, import_errors: [] }
42
+ end
43
+
44
+ { format_errors: format_errors, data_validation_errors: data_validation_errors }.merge(import_result)
45
+ end
46
+
47
+ private
48
+
49
+ def validate_presence_of_sheets(sheet_names)
50
+ expected_model_names = models.map(&:model_name).sort
51
+ return unless sheet_names.sort != expected_model_names
52
+
53
+ format_errors << { name: :invalid_sheets,
54
+ expected_sheets: expected_model_names,
55
+ in_file_sheets: sheet_names.sort }
56
+ end
57
+
58
+ def validate_presence_of_columns(xlsx_reader)
59
+ models.each do |model|
60
+ sheet_headers = xlsx_reader.sheet_header_names(model.model_name)
61
+ model_headers = model.headers
62
+ next if sheet_headers.sort == model_headers.sort
63
+
64
+ format_errors << { name: :invalid_columns, model_name: model.model_name,
65
+ expected_columns: model_headers.map(&:to_s),
66
+ in_file_columns: sheet_headers.map(&:to_s) }
67
+ end
68
+ end
69
+
70
+ def validate_data(xlsx_reader)
71
+ models.select { |m| m.model_name.in? xlsx_reader.sheet_names }.each do |model|
72
+ data = xlsx_reader.sheet_data(model.model_name)
73
+ errors = ImpExp::DataValidators::Base.new(model, ROW_OFFSET).call(data)
74
+ errors.concat extra_data_validator_klass.new(model, ROW_OFFSET).call(data) if extra_data_validator_klass
75
+ data_validation_errors.concat(errors.map { |err| err.merge(model_name: model.model_name) }) if errors.any?
76
+ end
77
+ data_validation_errors.flatten
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Models
5
+ class Base
6
+ def active_record_class
7
+ "::#{self.class.name.demodulize}".constantize
8
+ end
9
+
10
+ def find_record(attrs, scoping_parent)
11
+ scoped(scoping_parent).find_by(code: attrs[:code])
12
+ end
13
+
14
+ def strict_find_by_attribute
15
+ nil
16
+ end
17
+
18
+ def attributes
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def attribute_names
23
+ attributes.map(&:first)
24
+ end
25
+
26
+ def attribute_types
27
+ attribute_names.map { active_record_class.column_for_attribute(_1)&.type }
28
+ end
29
+
30
+ def attribute_to_import_names
31
+ attribute_names.reject { |attribute_name| attribute_options(attribute_name)[:imported] == false }
32
+ end
33
+
34
+ def extra_attributes_imported_at_runtime(scoping_parent, _record, _row)
35
+ scoping_parent_fk = scoping_parent_foreign_key_column_name(scoping_parent)
36
+ if scoping_parent && active_record_class.attribute_names.include?(scoping_parent_fk)
37
+ return { scoping_parent_fk.to_sym => scoping_parent.id }
38
+ end
39
+
40
+ {}
41
+ end
42
+
43
+ def file_attributes
44
+ []
45
+ end
46
+
47
+ def file_attribute_names
48
+ file_attributes.map(&:first)
49
+ end
50
+
51
+ def relations
52
+ []
53
+ end
54
+
55
+ def relation_names
56
+ relations.map(&:first)
57
+ end
58
+
59
+ def scoping_parent_foreign_key_column_name(scoping_parent)
60
+ "#{scoping_parent.model_name.singular}_id"
61
+ end
62
+
63
+ def scoped(scoping_parent)
64
+ active_record_class.rewhere({ scoping_parent_foreign_key_column_name(scoping_parent) => scoping_parent.id })
65
+ end
66
+
67
+ def preload_associations(collection)
68
+ collection
69
+ end
70
+
71
+ def order(collection)
72
+ collection.order(:created_at)
73
+ end
74
+
75
+ def model_name
76
+ self.class.name.demodulize
77
+ end
78
+
79
+ def relation_options(relation_name)
80
+ relations.find { |relation| relation[0] == relation_name.to_sym }&.slice(1) || {}
81
+ end
82
+
83
+ def attribute_options(attribute_name)
84
+ attributes.find { |attribute| attribute[0] == attribute_name.to_sym }&.slice(1) || {}
85
+ end
86
+
87
+ def headers_translated
88
+ (attributes + relations).map do |name, opts = {}|
89
+ i18n_path = opts[:i18n_path].presence || "#{active_record_class.model_name.i18n_key}.#{name}"
90
+ key = "activerecord.attributes.#{i18n_path}"
91
+ I18n.t(key, raise: true)
92
+ end
93
+ end
94
+
95
+ def headers
96
+ attribute_names + relation_names + file_attribute_names
97
+ end
98
+
99
+ def rows(scoping_parent)
100
+ rows = []
101
+ preload_associations(order(scoped(scoping_parent))).each do |record|
102
+ data = record.slice(attribute_names)
103
+ attribute_names.each do |attribute_name|
104
+ serializer = attribute_options(attribute_name)[:serializer]
105
+ data[attribute_name] = serializer.dump(record, attribute_name) if serializer
106
+ end
107
+ data = data.values
108
+ relation_names.each do |relation_name|
109
+ serializer = relation_options(relation_name)[:serializer]
110
+ data << serializer.dump(record, relation_name)
111
+ end
112
+ data.concat(file_attribute_names.map do |file_attribute_name|
113
+ file = record.send(file_attribute_name)
114
+ "#{files_directory_path}/#{filename(file)}" if file.attached?
115
+ end)
116
+ rows << data
117
+ end
118
+ rows
119
+ end
120
+
121
+ def files(scoping_parent)
122
+ scoped(scoping_parent).flat_map do |record|
123
+ record.slice(file_attribute_names).values.select(&:attached?)
124
+ end
125
+ end
126
+
127
+ def files_directory_path
128
+ active_record_class.model_name.element
129
+ end
130
+
131
+ def filename(active_storage_file)
132
+ "#{active_storage_file.name}-#{active_storage_file.record.code}#{file_extension(active_storage_file)}"
133
+ end
134
+
135
+ private
136
+
137
+ def file_extension(active_storage_file)
138
+ active_storage_file.filename.extension_with_delimiter.presence ||
139
+ ".#{Marcel::EXTENSIONS.invert[active_storage_file.content_type]}"
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ class PingObserver
5
+ attr_reader :import_service, :active_record_import
6
+
7
+ def initialize(import_service, active_record_import)
8
+ @import_service = import_service
9
+ @active_record_import = active_record_import
10
+ end
11
+
12
+ def setup
13
+ import_service.importers.each do |importer|
14
+ importer.add_observer(self)
15
+ end
16
+ end
17
+
18
+ def update(data_hash)
19
+ active_record_import.ping(**data_hash)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ class ArrayLike
6
+ SEPARATOR = "|"
7
+
8
+ def dump(record, attribute_name)
9
+ record.send(attribute_name)&.join(SEPARATOR)
10
+ end
11
+
12
+ # rubocop: disable Lint/UnusedMethodArgument
13
+ def load(data, model: nil, record: nil, scoping_parent: nil)
14
+ return [] if data.blank?
15
+
16
+ data.to_s.split(SEPARATOR).map(&:strip)
17
+ end
18
+ # rubocop: enable Lint/UnusedMethodArgument
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop : disable Lint/EmptyClass
4
+ module ImpExp
5
+ module Serializers
6
+ module Errors
7
+ class Base
8
+ end
9
+ end
10
+ end
11
+ end
12
+ # rubocop : enable Lint/EmptyClass
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ module Errors
6
+ class RecordNotFound < Base
7
+ attr_reader :active_record_class, :find_args
8
+
9
+ def initialize(active_record_class, find_args)
10
+ super()
11
+ @active_record_class = active_record_class
12
+ @active_record_class = @active_record_class.constantize if @active_record_class.is_a?(String)
13
+ @find_args = find_args&.symbolize_keys
14
+ end
15
+
16
+ def message
17
+ conditions = find_args.map { |k, v| "#{k}=#{v}" }.join(' ')
18
+ I18n.t('imp_exp.importer.record_not_found', model_name: active_record_class.model_name,
19
+ conditions: conditions)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ module Errors
6
+ class WrongFormat < Base
7
+ attr_reader :raw_message
8
+
9
+ def initialize(raw_message = nil)
10
+ super()
11
+ @raw_message = raw_message
12
+ end
13
+
14
+ def message
15
+ I18n.t('imp_exp.importer.wrong_format', message: raw_message)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ class PolymorphicRelationOneToOne
6
+ attr_reader :column, :relation_classes
7
+
8
+ def initialize(column, relation_classes)
9
+ @column = column
10
+ @relation_classes = relation_classes
11
+ end
12
+
13
+ def dump(record, relation_name)
14
+ relation = record.send(relation_name)
15
+ value = relation&.send(column)
16
+
17
+ "#{relation.model_name.name}|#{value}" if relation && value.present?
18
+ end
19
+
20
+ # rubocop: disable Lint/UnusedMethodArgument
21
+ def load(data, scoping_parent:, model: nil, relation_name: nil)
22
+ return if data.blank?
23
+
24
+ relation_class, value = data.strip.split("|")
25
+
26
+ return if relation_class.blank? || value.blank? || !relation_class.in?(relation_classes)
27
+
28
+ record = ImpExp::Utils::RelationScoper.call(relation_class.constantize, relation_class, scoping_parent)
29
+ .find_by({ column => value })
30
+ return record if record
31
+
32
+ ImpExp::Serializers::Errors::RecordNotFound.new(relation_class, { column => value })
33
+ end
34
+ # rubocop: enable Lint/UnusedMethodArgument
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ class RelationOneToMany
6
+ SEPARATOR = ','
7
+
8
+ attr_reader :columns, :class_name
9
+
10
+ def initialize(*columns, class_name: nil)
11
+ @columns = columns
12
+ @class_name = class_name
13
+ end
14
+
15
+ def dump(record, relation_name)
16
+ records = record.send(relation_name)
17
+ records.map do |r|
18
+ columns.map do |column|
19
+ r.send(column)
20
+ end.join(COMPOSITE_IDENTIFIER_SEPARATOR)
21
+ end
22
+ .join(SEPARATOR)
23
+ end
24
+
25
+ def load(data, model:, relation_name:, scoping_parent:)
26
+ values_array = data.to_s.split(SEPARATOR).map do |chunk|
27
+ chunk.strip.split(COMPOSITE_IDENTIFIER_SEPARATOR).map(&:strip)
28
+ end
29
+
30
+ relation_class = class_name || relation_class_from_reflection(model, relation_name)
31
+ relation_class = relation_class.to_s.constantize
32
+
33
+ if values_array.empty?
34
+ records = relation_class.none
35
+ else
36
+ records = relation_class.where(columns.zip(values_array[0]).to_h)
37
+ end
38
+
39
+ values_array.uniq!
40
+
41
+ values_array[1..]&.each do |values|
42
+ records = records.or(relation_class.where(columns.zip(values).to_h))
43
+ end
44
+
45
+ records = Utils::RelationScoper.call(records, relation_class, scoping_parent)
46
+
47
+ values_array.map do |values|
48
+ record = records.find { |r| r.slice(columns).values.map(&:to_s) == values }
49
+ record || ImpExp::Serializers::Errors::RecordNotFound.new(relation_class, columns.zip(values).to_h)
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def relation_class_from_reflection(model, relation_name)
56
+ reflection = model.active_record_class.reflections[relation_name.to_s]
57
+ reflection.options[:class_name] || reflection.name.to_s.classify
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ class RelationOneToOne
6
+ attr_reader :columns
7
+
8
+ def initialize(*columns)
9
+ @columns = columns
10
+ end
11
+
12
+ def dump(record, relation_name)
13
+ columns.map do |column|
14
+ record.send(relation_name)&.send(column)
15
+ end.join(COMPOSITE_IDENTIFIER_SEPARATOR)
16
+ end
17
+
18
+ def load(data, model:, relation_name:, scoping_parent:)
19
+ return if data.blank?
20
+
21
+ relation_class = model.active_record_class.reflections[relation_name.to_s].options[:class_name]
22
+ relation_class ||= relation_name.to_s.classify
23
+ relation_class = relation_class.constantize
24
+
25
+ data = data.to_s.split(COMPOSITE_IDENTIFIER_SEPARATOR).map(&:strip)
26
+
27
+ conditions = columns.zip(data).to_h
28
+
29
+ query_scoped = Utils::RelationScoper.call(relation_class.all, relation_class, scoping_parent)
30
+
31
+ record = query_scoped.find_by(conditions)
32
+ return record if record
33
+
34
+ ImpExp::Serializers::Errors::RecordNotFound.new(relation_class, conditions)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ class Time
6
+ def dump(record, attribute_name)
7
+ record.send(attribute_name)&.strftime("%H:%M:%S")
8
+ end
9
+
10
+ # rubocop: disable Lint/UnusedMethodArgument
11
+ def load(data, model: nil, record: nil, scoping_parent: nil)
12
+ data&.strip
13
+ end
14
+ # rubocop: enable Lint/UnusedMethodArgument
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ class ToStableUuid
6
+ # ce serializer permet de transformer un uuid en un autre uuid
7
+ # de manière stable, c'est à dire qu'appeler 2 fois le serializer
8
+ # avec le même argument (même uuid) donnera toujours le même uuid en
9
+ # résultat du dump
10
+
11
+ def dump(record, attribute_name)
12
+ value = record.send(attribute_name)
13
+ return if value.blank?
14
+
15
+ Digest::MD5.hexdigest(value).gsub(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '\1-\2-\3-\4-\5')
16
+ end
17
+
18
+ # rubocop: disable Lint/UnusedMethodArgument
19
+ def load(data, model: nil, record: nil, scoping_parent: nil)
20
+ data&.strip
21
+ end
22
+ # rubocop: enable Lint/UnusedMethodArgument
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ class WholeRecords
6
+ attr_reader :columns
7
+
8
+ def initialize(columns)
9
+ @columns = columns
10
+ end
11
+
12
+ def dump(record, relation_name)
13
+ record.send(relation_name).map do |associated_records|
14
+ associated_records.slice(columns)
15
+ end.to_json
16
+ end
17
+
18
+ # rubocop: disable Lint/UnusedMethodArgument
19
+ def load(data, model:, relation_name:, scoping_parent: nil)
20
+ return [] if data.blank?
21
+
22
+ begin
23
+ data = JSON.parse(data)
24
+ rescue JSON::ParserError => e
25
+ message = e.message.gsub("859: ", "") # Ruby 3.1 + 3.2 compatibility
26
+ return [ImpExp::Serializers::Errors::WrongFormat.new("#{relation_name} error | #{message}")]
27
+ end
28
+
29
+ unless data.is_a?(Array) && data.all? { |datum| datum.is_a?(Hash) }
30
+ return [ImpExp::Serializers::Errors::WrongFormat.new("#{relation_name} should be an array of hashes")]
31
+ end
32
+
33
+ data = data.map(&:deep_symbolize_keys)
34
+
35
+ relation_class = model.active_record_class.reflections[relation_name.to_s].options[:class_name]
36
+ relation_class ||= relation_name.to_s.classify
37
+ relation_class = relation_class.constantize
38
+
39
+ data.map do |hash|
40
+ relation_class.new(hash.slice(*columns))
41
+ end
42
+ end
43
+ # rubocop: enable Lint/UnusedMethodArgument
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ class Service
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Utils
5
+ class RelationScoper < Service
6
+ attr_reader :records, :relation_class, :scoping_parent
7
+
8
+ def initialize(records, relation_class, scoping_parent)
9
+ super()
10
+ @records = records
11
+ @relation_class = relation_class
12
+ @scoping_parent = scoping_parent
13
+ end
14
+
15
+ def call
16
+ records.where(id: imp_exp_model.scoped(scoping_parent))
17
+ end
18
+
19
+ private
20
+
21
+ def imp_exp_model
22
+ "ImpExp::Models::#{relation_class}".constantize.new
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xsv'
4
+
5
+ module ImpExp
6
+ module Utils
7
+ class XlsxReader
8
+ attr_reader :workbook, :skip_rows, :parse_headers
9
+
10
+ delegate :sheets, to: :workbook
11
+
12
+ def initialize(xlsx_stream, skip_rows: 1, parse_headers: true)
13
+ @workbook = Xsv.open(xlsx_stream, parse_headers: parse_headers)
14
+ @parse_headers = parse_headers
15
+ @skip_rows = skip_rows
16
+ end
17
+
18
+ def sheet_names
19
+ sheets.map(&:name)
20
+ end
21
+
22
+ def sheet_by_name(sheet_name)
23
+ workbook.sheets_by_name(sheet_name)[0]
24
+ end
25
+
26
+ def sheet_header_names(sheet_name)
27
+ return [] unless parse_headers
28
+
29
+ sheet = sheet_by_name(sheet_name)
30
+ return [] unless sheet
31
+
32
+ sheet[0].keys.compact.map(&:to_sym)
33
+ end
34
+
35
+ def sheet_row_values(sheet_name, row_nb)
36
+ sheet = sheet_by_name(sheet_name)
37
+ return [] unless sheet
38
+
39
+ data = sheet[row_nb + skip_rows]
40
+ return data unless parse_headers
41
+
42
+ data.values
43
+ end
44
+
45
+ def sheet_data(sheet_name)
46
+ data = sheet_by_name(sheet_name).entries[skip_rows..]
47
+
48
+ return [] if data.nil?
49
+
50
+ if parse_headers
51
+ data.map { |h| h.delete_if { |k, _v| k.nil? }.with_indifferent_access }
52
+ .delete_if { |h| h.values.all?(&:blank?) }
53
+ else
54
+ data.delete_if { |a| a.all?(&:blank?) }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,10 @@
1
+ en:
2
+ imp_exp:
3
+ importer:
4
+ invalid_columns: "The %{model_name} sheet does not contain the expected columns. Expected columns: %{expected_columns} Columns in the file: %{in_file_columns}."
5
+ invalid_sheets: "The file does not contain the expected sheets. Expected sheets: %{expected_sheets} Sheets in the file: %{in_file_sheets}."
6
+ record_not_found: "%{model_name} with %{conditions} conditions has not been found."
7
+ wrong_format: "Format error: %{message}."
8
+ model_errors: "%{model_name} sheet - Row %{line}: %{messages}"
9
+ data_validation_error: "%{model_name} sheet - Row %{line} - Column \"%{column}\": value \"%{value}\" incorrect, expected value: %{expected}"
10
+ file_not_found: "%{file_path} file not found."
@@ -0,0 +1,10 @@
1
+ fr:
2
+ imp_exp:
3
+ importer:
4
+ invalid_columns: "La feuille %{model_name} ne contient pas les colonnes attendues. Colonnes attendues : %{expected_columns}. Colonnes dans le fichier : %{in_file_columns}."
5
+ invalid_sheets: "Le fichier ne contient pas les feuilles attendues. Feuilles attendues : %{expected_sheets}. Feuilles dans le fichier : %{in_file_sheets}."
6
+ record_not_found: "%{model_name} avec les conditions %{conditions} n'a pas été trouvé."
7
+ wrong_format: "Erreur de format : %{message}."
8
+ model_errors: "Feuille %{model_name} - Ligne %{line} : %{messages}"
9
+ data_validation_error: "Feuille %{model_name} - Ligne %{line} - Colonne \"%{column}\" : valeur \"%{value}\" incorrecte, valeur attendue : %{expected}"
10
+ file_not_found: "Fichier %{file_path} non trouvé."
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ VERSION = "1.0.0"
5
+ end
data/lib/imp_exp.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ COMPOSITE_IDENTIFIER_SEPARATOR = '|'
6
+ end
7
+
8
+ class Engine < ::Rails::Engine
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :imp_exp do
4
+ task generate_account_export: :environment do
5
+ xlsx_stream = ImpExp::Exports::Account.new(Account.find_by!(code: "C1")).call[:xlsx_stream]
6
+ File.binwrite('../../account_export.xlsx', xlsx_stream.read)
7
+ end
8
+ end
metadata ADDED
@@ -0,0 +1,130 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: imp_exp
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Nicolas Florentin
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-10-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: caxlsx
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.4'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 7.0.5
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 7.0.5
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubyzip
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 2.3.2
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 2.3.2
55
+ - !ruby/object:Gem::Dependency
56
+ name: xsv
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.2'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.2'
69
+ description: Import data from xlsx into a rails app and export data to xlsx from a
70
+ rails app
71
+ email:
72
+ - nicolas@sleede.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - MIT-LICENSE
78
+ - README.md
79
+ - Rakefile
80
+ - app/services/imp_exp/data_validators/base.rb
81
+ - app/services/imp_exp/exporters/base.rb
82
+ - app/services/imp_exp/exporters/files.rb
83
+ - app/services/imp_exp/exporters/xlsx.rb
84
+ - app/services/imp_exp/importers/base.rb
85
+ - app/services/imp_exp/importers/xlsx.rb
86
+ - app/services/imp_exp/models/base.rb
87
+ - app/services/imp_exp/ping_observer.rb
88
+ - app/services/imp_exp/serializers/array_like.rb
89
+ - app/services/imp_exp/serializers/errors/base.rb
90
+ - app/services/imp_exp/serializers/errors/record_not_found.rb
91
+ - app/services/imp_exp/serializers/errors/wrong_format.rb
92
+ - app/services/imp_exp/serializers/polymorphic_relation_one_to_one.rb
93
+ - app/services/imp_exp/serializers/relation_one_to_many.rb
94
+ - app/services/imp_exp/serializers/relation_one_to_one.rb
95
+ - app/services/imp_exp/serializers/time.rb
96
+ - app/services/imp_exp/serializers/to_stable_uuid.rb
97
+ - app/services/imp_exp/serializers/whole_records.rb
98
+ - app/services/imp_exp/service.rb
99
+ - app/services/imp_exp/utils/relation_scoper.rb
100
+ - app/services/imp_exp/utils/xlsx_reader.rb
101
+ - config/locales/en.yml
102
+ - config/locales/fr.yml
103
+ - lib/imp_exp.rb
104
+ - lib/imp_exp/version.rb
105
+ - lib/tasks/imp_exp.rake
106
+ homepage:
107
+ licenses:
108
+ - MIT
109
+ metadata: {}
110
+ post_install_message:
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '3.1'
119
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ requirements: []
125
+ rubygems_version: 3.4.10
126
+ signing_key:
127
+ specification_version: 4
128
+ summary: Import data from xlsx into a rails app and export data to xlsx from a rails
129
+ app
130
+ test_files: []