yaml_exporter 0.1.0 → 0.2.1

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.
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YamlExporter
4
+ module Nodes
5
+ # `one :assoc, find_by: :col, of: :nested_assoc`
6
+ #
7
+ # Identifies the target record indirectly: the YAML value is looked up on
8
+ # a related model (`of:`) rather than on the target itself.
9
+ #
10
+ # Example: Book `belongs_to :responsible_editor` (CorporateUser), and
11
+ # CorporateUser `belongs_to :user` (User with a slug). Declaring
12
+ # `one :responsible_editor, find_by: :slug, of: :user`
13
+ # stores the User's slug in the YAML and resolves the CorporateUser on
14
+ # import by reversing the FK.
15
+ #
16
+ # Only 1:[0,1] `of:` associations are permitted (belongs_to or has_one).
17
+ # Phase: :pre_save (sets the FK before record.save!).
18
+ class OneReferenceOf
19
+ include OfResolution
20
+
21
+ attr_reader :name, :owner_class, :find_by, :of
22
+
23
+ def initialize(name:, owner_class:, find_by:, of:)
24
+ @name = name.to_sym
25
+ @owner_class = owner_class
26
+ @find_by = find_by.to_sym
27
+ @of = of.to_sym
28
+
29
+ reflection = owner_class.reflect_on_association(@name)
30
+ unless reflection
31
+ raise ArgumentError,
32
+ "`one #{name.inspect}`: #{owner_class} has no association `#{name}`"
33
+ end
34
+
35
+ validate_of_reflection!("one #{name.inspect}")
36
+ end
37
+
38
+ def phase
39
+ :pre_save
40
+ end
41
+
42
+ def yaml_keys
43
+ [@name.to_s]
44
+ end
45
+
46
+ def target_class
47
+ @target_class ||= @owner_class.reflect_on_association(@name).klass
48
+ end
49
+
50
+ def import(record, data, path:, importer:)
51
+ value = data.key?(@name.to_s) ? data[@name.to_s] : nil
52
+
53
+ if value.nil?
54
+ record.public_send("#{@name}=", nil)
55
+ return
56
+ end
57
+
58
+ related = of_class.find_by(@find_by => value)
59
+ unless related
60
+ raise ActiveRecord::RecordNotFound,
61
+ "no #{of_class} with #{@find_by}=#{value.inspect}"
62
+ end
63
+
64
+ target = find_target_via(related)
65
+ unless target
66
+ raise ActiveRecord::RecordNotFound,
67
+ "no #{target_class} linked to #{of_class} #{@find_by}=#{value.inspect} via `#{@of}`"
68
+ end
69
+
70
+ record.public_send("#{@name}=", target)
71
+ end
72
+
73
+ def export(record, exporter:)
74
+ target = record.public_send(@name)
75
+ return [@name.to_s, nil] if target.nil?
76
+
77
+ [@name.to_s, of_value_for(target)]
78
+ end
79
+
80
+ def schema_fragment
81
+ { @name => { type: TypeInference.schema_type_for(of_class, @find_by) } }
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YamlExporter
4
+ # Builds a JSON-schema-ish hash describing a structure. Keys are symbols
5
+ # so callers can use `schema[:properties][:title]` ergonomically.
6
+ class Schema
7
+ def self.generate(structure)
8
+ new(structure).generate
9
+ end
10
+
11
+ def initialize(structure)
12
+ @structure = structure
13
+ end
14
+
15
+ def generate
16
+ {
17
+ type: 'object',
18
+ properties: @structure.nodes.each_with_object({}) do |node, acc|
19
+ acc.merge!(node.schema_fragment)
20
+ end
21
+ }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YamlExporter
4
+ # Immutable representation of a `yaml_structure do ... end` block.
5
+ #
6
+ # * `klass` — the ActiveRecord class this structure is attached to.
7
+ # Resolved lazily: the constructor accepts either a class or a callable,
8
+ # and defers to first access. This matters for inner structures whose
9
+ # class might be resolved via a reflection that isn't safe to compute
10
+ # at declaration time (anonymous parent classes, etc.).
11
+ # * `nodes` — ordered list of node objects. Order matches declaration and
12
+ # drives export key order.
13
+ class Structure
14
+ attr_reader :nodes
15
+
16
+ def initialize(klass:, nodes:)
17
+ @klass_resolver = klass.respond_to?(:call) ? klass : -> { klass }
18
+ @nodes = nodes
19
+ end
20
+
21
+ def klass
22
+ @klass ||= @klass_resolver.call
23
+ end
24
+ end
25
+ end
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YamlExporter
4
+ VERSION = '0.2.1'
5
+ end
data/lib/yaml_exporter.rb CHANGED
@@ -1,9 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'yaml_serializable/structure_builder'
4
- require_relative 'yaml_serializable/yaml_exporter'
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,49 @@ 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 = YamlSerializable::StructureBuilder.new(&block).build
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
- YamlSerializable::YamlExporter.generate_schema(self, yaml_structure_definition)
38
+ YamlExporter::Schema.generate(yaml_structure_definition)
20
39
  end
21
40
  end
22
41
 
23
42
  module InstanceMethods
24
- def yaml_export
25
- YamlSerializable::YamlExporter.export(self, self.class.yaml_structure_definition)
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
- def yaml_import(yaml_string)
29
- YamlSerializable::YamlExporter.import(self, yaml_string, self.class.yaml_structure_definition)
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/of_resolution'
67
+ require_relative 'yaml_exporter/nodes/one_owned'
68
+ require_relative 'yaml_exporter/nodes/one_reference'
69
+ require_relative 'yaml_exporter/nodes/one_reference_of'
70
+ require_relative 'yaml_exporter/nodes/many_base'
71
+ require_relative 'yaml_exporter/nodes/many_positional'
72
+ require_relative 'yaml_exporter/nodes/many_find_by'
73
+ require_relative 'yaml_exporter/nodes/many_reference'
74
+ require_relative 'yaml_exporter/nodes/many_through'
75
+ require_relative 'yaml_exporter/builder'
76
+ require_relative 'yaml_exporter/importer'
77
+ require_relative 'yaml_exporter/exporter'
78
+ require_relative 'yaml_exporter/schema'
@@ -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
@@ -1,24 +1,21 @@
1
- require_relative "lib/yaml_serializable/version"
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 = YamlSerializable::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 serialization for ActiveRecord models with JSON schema support"
10
- spec.description = "A Ruby gem for YAML serialization and deserialization of ActiveRecord models with JSON schema generation."
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.5.0")
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
- end
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.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anatoly Zelenin
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-08-29 00:00:00.000000000 Z
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
- description: A Ruby gem for YAML serialization and deserialization of ActiveRecord
56
- models with JSON schema generation.
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,35 @@ 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/yaml_serializable/structure_builder.rb
70
- - lib/yaml_serializable/version.rb
71
- - lib/yaml_serializable/yaml_exporter.rb
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/of_resolution.rb
112
+ - lib/yaml_exporter/nodes/one_owned.rb
113
+ - lib/yaml_exporter/nodes/one_reference.rb
114
+ - lib/yaml_exporter/nodes/one_reference_of.rb
115
+ - lib/yaml_exporter/schema.rb
116
+ - lib/yaml_exporter/structure.rb
117
+ - lib/yaml_exporter/type_inference.rb
118
+ - lib/yaml_exporter/version.rb
119
+ - script/demo_quiz.rb
72
120
  - yaml_exporter.gemspec
73
121
  homepage: https://github.com/itadventurer/yaml_exporter
74
122
  licenses:
@@ -76,7 +124,6 @@ licenses:
76
124
  metadata:
77
125
  homepage_uri: https://github.com/itadventurer/yaml_exporter
78
126
  source_code_uri: https://github.com/itadventurer/yaml_exporter
79
- post_install_message:
80
127
  rdoc_options: []
81
128
  require_paths:
82
129
  - lib
@@ -84,15 +131,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
84
131
  requirements:
85
132
  - - ">="
86
133
  - !ruby/object:Gem::Version
87
- version: 2.5.0
134
+ version: '3.2'
88
135
  required_rubygems_version: !ruby/object:Gem::Requirement
89
136
  requirements:
90
137
  - - ">="
91
138
  - !ruby/object:Gem::Version
92
139
  version: '0'
93
140
  requirements: []
94
- rubygems_version: 3.5.11
95
- signing_key:
141
+ rubygems_version: 3.6.9
96
142
  specification_version: 4
97
- summary: YAML serialization for ActiveRecord models with JSON schema support
143
+ summary: YAML import/export for ActiveRecord models with a small declarative DSL
98
144
  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,3 +0,0 @@
1
- module YamlSerializable
2
- VERSION = "0.1.0"
3
- end