data_porter 0.1.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.
- checksums.yaml +7 -0
- data/.claude/commands/blog-status.md +10 -0
- data/.claude/commands/blog.md +109 -0
- data/.claude/commands/task-done.md +27 -0
- data/.claude/commands/tm/add-dependency.md +58 -0
- data/.claude/commands/tm/add-subtask.md +79 -0
- data/.claude/commands/tm/add-task.md +81 -0
- data/.claude/commands/tm/analyze-complexity.md +124 -0
- data/.claude/commands/tm/analyze-project.md +100 -0
- data/.claude/commands/tm/auto-implement-tasks.md +100 -0
- data/.claude/commands/tm/command-pipeline.md +80 -0
- data/.claude/commands/tm/complexity-report.md +120 -0
- data/.claude/commands/tm/convert-task-to-subtask.md +74 -0
- data/.claude/commands/tm/expand-all-tasks.md +52 -0
- data/.claude/commands/tm/expand-task.md +52 -0
- data/.claude/commands/tm/fix-dependencies.md +82 -0
- data/.claude/commands/tm/help.md +101 -0
- data/.claude/commands/tm/init-project-quick.md +49 -0
- data/.claude/commands/tm/init-project.md +53 -0
- data/.claude/commands/tm/install-taskmaster.md +118 -0
- data/.claude/commands/tm/learn.md +106 -0
- data/.claude/commands/tm/list-tasks-by-status.md +42 -0
- data/.claude/commands/tm/list-tasks-with-subtasks.md +30 -0
- data/.claude/commands/tm/list-tasks.md +46 -0
- data/.claude/commands/tm/next-task.md +69 -0
- data/.claude/commands/tm/parse-prd-with-research.md +51 -0
- data/.claude/commands/tm/parse-prd.md +52 -0
- data/.claude/commands/tm/project-status.md +67 -0
- data/.claude/commands/tm/quick-install-taskmaster.md +23 -0
- data/.claude/commands/tm/remove-all-subtasks.md +94 -0
- data/.claude/commands/tm/remove-dependency.md +65 -0
- data/.claude/commands/tm/remove-subtask.md +87 -0
- data/.claude/commands/tm/remove-subtasks.md +89 -0
- data/.claude/commands/tm/remove-task.md +110 -0
- data/.claude/commands/tm/setup-models.md +52 -0
- data/.claude/commands/tm/show-task.md +85 -0
- data/.claude/commands/tm/smart-workflow.md +58 -0
- data/.claude/commands/tm/sync-readme.md +120 -0
- data/.claude/commands/tm/tm-main.md +147 -0
- data/.claude/commands/tm/to-cancelled.md +58 -0
- data/.claude/commands/tm/to-deferred.md +50 -0
- data/.claude/commands/tm/to-done.md +47 -0
- data/.claude/commands/tm/to-in-progress.md +39 -0
- data/.claude/commands/tm/to-pending.md +35 -0
- data/.claude/commands/tm/to-review.md +43 -0
- data/.claude/commands/tm/update-single-task.md +122 -0
- data/.claude/commands/tm/update-task.md +75 -0
- data/.claude/commands/tm/update-tasks-from-id.md +111 -0
- data/.claude/commands/tm/validate-dependencies.md +72 -0
- data/.claude/commands/tm/view-models.md +52 -0
- data/.env.example +12 -0
- data/.mcp.json +24 -0
- data/.taskmaster/CLAUDE.md +435 -0
- data/.taskmaster/config.json +44 -0
- data/.taskmaster/docs/prd.txt +2044 -0
- data/.taskmaster/state.json +6 -0
- data/.taskmaster/tasks/task_001.md +19 -0
- data/.taskmaster/tasks/task_002.md +19 -0
- data/.taskmaster/tasks/task_003.md +19 -0
- data/.taskmaster/tasks/task_004.md +19 -0
- data/.taskmaster/tasks/task_005.md +19 -0
- data/.taskmaster/tasks/task_006.md +19 -0
- data/.taskmaster/tasks/task_007.md +19 -0
- data/.taskmaster/tasks/task_008.md +19 -0
- data/.taskmaster/tasks/task_009.md +19 -0
- data/.taskmaster/tasks/task_010.md +19 -0
- data/.taskmaster/tasks/task_011.md +19 -0
- data/.taskmaster/tasks/task_012.md +19 -0
- data/.taskmaster/tasks/task_013.md +19 -0
- data/.taskmaster/tasks/task_014.md +19 -0
- data/.taskmaster/tasks/task_015.md +19 -0
- data/.taskmaster/tasks/task_016.md +19 -0
- data/.taskmaster/tasks/task_017.md +19 -0
- data/.taskmaster/tasks/task_018.md +19 -0
- data/.taskmaster/tasks/task_019.md +19 -0
- data/.taskmaster/tasks/task_020.md +19 -0
- data/.taskmaster/tasks/tasks.json +299 -0
- data/.taskmaster/templates/example_prd.txt +47 -0
- data/.taskmaster/templates/example_prd_rpg.txt +511 -0
- data/CHANGELOG.md +29 -0
- data/CLAUDE.md +65 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTING.md +49 -0
- data/LICENSE +21 -0
- data/README.md +463 -0
- data/Rakefile +12 -0
- data/app/assets/stylesheets/data_porter/application.css +646 -0
- data/app/channels/data_porter/import_channel.rb +10 -0
- data/app/controllers/data_porter/imports_controller.rb +68 -0
- data/app/javascript/data_porter/progress_controller.js +33 -0
- data/app/jobs/data_porter/dry_run_job.rb +12 -0
- data/app/jobs/data_porter/import_job.rb +12 -0
- data/app/jobs/data_porter/parse_job.rb +12 -0
- data/app/models/data_porter/data_import.rb +49 -0
- data/app/views/data_porter/imports/index.html.erb +142 -0
- data/app/views/data_porter/imports/new.html.erb +88 -0
- data/app/views/data_porter/imports/show.html.erb +49 -0
- data/config/database.yml +3 -0
- data/config/routes.rb +12 -0
- data/docs/SPEC.md +2012 -0
- data/docs/UI.md +32 -0
- data/docs/blog/001-why-build-a-data-import-engine.md +166 -0
- data/docs/blog/002-scaffolding-a-rails-engine.md +188 -0
- data/docs/blog/003-configuration-dsl.md +222 -0
- data/docs/blog/004-store-model-jsonb.md +237 -0
- data/docs/blog/005-target-dsl.md +284 -0
- data/docs/blog/006-parsing-csv-sources.md +300 -0
- data/docs/blog/007-orchestrator.md +247 -0
- data/docs/blog/008-actioncable-stimulus.md +376 -0
- data/docs/blog/009-phlex-ui-components.md +446 -0
- data/docs/blog/010-controllers-routing.md +374 -0
- data/docs/blog/011-generators.md +364 -0
- data/docs/blog/012-json-api-sources.md +323 -0
- data/docs/blog/013-testing-rails-engine.md +618 -0
- data/docs/blog/014-dry-run.md +307 -0
- data/docs/blog/015-publishing-retro.md +264 -0
- data/docs/blog/016-erb-view-templates.md +431 -0
- data/docs/blog/017-showcase-final-retro.md +220 -0
- data/docs/blog/BACKLOG.md +8 -0
- data/docs/blog/SERIES.md +154 -0
- data/docs/screenshots/index-with-previewing.jpg +0 -0
- data/docs/screenshots/index.jpg +0 -0
- data/docs/screenshots/modal-new-import.jpg +0 -0
- data/docs/screenshots/preview.jpg +0 -0
- data/lib/data_porter/broadcaster.rb +29 -0
- data/lib/data_porter/components/base.rb +10 -0
- data/lib/data_porter/components/failure_alert.rb +20 -0
- data/lib/data_porter/components/preview_table.rb +54 -0
- data/lib/data_porter/components/progress_bar.rb +33 -0
- data/lib/data_porter/components/results_summary.rb +19 -0
- data/lib/data_porter/components/status_badge.rb +16 -0
- data/lib/data_porter/components/summary_cards.rb +30 -0
- data/lib/data_porter/components.rb +14 -0
- data/lib/data_porter/configuration.rb +25 -0
- data/lib/data_porter/dsl/api_config.rb +25 -0
- data/lib/data_porter/dsl/column.rb +17 -0
- data/lib/data_porter/engine.rb +15 -0
- data/lib/data_porter/orchestrator.rb +141 -0
- data/lib/data_porter/record_validator.rb +32 -0
- data/lib/data_porter/registry.rb +33 -0
- data/lib/data_porter/sources/api.rb +49 -0
- data/lib/data_porter/sources/base.rb +35 -0
- data/lib/data_porter/sources/csv.rb +43 -0
- data/lib/data_porter/sources/json.rb +45 -0
- data/lib/data_porter/sources.rb +20 -0
- data/lib/data_porter/store_models/error.rb +13 -0
- data/lib/data_porter/store_models/import_record.rb +52 -0
- data/lib/data_porter/store_models/report.rb +21 -0
- data/lib/data_porter/target.rb +89 -0
- data/lib/data_porter/type_validator.rb +46 -0
- data/lib/data_porter/version.rb +5 -0
- data/lib/data_porter.rb +32 -0
- data/lib/generators/data_porter/install/install_generator.rb +33 -0
- data/lib/generators/data_porter/install/templates/create_data_porter_imports.rb.erb +21 -0
- data/lib/generators/data_porter/install/templates/initializer.rb +30 -0
- data/lib/generators/data_porter/target/target_generator.rb +44 -0
- data/lib/generators/data_porter/target/templates/target.rb.tt +20 -0
- data/sig/data_porter.rbs +4 -0
- metadata +274 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataPorter
|
|
4
|
+
class TargetNotFound < Error; end
|
|
5
|
+
|
|
6
|
+
module Registry
|
|
7
|
+
@targets = {}
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def register(key, klass)
|
|
11
|
+
@targets[key.to_sym] = klass
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def find(key)
|
|
15
|
+
@targets.fetch(key.to_sym) { raise TargetNotFound, "Target '#{key}' not found" }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def available
|
|
19
|
+
@targets.map do |key, klass|
|
|
20
|
+
{ key: key, label: klass._label, icon: klass._icon }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def refresh!
|
|
25
|
+
clear
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def clear
|
|
29
|
+
@targets = {}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module DataPorter
|
|
7
|
+
module Sources
|
|
8
|
+
class Api < Base
|
|
9
|
+
def fetch
|
|
10
|
+
api = @target_class._api_config
|
|
11
|
+
response = perform_request(api)
|
|
12
|
+
parsed = ::JSON.parse(response.body)
|
|
13
|
+
records = extract_records(parsed, api)
|
|
14
|
+
|
|
15
|
+
Array(records).map do |hash|
|
|
16
|
+
hash.transform_keys { |k| k.parameterize(separator: "_").to_sym }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def perform_request(api)
|
|
23
|
+
url = resolve_endpoint(api)
|
|
24
|
+
headers = resolve_headers(api)
|
|
25
|
+
uri = URI(url)
|
|
26
|
+
|
|
27
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
28
|
+
request = Net::HTTP::Get.new(uri)
|
|
29
|
+
headers.each { |k, v| request[k] = v }
|
|
30
|
+
http.request(request)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resolve_endpoint(api)
|
|
35
|
+
params = @data_import.config.symbolize_keys
|
|
36
|
+
api.endpoint.is_a?(Proc) ? api.endpoint.call(params) : api.endpoint
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolve_headers(api)
|
|
40
|
+
api.headers.is_a?(Proc) ? api.headers.call : (api.headers || {})
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def extract_records(parsed, api)
|
|
44
|
+
root = api.response_root
|
|
45
|
+
root ? parsed[root.to_s] : parsed
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataPorter
|
|
4
|
+
module Sources
|
|
5
|
+
class Base
|
|
6
|
+
def initialize(data_import, **)
|
|
7
|
+
@data_import = data_import
|
|
8
|
+
@target_class = data_import.target_class
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def fetch
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def apply_csv_mapping(row)
|
|
18
|
+
mappings = @target_class._csv_mappings
|
|
19
|
+
return auto_map(row) if mappings.nil? || mappings.empty?
|
|
20
|
+
|
|
21
|
+
explicit_map(row, mappings)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def explicit_map(row, mappings)
|
|
25
|
+
mappings.each_with_object({}) do |(header, column), hash|
|
|
26
|
+
hash[column] = row[header]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def auto_map(row)
|
|
31
|
+
row.to_h.transform_keys { |k| k.parameterize(separator: "_").to_sym }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module DataPorter
|
|
6
|
+
module Sources
|
|
7
|
+
class Csv < Base
|
|
8
|
+
def initialize(data_import, content: nil)
|
|
9
|
+
super(data_import)
|
|
10
|
+
@content = content
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fetch
|
|
14
|
+
rows = []
|
|
15
|
+
::CSV.parse(csv_content, **csv_options) do |row|
|
|
16
|
+
rows << apply_csv_mapping(row)
|
|
17
|
+
end
|
|
18
|
+
rows
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def csv_content
|
|
24
|
+
@content || download_file
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def download_file
|
|
28
|
+
@data_import.file.download.force_encoding("UTF-8")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def csv_options
|
|
32
|
+
{ headers: true }.merge(extra_options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def extra_options
|
|
36
|
+
config = @data_import.config
|
|
37
|
+
return {} unless config.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
config.symbolize_keys.slice(:col_sep, :encoding)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module DataPorter
|
|
6
|
+
module Sources
|
|
7
|
+
class Json < Base
|
|
8
|
+
def initialize(data_import, content: nil)
|
|
9
|
+
super(data_import)
|
|
10
|
+
@content = content
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fetch
|
|
14
|
+
parsed = ::JSON.parse(json_content)
|
|
15
|
+
records = extract_records(parsed)
|
|
16
|
+
|
|
17
|
+
Array(records).map do |hash|
|
|
18
|
+
hash.transform_keys { |k| k.parameterize(separator: "_").to_sym }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def json_content
|
|
25
|
+
@content || config_raw_json || download_file
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def config_raw_json
|
|
29
|
+
config = @data_import.config
|
|
30
|
+
config["raw_json"] if config.is_a?(Hash)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def download_file
|
|
34
|
+
@data_import.file.download
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def extract_records(parsed)
|
|
38
|
+
root = @target_class._json_root
|
|
39
|
+
return parsed unless root
|
|
40
|
+
|
|
41
|
+
parsed.dig(*root.split("."))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sources/base"
|
|
4
|
+
require_relative "sources/csv"
|
|
5
|
+
require_relative "sources/json"
|
|
6
|
+
require_relative "sources/api"
|
|
7
|
+
|
|
8
|
+
module DataPorter
|
|
9
|
+
module Sources
|
|
10
|
+
REGISTRY = {
|
|
11
|
+
api: Api,
|
|
12
|
+
csv: Csv,
|
|
13
|
+
json: Json
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def self.resolve(type)
|
|
17
|
+
REGISTRY.fetch(type.to_sym) { raise Error, "Unknown source type: #{type}" }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "store_model"
|
|
4
|
+
require_relative "error"
|
|
5
|
+
|
|
6
|
+
module DataPorter
|
|
7
|
+
module StoreModels
|
|
8
|
+
class ImportRecord
|
|
9
|
+
include StoreModel::Model
|
|
10
|
+
|
|
11
|
+
attribute :line_number, :integer
|
|
12
|
+
attribute :status, :string, default: "pending"
|
|
13
|
+
attribute :data, default: -> { {} }
|
|
14
|
+
attribute :errors_list, Error.to_array_type, default: -> { [] }
|
|
15
|
+
attribute :warnings, Error.to_array_type, default: -> { [] }
|
|
16
|
+
attribute :target_id, :integer
|
|
17
|
+
attribute :dry_run_passed, :boolean, default: false
|
|
18
|
+
|
|
19
|
+
def complete? = status == "complete"
|
|
20
|
+
|
|
21
|
+
def importable? = status == "complete"
|
|
22
|
+
|
|
23
|
+
def add_error(message)
|
|
24
|
+
errors_list << Error.new(message: message)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add_warning(message)
|
|
28
|
+
warnings << Error.new(message: message)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def attributes
|
|
32
|
+
data.symbolize_keys.compact
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def determine_status!
|
|
36
|
+
self.status = if required_error?
|
|
37
|
+
"missing"
|
|
38
|
+
elsif errors_list.any?
|
|
39
|
+
"partial"
|
|
40
|
+
else
|
|
41
|
+
"complete"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def required_error?
|
|
48
|
+
errors_list.any? { |e| e.message.include?("required") }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "store_model"
|
|
4
|
+
require_relative "error"
|
|
5
|
+
|
|
6
|
+
module DataPorter
|
|
7
|
+
module StoreModels
|
|
8
|
+
class Report
|
|
9
|
+
include StoreModel::Model
|
|
10
|
+
|
|
11
|
+
attribute :records_count, :integer, default: 0
|
|
12
|
+
attribute :complete_count, :integer, default: 0
|
|
13
|
+
attribute :partial_count, :integer, default: 0
|
|
14
|
+
attribute :missing_count, :integer, default: 0
|
|
15
|
+
attribute :duplicate_count, :integer, default: 0
|
|
16
|
+
attribute :imported_count, :integer, default: 0
|
|
17
|
+
attribute :errored_count, :integer, default: 0
|
|
18
|
+
attribute :error_reports, Error.to_array_type, default: []
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "dsl/column"
|
|
4
|
+
require_relative "dsl/api_config"
|
|
5
|
+
|
|
6
|
+
module DataPorter
|
|
7
|
+
class Target
|
|
8
|
+
class << self
|
|
9
|
+
attr_reader :_label, :_model_name, :_icon, :_sources,
|
|
10
|
+
:_columns, :_csv_mappings, :_dedup_keys, :_json_root,
|
|
11
|
+
:_api_config, :_dry_run_enabled
|
|
12
|
+
|
|
13
|
+
def label(value)
|
|
14
|
+
@_label = value
|
|
15
|
+
auto_register
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def model_name(value)
|
|
19
|
+
@_model_name = value
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def icon(value)
|
|
23
|
+
@_icon = value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def sources(*types)
|
|
27
|
+
@_sources = types.map(&:to_sym)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def columns(&)
|
|
31
|
+
@_columns = []
|
|
32
|
+
instance_eval(&)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def column(name, **)
|
|
36
|
+
@_columns << DSL::Column.new(name: name, **)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def csv_mapping(&)
|
|
40
|
+
@_csv_mappings = {}
|
|
41
|
+
instance_eval(&)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def map(hash)
|
|
45
|
+
@_csv_mappings.merge!(hash)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def deduplicate_by(*keys)
|
|
49
|
+
@_dedup_keys = keys.map(&:to_sym)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def json_root(path)
|
|
53
|
+
@_json_root = path
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def api_config(&)
|
|
57
|
+
@_api_config = DSL::ApiConfig.new
|
|
58
|
+
@_api_config.instance_eval(&)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def dry_run_enabled
|
|
62
|
+
@_dry_run_enabled = true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def auto_register
|
|
68
|
+
return unless name
|
|
69
|
+
|
|
70
|
+
key = name.demodulize.delete_suffix("Target").underscore
|
|
71
|
+
Registry.register(key, self)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def transform(record)
|
|
76
|
+
record
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate(record); end
|
|
80
|
+
|
|
81
|
+
def persist(_record, context:)
|
|
82
|
+
raise NotImplementedError
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def after_import(_results, context:); end
|
|
86
|
+
|
|
87
|
+
def on_error(_record, _error, context:); end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
module DataPorter
|
|
7
|
+
module TypeValidator
|
|
8
|
+
VALIDATORS = {
|
|
9
|
+
string: ->(_value, _opts) { true },
|
|
10
|
+
integer: ->(value, _opts) { Integer(value, exception: false) },
|
|
11
|
+
decimal: ->(value, _opts) { Float(value, exception: false) },
|
|
12
|
+
date: ->(value, opts) { parse_date(value, opts) },
|
|
13
|
+
datetime: ->(value, _opts) { DateTime.parse(value) rescue false }, # rubocop:disable Style/RescueModifier
|
|
14
|
+
email: ->(value, _opts) { value.match?(/\A[^@\s]+@[^@\s]+\z/) },
|
|
15
|
+
phone: ->(value, _opts) { value.match?(/\A[+\d][\d\s\-().]{6,}\z/) },
|
|
16
|
+
url: ->(value, _opts) { valid_url?(value) },
|
|
17
|
+
boolean: ->(value, _opts) { %w[true false 1 0].include?(value.to_s.downcase) }
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def self.valid?(value, type, options = {})
|
|
21
|
+
validator = VALIDATORS[type.to_sym]
|
|
22
|
+
return true unless validator
|
|
23
|
+
|
|
24
|
+
!!validator.call(value.to_s, options)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.parse_date(value, opts)
|
|
28
|
+
if opts[:format]
|
|
29
|
+
Date.strptime(value.to_s, opts[:format])
|
|
30
|
+
else
|
|
31
|
+
Date.parse(value.to_s)
|
|
32
|
+
end
|
|
33
|
+
rescue ArgumentError
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.valid_url?(value)
|
|
38
|
+
uri = URI.parse(value.to_s)
|
|
39
|
+
uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
40
|
+
rescue URI::InvalidURIError
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private_class_method :parse_date, :valid_url?
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/data_porter.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module DataPorter
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "data_porter/version"
|
|
10
|
+
require_relative "data_porter/configuration"
|
|
11
|
+
require_relative "data_porter/type_validator"
|
|
12
|
+
require_relative "data_porter/store_models/error"
|
|
13
|
+
require_relative "data_porter/store_models/report"
|
|
14
|
+
require_relative "data_porter/store_models/import_record"
|
|
15
|
+
require_relative "data_porter/target"
|
|
16
|
+
require_relative "data_porter/registry"
|
|
17
|
+
require_relative "data_porter/sources"
|
|
18
|
+
require_relative "data_porter/record_validator"
|
|
19
|
+
require_relative "data_porter/broadcaster"
|
|
20
|
+
require_relative "data_porter/orchestrator"
|
|
21
|
+
require_relative "data_porter/components"
|
|
22
|
+
require_relative "data_porter/engine"
|
|
23
|
+
|
|
24
|
+
module DataPorter
|
|
25
|
+
def self.configuration
|
|
26
|
+
@configuration ||= Configuration.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.configure
|
|
30
|
+
yield(configuration)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module DataPorter
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
def copy_migration
|
|
14
|
+
migration_template(
|
|
15
|
+
"create_data_porter_imports.rb.erb",
|
|
16
|
+
"db/migrate/create_data_porter_imports.rb"
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def copy_initializer
|
|
21
|
+
template("initializer.rb", "config/initializers/data_porter.rb")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create_importers_directory
|
|
25
|
+
empty_directory("app/importers")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def mount_engine
|
|
29
|
+
route 'mount DataPorter::Engine, at: "/imports"'
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateDataPorterImports < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
create_table :data_porter_imports do |t|
|
|
6
|
+
t.string :target_key, null: false
|
|
7
|
+
t.string :source_type, null: false, default: "csv"
|
|
8
|
+
t.integer :status, null: false, default: 0
|
|
9
|
+
t.jsonb :records, null: false, default: []
|
|
10
|
+
t.jsonb :report, null: false, default: {}
|
|
11
|
+
t.jsonb :config, null: false, default: {}
|
|
12
|
+
|
|
13
|
+
t.references :user, polymorphic: true, null: false
|
|
14
|
+
|
|
15
|
+
t.timestamps
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_index :data_porter_imports, :status
|
|
19
|
+
add_index :data_porter_imports, :target_key
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
DataPorter.configure do |config|
|
|
4
|
+
# Parent controller for the engine's controllers to inherit from.
|
|
5
|
+
# This controls authentication, layouts, and helpers.
|
|
6
|
+
# config.parent_controller = "ApplicationController"
|
|
7
|
+
|
|
8
|
+
# ActiveJob queue name for import jobs.
|
|
9
|
+
# config.queue_name = :imports
|
|
10
|
+
|
|
11
|
+
# ActiveStorage service for uploaded files.
|
|
12
|
+
# config.storage_service = :local
|
|
13
|
+
|
|
14
|
+
# ActionCable channel prefix.
|
|
15
|
+
# config.cable_channel_prefix = "data_porter"
|
|
16
|
+
|
|
17
|
+
# Context builder: inject business data into targets.
|
|
18
|
+
# Receives the current controller instance.
|
|
19
|
+
# config.context_builder = ->(controller) {
|
|
20
|
+
# OpenStruct.new(
|
|
21
|
+
# user: controller.current_user
|
|
22
|
+
# )
|
|
23
|
+
# }
|
|
24
|
+
|
|
25
|
+
# Maximum number of records displayed in preview.
|
|
26
|
+
# config.preview_limit = 500
|
|
27
|
+
|
|
28
|
+
# Enabled source types.
|
|
29
|
+
# config.enabled_sources = %i[csv json api]
|
|
30
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module DataPorter
|
|
6
|
+
module Generators
|
|
7
|
+
class TargetGenerator < Rails::Generators::NamedBase
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
argument :columns, type: :array, default: [], banner: "name:type[:required]"
|
|
11
|
+
|
|
12
|
+
def create_target_file
|
|
13
|
+
template("target.rb.tt", "app/importers/#{file_name}_target.rb")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def target_class_name
|
|
19
|
+
"#{class_name}Target"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def model_name
|
|
23
|
+
class_name.singularize
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def target_label
|
|
27
|
+
class_name.titleize
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def parsed_columns
|
|
31
|
+
columns.map { |col| parse_column(col) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_column(definition)
|
|
35
|
+
parts = definition.split(":")
|
|
36
|
+
{
|
|
37
|
+
name: parts[0],
|
|
38
|
+
type: parts[1] || "string",
|
|
39
|
+
required: parts[2] == "required"
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= target_class_name %> < DataPorter::Target
|
|
4
|
+
label "<%= target_label %>"
|
|
5
|
+
model_name "<%= model_name %>"
|
|
6
|
+
icon "fas fa-file-import"
|
|
7
|
+
sources :csv
|
|
8
|
+
<% if parsed_columns.any? %>
|
|
9
|
+
|
|
10
|
+
columns do
|
|
11
|
+
<% parsed_columns.each do |col| -%>
|
|
12
|
+
column :<%= col[:name] %>, type: :<%= col[:type] %><%= ", required: true" if col[:required] %>
|
|
13
|
+
<% end -%>
|
|
14
|
+
end
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
17
|
+
def persist(record, context:)
|
|
18
|
+
# <%= model_name %>.create!(record.attributes)
|
|
19
|
+
end
|
|
20
|
+
end
|
data/sig/data_porter.rbs
ADDED