yaml_exporter 0.1.0 → 0.2.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 +4 -4
- data/.github/workflows/gem-push.yml +20 -29
- data/.github/workflows/test.yml +27 -0
- data/AGENTS.md +34 -0
- data/CHANGELOG.md +45 -0
- data/Gemfile.lock +106 -0
- data/README.md +772 -37
- data/RELEASING.md +45 -0
- data/Rakefile +14 -0
- data/lib/yaml_exporter/builder.rb +93 -0
- data/lib/yaml_exporter/exporter.rb +139 -0
- data/lib/yaml_exporter/importer.rb +86 -0
- data/lib/yaml_exporter/nodes/attribute.rb +70 -0
- data/lib/yaml_exporter/nodes/many_base.rb +215 -0
- data/lib/yaml_exporter/nodes/many_find_by.rb +70 -0
- data/lib/yaml_exporter/nodes/many_positional.rb +14 -0
- data/lib/yaml_exporter/nodes/many_reference.rb +73 -0
- data/lib/yaml_exporter/nodes/many_through.rb +176 -0
- data/lib/yaml_exporter/nodes/one_owned.rb +96 -0
- data/lib/yaml_exporter/nodes/one_reference.rb +72 -0
- data/lib/yaml_exporter/nodes/one_reference_of.rb +128 -0
- data/lib/yaml_exporter/schema.rb +24 -0
- data/lib/yaml_exporter/structure.rb +25 -0
- data/lib/yaml_exporter/type_inference.rb +64 -0
- data/lib/yaml_exporter/version.rb +5 -0
- data/lib/yaml_exporter.rb +53 -8
- data/script/demo_quiz.rb +78 -0
- data/yaml_exporter.gemspec +8 -9
- metadata +58 -13
- data/lib/yaml_serializable/structure_builder.rb +0 -30
- data/lib/yaml_serializable/version.rb +0 -3
- data/lib/yaml_serializable/yaml_exporter.rb +0 -196
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YamlExporter
|
|
4
|
+
# Maps an ActiveRecord column type to a JSON-schema primitive.
|
|
5
|
+
#
|
|
6
|
+
# Single source of truth used by every node that emits a `type:` entry
|
|
7
|
+
# in the generated schema (Nodes::Attribute, Nodes::OneReference,
|
|
8
|
+
# Nodes::ManyReference, and the extra_entry_keys path in Nodes::ManyBase).
|
|
9
|
+
#
|
|
10
|
+
# Unknown or un-resolvable columns fall back to 'string' so the schema
|
|
11
|
+
# always validates as JSON-schema-ish.
|
|
12
|
+
module TypeInference
|
|
13
|
+
# AR column type -> JSON-schema primitive. Date/time stay 'string' on
|
|
14
|
+
# purpose: YAML round-trips them as ISO-8601 strings by default, and we
|
|
15
|
+
# don't (yet) emit a `format:` facet.
|
|
16
|
+
COLUMN_TYPE_TO_JSON_SCHEMA = {
|
|
17
|
+
string: 'string',
|
|
18
|
+
text: 'string',
|
|
19
|
+
uuid: 'string',
|
|
20
|
+
binary: 'string',
|
|
21
|
+
integer: 'integer',
|
|
22
|
+
bigint: 'integer',
|
|
23
|
+
float: 'number',
|
|
24
|
+
decimal: 'number',
|
|
25
|
+
boolean: 'boolean',
|
|
26
|
+
date: 'string',
|
|
27
|
+
datetime: 'string',
|
|
28
|
+
time: 'string',
|
|
29
|
+
timestamp: 'string',
|
|
30
|
+
json: 'object',
|
|
31
|
+
jsonb: 'object'
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
FALLBACK = 'string'
|
|
35
|
+
|
|
36
|
+
# Returns the JSON-schema type string for `column_name` on `klass`.
|
|
37
|
+
# `klass` may be nil (e.g. declaration-time lookup on an anonymous
|
|
38
|
+
# class whose table doesn't exist yet) — we quietly fall back.
|
|
39
|
+
def self.schema_type_for(klass, column_name)
|
|
40
|
+
return FALLBACK unless klass.respond_to?(:columns_hash)
|
|
41
|
+
|
|
42
|
+
column = klass.columns_hash[column_name.to_s]
|
|
43
|
+
return FALLBACK unless column
|
|
44
|
+
|
|
45
|
+
COLUMN_TYPE_TO_JSON_SCHEMA[column.type] || FALLBACK
|
|
46
|
+
rescue ActiveRecord::ActiveRecordError
|
|
47
|
+
FALLBACK
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# True when `column_name` on `klass` is a `text` column (as opposed to a
|
|
51
|
+
# `string`/varchar). Drives whether string values export as YAML literal
|
|
52
|
+
# block scalars. Unknown/unresolvable columns are treated as non-text.
|
|
53
|
+
def self.text_column?(klass, column_name)
|
|
54
|
+
return false unless klass.respond_to?(:columns_hash)
|
|
55
|
+
|
|
56
|
+
column = klass.columns_hash[column_name.to_s]
|
|
57
|
+
return false unless column
|
|
58
|
+
|
|
59
|
+
column.type == :text
|
|
60
|
+
rescue ActiveRecord::ActiveRecordError
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/yaml_exporter.rb
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
require 'active_record'
|
|
4
|
+
require 'active_support/core_ext/class/attribute'
|
|
5
5
|
|
|
6
|
+
require_relative 'yaml_exporter/version'
|
|
7
|
+
|
|
8
|
+
# Top-level entrypoint: defines the `include YamlExporter` hook and the
|
|
9
|
+
# public API methods (`yaml_structure`, `yaml_schema`, `yaml_import`,
|
|
10
|
+
# `yaml_export`). Parsing, import, export and schema generation live in
|
|
11
|
+
# dedicated classes under `lib/yaml_exporter/`.
|
|
6
12
|
module YamlExporter
|
|
13
|
+
# Base class for every library-specific error. Inherits from
|
|
14
|
+
# ActiveRecord::ActiveRecordError so existing `rescue ActiveRecord::ActiveRecordError`
|
|
15
|
+
# blocks (and the import transaction wrapper) catch it naturally.
|
|
16
|
+
class Error < ActiveRecord::ActiveRecordError; end
|
|
17
|
+
|
|
18
|
+
# Raised when a YAML document carries a key (top-level or inside an entry)
|
|
19
|
+
# that the corresponding `yaml_structure` scope does not declare.
|
|
20
|
+
class UnknownAttributeError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when a `find_by`-identified list contains two entries with the same
|
|
23
|
+
# key value — e.g. two `slug: chapter-1` entries under one `book_parts:`.
|
|
24
|
+
class DuplicateKeyError < Error; end
|
|
25
|
+
|
|
7
26
|
def self.included(base)
|
|
8
27
|
base.extend(ClassMethods)
|
|
9
28
|
end
|
|
@@ -11,22 +30,48 @@ module YamlExporter
|
|
|
11
30
|
module ClassMethods
|
|
12
31
|
def yaml_structure(&block)
|
|
13
32
|
class_attribute :yaml_structure_definition, instance_writer: false
|
|
14
|
-
self.yaml_structure_definition =
|
|
33
|
+
self.yaml_structure_definition = YamlExporter::Builder.new(self, &block).build
|
|
15
34
|
include InstanceMethods
|
|
16
35
|
end
|
|
17
36
|
|
|
18
37
|
def yaml_schema
|
|
19
|
-
|
|
38
|
+
YamlExporter::Schema.generate(yaml_structure_definition)
|
|
20
39
|
end
|
|
21
40
|
end
|
|
22
41
|
|
|
23
42
|
module InstanceMethods
|
|
24
|
-
def
|
|
25
|
-
|
|
43
|
+
def yaml_import(yaml_string)
|
|
44
|
+
YamlExporter::Importer.new(self, self.class.yaml_structure_definition).import(yaml_string)
|
|
26
45
|
end
|
|
27
46
|
|
|
28
|
-
|
|
29
|
-
|
|
47
|
+
# `omit_nil:` (default true) drops keys whose value is empty — a nil
|
|
48
|
+
# attribute, a missing `one` reference, an absent owned `one`, or an empty
|
|
49
|
+
# `many` list. Round-trip safe: import treats a missing key, an explicit
|
|
50
|
+
# `null`, and an empty list identically. Pass `omit_nil: false` to keep
|
|
51
|
+
# explicit `null`s in the file (e.g. so optional fields stay discoverable).
|
|
52
|
+
def yaml_export(omit_nil: true)
|
|
53
|
+
YamlExporter::Exporter.new(
|
|
54
|
+
self, self.class.yaml_structure_definition, omit_nil: omit_nil
|
|
55
|
+
).to_yaml
|
|
30
56
|
end
|
|
31
57
|
end
|
|
32
58
|
end
|
|
59
|
+
|
|
60
|
+
require 'set'
|
|
61
|
+
require 'yaml'
|
|
62
|
+
|
|
63
|
+
require_relative 'yaml_exporter/structure'
|
|
64
|
+
require_relative 'yaml_exporter/type_inference'
|
|
65
|
+
require_relative 'yaml_exporter/nodes/attribute'
|
|
66
|
+
require_relative 'yaml_exporter/nodes/one_owned'
|
|
67
|
+
require_relative 'yaml_exporter/nodes/one_reference'
|
|
68
|
+
require_relative 'yaml_exporter/nodes/one_reference_of'
|
|
69
|
+
require_relative 'yaml_exporter/nodes/many_base'
|
|
70
|
+
require_relative 'yaml_exporter/nodes/many_positional'
|
|
71
|
+
require_relative 'yaml_exporter/nodes/many_find_by'
|
|
72
|
+
require_relative 'yaml_exporter/nodes/many_reference'
|
|
73
|
+
require_relative 'yaml_exporter/nodes/many_through'
|
|
74
|
+
require_relative 'yaml_exporter/builder'
|
|
75
|
+
require_relative 'yaml_exporter/importer'
|
|
76
|
+
require_relative 'yaml_exporter/exporter'
|
|
77
|
+
require_relative 'yaml_exporter/schema'
|
data/script/demo_quiz.rb
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Runnable demo sharing the same schema and models as the test suite.
|
|
5
|
+
# From the repository root:
|
|
6
|
+
#
|
|
7
|
+
# ruby script/demo_quiz.rb
|
|
8
|
+
#
|
|
9
|
+
# One-time: gem install sqlite3 && bundle install
|
|
10
|
+
|
|
11
|
+
require 'bundler/setup'
|
|
12
|
+
require 'active_record'
|
|
13
|
+
require 'json'
|
|
14
|
+
require 'pathname'
|
|
15
|
+
|
|
16
|
+
root = Pathname.new(__dir__).join('..').expand_path
|
|
17
|
+
$LOAD_PATH.unshift root.join('lib').to_s
|
|
18
|
+
require 'yaml_exporter'
|
|
19
|
+
|
|
20
|
+
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
|
|
21
|
+
ActiveRecord::Base.logger = nil
|
|
22
|
+
|
|
23
|
+
load root.join('test/support/schema.rb')
|
|
24
|
+
load root.join('test/support/models.rb')
|
|
25
|
+
|
|
26
|
+
quiz = Quiz.create!(title: 'Ruby Basics', quiz_type: 'multiple_choice')
|
|
27
|
+
|
|
28
|
+
q1 = quiz.questions.create!(
|
|
29
|
+
text: 'What is Ruby?',
|
|
30
|
+
question_type: 'single_choice',
|
|
31
|
+
feedback: 'Ruby is a dynamic, open-source programming language.'
|
|
32
|
+
)
|
|
33
|
+
q1.answers.create!(text: 'A programming language', is_correct: true, impact: 10)
|
|
34
|
+
q1.answers.create!(text: 'A gemstone', is_correct: false, impact: 0)
|
|
35
|
+
q1.answers.create!(text: 'A color', is_correct: false, impact: 0)
|
|
36
|
+
|
|
37
|
+
q2 = quiz.questions.create!(
|
|
38
|
+
text: 'Which keyword defines a method in Ruby?',
|
|
39
|
+
question_type: 'single_choice',
|
|
40
|
+
feedback: "Use 'def' to define methods."
|
|
41
|
+
)
|
|
42
|
+
q2.answers.create!(text: 'def', is_correct: true, impact: 10)
|
|
43
|
+
q2.answers.create!(text: 'func', is_correct: false, impact: 0)
|
|
44
|
+
q2.answers.create!(text: 'fn', is_correct: false, impact: 0)
|
|
45
|
+
|
|
46
|
+
puts '=' * 60
|
|
47
|
+
puts '1) YAML Export'
|
|
48
|
+
puts '=' * 60
|
|
49
|
+
yaml_output = quiz.yaml_export
|
|
50
|
+
puts yaml_output
|
|
51
|
+
|
|
52
|
+
puts '=' * 60
|
|
53
|
+
puts '2) JSON Schema'
|
|
54
|
+
puts '=' * 60
|
|
55
|
+
schema = Quiz.yaml_schema
|
|
56
|
+
puts JSON.pretty_generate(schema)
|
|
57
|
+
|
|
58
|
+
puts "\n" + '=' * 60
|
|
59
|
+
puts '3) YAML Import (round-trip into a new Quiz)'
|
|
60
|
+
puts '=' * 60
|
|
61
|
+
|
|
62
|
+
imported_quiz = Quiz.create!(title: 'placeholder', quiz_type: 'placeholder')
|
|
63
|
+
imported_quiz.yaml_import(yaml_output)
|
|
64
|
+
|
|
65
|
+
puts "Title: #{imported_quiz.title}"
|
|
66
|
+
puts "Type: #{imported_quiz.quiz_type}"
|
|
67
|
+
puts "Questions: #{imported_quiz.questions.count}"
|
|
68
|
+
imported_quiz.questions.each do |q|
|
|
69
|
+
puts " Q: #{q.text}"
|
|
70
|
+
q.answers.each do |a|
|
|
71
|
+
marker = a.is_correct ? '✓' : '✗'
|
|
72
|
+
puts " #{marker} #{a.text} (impact: #{a.impact || 'n/a'})"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
puts "\n" + '=' * 60
|
|
77
|
+
puts 'Done — all features working!'
|
|
78
|
+
puts '=' * 60
|
data/yaml_exporter.gemspec
CHANGED
|
@@ -1,24 +1,21 @@
|
|
|
1
|
-
require_relative "lib/
|
|
1
|
+
require_relative "lib/yaml_exporter/version"
|
|
2
2
|
|
|
3
3
|
Gem::Specification.new do |spec|
|
|
4
4
|
spec.name = "yaml_exporter"
|
|
5
|
-
spec.version =
|
|
5
|
+
spec.version = YamlExporter::VERSION
|
|
6
6
|
spec.authors = ["Anatoly Zelenin"]
|
|
7
7
|
spec.email = ["anatoly@zelenin.de"]
|
|
8
8
|
|
|
9
|
-
spec.summary = "YAML
|
|
10
|
-
spec.description = "A Ruby gem
|
|
9
|
+
spec.summary = "YAML import/export for ActiveRecord models with a small declarative DSL"
|
|
10
|
+
spec.description = "A Ruby gem that declaratively maps ActiveRecord models to YAML documents (and back) via a compact `attributes` / `one` / `many` DSL."
|
|
11
11
|
spec.homepage = "https://github.com/itadventurer/yaml_exporter"
|
|
12
12
|
spec.license = "MIT"
|
|
13
13
|
|
|
14
|
-
spec.required_ruby_version = Gem::Requirement.new(">= 2
|
|
14
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 3.2")
|
|
15
15
|
|
|
16
16
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
17
17
|
spec.metadata["source_code_uri"] = "https://github.com/itadventurer/yaml_exporter"
|
|
18
|
-
#spec.metadata["changelog_uri"] = "https://github.com/itadventurer/yaml_exporter/blob/master/CHANGELOG.md"
|
|
19
18
|
|
|
20
|
-
# Specify which files should be added to the gem when it is released.
|
|
21
|
-
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
22
19
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
23
20
|
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
|
24
21
|
end
|
|
@@ -27,5 +24,7 @@ Gem::Specification.new do |spec|
|
|
|
27
24
|
spec.add_dependency "activerecord", ">= 5.2"
|
|
28
25
|
spec.add_dependency "activesupport", ">= 5.2"
|
|
29
26
|
|
|
27
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
|
30
28
|
spec.add_development_dependency "rake", "~> 13.0"
|
|
31
|
-
|
|
29
|
+
spec.add_development_dependency "sqlite3", ">= 1.4"
|
|
30
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: yaml_exporter
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anatoly Zelenin
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: activerecord
|
|
@@ -38,6 +37,20 @@ dependencies:
|
|
|
38
37
|
- - ">="
|
|
39
38
|
- !ruby/object:Gem::Version
|
|
40
39
|
version: '5.2'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: minitest
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '5.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '5.0'
|
|
41
54
|
- !ruby/object:Gem::Dependency
|
|
42
55
|
name: rake
|
|
43
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -52,8 +65,22 @@ dependencies:
|
|
|
52
65
|
- - "~>"
|
|
53
66
|
- !ruby/object:Gem::Version
|
|
54
67
|
version: '13.0'
|
|
55
|
-
|
|
56
|
-
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: sqlite3
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '1.4'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '1.4'
|
|
82
|
+
description: A Ruby gem that declaratively maps ActiveRecord models to YAML documents
|
|
83
|
+
(and back) via a compact `attributes` / `one` / `many` DSL.
|
|
57
84
|
email:
|
|
58
85
|
- anatoly@zelenin.de
|
|
59
86
|
executables: []
|
|
@@ -61,14 +88,34 @@ extensions: []
|
|
|
61
88
|
extra_rdoc_files: []
|
|
62
89
|
files:
|
|
63
90
|
- ".github/workflows/gem-push.yml"
|
|
91
|
+
- ".github/workflows/test.yml"
|
|
64
92
|
- ".gitignore"
|
|
93
|
+
- AGENTS.md
|
|
94
|
+
- CHANGELOG.md
|
|
65
95
|
- Gemfile
|
|
96
|
+
- Gemfile.lock
|
|
66
97
|
- LICENSE
|
|
67
98
|
- README.md
|
|
99
|
+
- RELEASING.md
|
|
100
|
+
- Rakefile
|
|
68
101
|
- lib/yaml_exporter.rb
|
|
69
|
-
- lib/
|
|
70
|
-
- lib/
|
|
71
|
-
- lib/
|
|
102
|
+
- lib/yaml_exporter/builder.rb
|
|
103
|
+
- lib/yaml_exporter/exporter.rb
|
|
104
|
+
- lib/yaml_exporter/importer.rb
|
|
105
|
+
- lib/yaml_exporter/nodes/attribute.rb
|
|
106
|
+
- lib/yaml_exporter/nodes/many_base.rb
|
|
107
|
+
- lib/yaml_exporter/nodes/many_find_by.rb
|
|
108
|
+
- lib/yaml_exporter/nodes/many_positional.rb
|
|
109
|
+
- lib/yaml_exporter/nodes/many_reference.rb
|
|
110
|
+
- lib/yaml_exporter/nodes/many_through.rb
|
|
111
|
+
- lib/yaml_exporter/nodes/one_owned.rb
|
|
112
|
+
- lib/yaml_exporter/nodes/one_reference.rb
|
|
113
|
+
- lib/yaml_exporter/nodes/one_reference_of.rb
|
|
114
|
+
- lib/yaml_exporter/schema.rb
|
|
115
|
+
- lib/yaml_exporter/structure.rb
|
|
116
|
+
- lib/yaml_exporter/type_inference.rb
|
|
117
|
+
- lib/yaml_exporter/version.rb
|
|
118
|
+
- script/demo_quiz.rb
|
|
72
119
|
- yaml_exporter.gemspec
|
|
73
120
|
homepage: https://github.com/itadventurer/yaml_exporter
|
|
74
121
|
licenses:
|
|
@@ -76,7 +123,6 @@ licenses:
|
|
|
76
123
|
metadata:
|
|
77
124
|
homepage_uri: https://github.com/itadventurer/yaml_exporter
|
|
78
125
|
source_code_uri: https://github.com/itadventurer/yaml_exporter
|
|
79
|
-
post_install_message:
|
|
80
126
|
rdoc_options: []
|
|
81
127
|
require_paths:
|
|
82
128
|
- lib
|
|
@@ -84,15 +130,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
84
130
|
requirements:
|
|
85
131
|
- - ">="
|
|
86
132
|
- !ruby/object:Gem::Version
|
|
87
|
-
version: 2
|
|
133
|
+
version: '3.2'
|
|
88
134
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
89
135
|
requirements:
|
|
90
136
|
- - ">="
|
|
91
137
|
- !ruby/object:Gem::Version
|
|
92
138
|
version: '0'
|
|
93
139
|
requirements: []
|
|
94
|
-
rubygems_version: 3.
|
|
95
|
-
signing_key:
|
|
140
|
+
rubygems_version: 3.6.9
|
|
96
141
|
specification_version: 4
|
|
97
|
-
summary: YAML
|
|
142
|
+
summary: YAML import/export for ActiveRecord models with a small declarative DSL
|
|
98
143
|
test_files: []
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module YamlSerializable
|
|
4
|
-
class StructureBuilder
|
|
5
|
-
def initialize(&block)
|
|
6
|
-
@structure = { attributes: [], associations: {} }
|
|
7
|
-
instance_eval(&block)
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
def yaml_attribute(*attrs)
|
|
11
|
-
@structure[:attributes].concat(attrs)
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def yaml_has_one(name, &block)
|
|
15
|
-
@structure[:associations][name] = { type: :has_one, structure: self.class.new(&block).build }
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def yaml_has_many(name, &block)
|
|
19
|
-
@structure[:associations][name] = { type: :has_many, structure: self.class.new(&block).build }
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def yaml_condition(&block)
|
|
23
|
-
@structure[:condition] = block
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def build
|
|
27
|
-
@structure
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
require 'yaml'
|
|
2
|
-
require 'json-schema'
|
|
3
|
-
|
|
4
|
-
module YamlSerializable
|
|
5
|
-
class YamlExporter
|
|
6
|
-
def self.export(object, structure)
|
|
7
|
-
new(object, structure).export
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
def self.import(object, yaml_string, structure)
|
|
11
|
-
new(object, structure).import(yaml_string)
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def self.generate_schema(klass, structure)
|
|
15
|
-
new(klass.new, structure).generate_schema
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def initialize(object, structure)
|
|
19
|
-
@object = object
|
|
20
|
-
@structure = structure
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def export
|
|
24
|
-
yaml = YAML.dump(build_hash(@structure, @object))
|
|
25
|
-
validate(yaml)
|
|
26
|
-
yaml
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def import(yaml_string)
|
|
30
|
-
data = YAML.safe_load(yaml_string)
|
|
31
|
-
validate(yaml_string)
|
|
32
|
-
update_object(data)
|
|
33
|
-
@object
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def generate_schema
|
|
37
|
-
{
|
|
38
|
-
type: 'object',
|
|
39
|
-
properties: generate_properties(@structure, @object.class),
|
|
40
|
-
required: required_attributes(@structure, @object.class)
|
|
41
|
-
}
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
private
|
|
45
|
-
|
|
46
|
-
def build_hash(structure, object)
|
|
47
|
-
result = {}
|
|
48
|
-
|
|
49
|
-
structure[:attributes].each do |attr|
|
|
50
|
-
value = object.send(attr)
|
|
51
|
-
result[attr.to_s] = value unless value.nil?
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
structure[:associations].each do |name, config|
|
|
55
|
-
if config[:type] == :has_many
|
|
56
|
-
associated_objects = object.send(name).unscope(:order).order(:id)
|
|
57
|
-
if associated_objects.any?
|
|
58
|
-
result[name.to_s] = associated_objects.map { |item| build_hash(config[:structure], item) }
|
|
59
|
-
end
|
|
60
|
-
elsif config[:type] == :has_one
|
|
61
|
-
associated_object = object.send(name)
|
|
62
|
-
result[name.to_s] = build_hash(config[:structure], associated_object) if associated_object
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
result.compact
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def validate(yaml)
|
|
70
|
-
schema = generate_schema
|
|
71
|
-
data = YAML.safe_load(yaml)
|
|
72
|
-
JSON::Validator.validate!(schema, data)
|
|
73
|
-
rescue JSON::Schema::ValidationError => e
|
|
74
|
-
raise "Invalid YAML structure: #{e.message}"
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def update_object(data)
|
|
78
|
-
@object.transaction do
|
|
79
|
-
update_attributes(@object, data, @structure[:attributes])
|
|
80
|
-
@structure[:associations].each do |name, config|
|
|
81
|
-
if config[:type] == :has_many
|
|
82
|
-
update_collection(@object, data[name.to_s], name, config[:structure])
|
|
83
|
-
elsif config[:type] == :has_one && data[name.to_s]
|
|
84
|
-
update_nested(@object, data[name.to_s], name, config[:structure])
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
@object.save!
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def update_attributes(object, data, attributes)
|
|
92
|
-
attributes.each do |attr|
|
|
93
|
-
if data.key?(attr.to_s)
|
|
94
|
-
object.send("#{attr}=", data[attr.to_s])
|
|
95
|
-
else
|
|
96
|
-
object.send("#{attr}=", nil)
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def update_collection(parent, items_data, association_name, config)
|
|
102
|
-
existing_items = parent.send(association_name).unscope(:order).order(:id).to_a
|
|
103
|
-
new_items = []
|
|
104
|
-
|
|
105
|
-
items_data&.each_with_index do |item_data, index|
|
|
106
|
-
item = existing_items[index] || parent.send(association_name).build
|
|
107
|
-
update_attributes(item, item_data, config[:attributes])
|
|
108
|
-
config[:associations]&.each do |sub_name, sub_config|
|
|
109
|
-
if sub_config[:type] == :has_many
|
|
110
|
-
update_collection(item, item_data[sub_name.to_s], sub_name, sub_config[:structure])
|
|
111
|
-
elsif sub_config[:type] == :has_one && item_data[sub_name.to_s]
|
|
112
|
-
update_nested(item, item_data[sub_name.to_s], sub_name, sub_config[:structure])
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
new_items << item
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# Entferne Items, die nicht mehr im YAML vorhanden sind
|
|
119
|
-
(existing_items - new_items).each(&:mark_for_destruction)
|
|
120
|
-
|
|
121
|
-
parent.send("#{association_name}=", new_items)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def update_nested(parent, nested_data, association_name, config)
|
|
125
|
-
nested_object = parent.send(association_name) || parent.send("build_#{association_name}")
|
|
126
|
-
update_attributes(nested_object, nested_data, config[:attributes])
|
|
127
|
-
config[:associations]&.each do |sub_name, sub_config|
|
|
128
|
-
if sub_config[:type] == :has_many
|
|
129
|
-
update_collection(nested_object, nested_data[sub_name.to_s], sub_name, sub_config[:structure])
|
|
130
|
-
elsif sub_config[:type] == :has_one && nested_data[sub_name.to_s]
|
|
131
|
-
update_nested(nested_object, nested_data[sub_name.to_s], sub_name, sub_config[:structure])
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def generate_properties(structure, klass)
|
|
137
|
-
properties = {}
|
|
138
|
-
|
|
139
|
-
structure[:attributes].each do |attr|
|
|
140
|
-
column = klass.columns_hash[attr.to_s]
|
|
141
|
-
properties[attr.to_s] = { type: infer_type(column) }
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
structure[:associations].each do |name, config|
|
|
145
|
-
if config[:type] == :has_many
|
|
146
|
-
properties[name.to_s] = {
|
|
147
|
-
type: 'array',
|
|
148
|
-
items: {
|
|
149
|
-
type: 'object',
|
|
150
|
-
properties: generate_properties(config[:structure], association_class(klass, name)),
|
|
151
|
-
required: required_attributes(config[:structure], association_class(klass, name))
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
elsif config[:type] == :has_one
|
|
155
|
-
properties[name.to_s] = {
|
|
156
|
-
type: 'object',
|
|
157
|
-
properties: generate_properties(config[:structure], association_class(klass, name)),
|
|
158
|
-
required: required_attributes(config[:structure], association_class(klass, name))
|
|
159
|
-
}
|
|
160
|
-
end
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
properties
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def infer_type(column)
|
|
167
|
-
case column.type
|
|
168
|
-
when :string, :text
|
|
169
|
-
'string'
|
|
170
|
-
when :integer, :bigint
|
|
171
|
-
'integer'
|
|
172
|
-
when :float, :decimal
|
|
173
|
-
'number'
|
|
174
|
-
when :boolean
|
|
175
|
-
'boolean'
|
|
176
|
-
when :date, :datetime, :time
|
|
177
|
-
'string'
|
|
178
|
-
when :json, :jsonb
|
|
179
|
-
'object'
|
|
180
|
-
else
|
|
181
|
-
'string'
|
|
182
|
-
end
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def association_class(klass, association_name)
|
|
186
|
-
klass.reflect_on_association(association_name).klass
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
def required_attributes(structure, klass)
|
|
190
|
-
structure[:attributes].select do |attr|
|
|
191
|
-
column = klass.columns_hash[attr.to_s]
|
|
192
|
-
column && !column.null
|
|
193
|
-
end
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
end
|