thunderframe 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b9c6e4b83f0233fc6d8f91d1e5a5285f16136964d2837753b76dd8a0dc1be928
4
+ data.tar.gz: c28f8d42fa7db56fd8f7e78b771820ca8e4869d3d3d7e5e3c86acfcdcf03306f
5
+ SHA512:
6
+ metadata.gz: 61cd9d6e6956b6d873425c6b8e7ede0d8f6338066772396da1a3e328c5369ed44fa9ea964a98cc199bc255712e033aa254f19e85538a46ec950dbdcde6744aae
7
+ data.tar.gz: cb16f8a545735240c5a947e98d930c3ea9d5726b41bd5007a8434568ca73a9126c08a26df790297aac6f4f3364510e314f333bbfb1c4419345e1b2da75d87546
data/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # thunderframe
2
+
3
+ Declares the shape of a result (`field`, `association`, `condition`) and renders entities into immutable `Data`.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderFrame
4
+ class Association
5
+ attr_reader :name, :frame, :options
6
+
7
+ def initialize(name, frame, options)
8
+ @name = name
9
+ @frame = frame
10
+ @options = options
11
+ end
12
+
13
+ def relation_name
14
+ final_name = options[:as] || name
15
+ final_name = options[:eager_loads] if options[:eager_loads].is_a?(String) || options[:eager_loads].is_a?(Symbol)
16
+
17
+ final_name.to_sym
18
+ end
19
+
20
+ def render(entity, context)
21
+ frame.render_hash(value(entity, context), context:)
22
+ end
23
+
24
+ # A callable `from` is not an association name, so it cannot be preloaded by itself: in that case only an
25
+ # explicit `eager_loads` says what to preload, and without it nothing is preloaded.
26
+ def preload_key
27
+ return options[:from] || relation_name unless options[:from].respond_to?(:call)
28
+
29
+ relation_name if options[:eager_loads].is_a?(String) || options[:eager_loads].is_a?(Symbol)
30
+ end
31
+
32
+ def prepare_preload
33
+ @prepare_preload ||= begin
34
+ below_preloads = frame.prepare_preload
35
+
36
+ {}.tap do |hash|
37
+ hash[preload_key] = below_preloads if preload_key && (below_preloads.any? || options[:eager_loads])
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def value(entity, context)
45
+ from = options[:from] || name
46
+ return from.arity == 2 ? from.call(entity, context) : from.call(entity) if from.respond_to?(:call)
47
+
48
+ entity.is_a?(Hash) ? entity[from] || entity[from.to_s] : entity.public_send(from)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderFrame
4
+ class Condition
5
+ attr_reader :test, :frame
6
+
7
+ def initialize(test, frame)
8
+ @test = test
9
+ @frame = frame
10
+ end
11
+
12
+ def applies?(entity)
13
+ case test
14
+ when Class then entity.is_a?(test)
15
+ when Symbol then entity.public_send(test)
16
+ else test.call(entity)
17
+ end
18
+ end
19
+
20
+ def prepare_preload
21
+ @prepare_preload ||= begin
22
+ below_preloads = frame.prepare_preload
23
+
24
+ {}.tap do |hash|
25
+ hash[test] = below_preloads if below_preloads.any? && test.is_a?(Class)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderFrame
4
+ class Field
5
+ attr_reader :name, :codec, :options, :block
6
+
7
+ def initialize(name, codec, options, block)
8
+ @name = name
9
+ @codec = codec
10
+ @options = options
11
+ @block = block
12
+ end
13
+
14
+ def relation_name
15
+ final_name = options[:as] || name
16
+ final_name = options[:eager_loads] if options[:eager_loads].is_a?(String) || options[:eager_loads].is_a?(Symbol)
17
+
18
+ final_name.to_sym
19
+ end
20
+
21
+ def prepare_preload
22
+ eager = options[:eager_loads]
23
+ return {} unless eager
24
+ return eager if eager.is_a?(Hash)
25
+
26
+ { relation_name => {} }
27
+ end
28
+
29
+ def extract(entity, context, label)
30
+ value = read(entity, context)
31
+ codec.coerce(value)
32
+ rescue ArgumentError
33
+ raise Error, "#{label}: #{name} must be #{codec.name}, got #{value.class} (#{value.inspect})"
34
+ end
35
+
36
+ private
37
+
38
+ def read(entity, context)
39
+ return block.arity == 2 ? block.call(entity, context) : block.call(entity) if block
40
+
41
+ source = options[:from] || name
42
+ return entity.public_send(source) unless entity.is_a?(Hash)
43
+
44
+ entity.key?(source) ? entity[source] : entity[source.to_s]
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderFrame
4
+ class Frame
5
+ attr_reader :associations, :conditions
6
+
7
+ def initialize(const_name, owner:, parent: nil, &block)
8
+ @const_name = const_name
9
+ @owner = owner
10
+ @parent = parent
11
+ @fields = []
12
+ @associations = []
13
+ @conditions = []
14
+ instance_exec(&block) if block
15
+ end
16
+
17
+ def fields(*names)
18
+ return @fields if names.empty?
19
+
20
+ names.each { |name| field(name) }
21
+ end
22
+
23
+ def field(name, type = String, **options, &block)
24
+ check_name!(name)
25
+ @fields << Field.new(name.to_sym, ThunderFoundation::Types.fetch(type), options, block)
26
+ end
27
+
28
+ def association(name, **options, &block)
29
+ check_name!(name)
30
+ @associations << Association.new(name.to_sym, nested(name, &block), options)
31
+ end
32
+
33
+ def condition(test, &block)
34
+ @conditions << Condition.new(test, nested("Condition#{@conditions.size + 1}", &block))
35
+ end
36
+
37
+ def members
38
+ fields.map(&:name) + associations.map(&:name) + conditions.flat_map { |condition| condition.frame.members }
39
+ end
40
+
41
+ # A Hash responds to each, but it is an entity and not a collection of entities; a Struct too.
42
+ def render_hash(entity, context: nil, skip_preload: false)
43
+ return if entity.nil?
44
+ return render_many_hash(entity, context:, skip_preload:) if collection?(entity)
45
+
46
+ hash = {}
47
+ fields.each { |field| hash[field.name] = field.extract(entity, context, label) }
48
+ associations.each { |association| hash[association.name] = association.render(entity, context) }
49
+ conditions.each { |condition| hash.merge!(condition.frame.render_hash(entity, context:)) if condition.applies?(entity) }
50
+ hash
51
+ end
52
+
53
+ # render_hash for a Hash that must have exactly the declared shape: a key the frame does not declare, or a declared
54
+ # field or association left out, raises Error. Associations are checked the same way, item by item when they hold
55
+ # a list. A field or association declared with `optional: true`, and the fields of a condition, may be left out.
56
+ def render_hash!(entity, context: nil)
57
+ return if entity.nil?
58
+ return entity.map { |item| render_hash!(item, context:) } if entity.is_a?(Array)
59
+ raise Error, "#{label} must be a Hash, got #{entity.class}" unless entity.is_a?(Hash)
60
+
61
+ keys = entity.keys.map(&:to_sym)
62
+ declared = fields + associations
63
+ required = declared.reject { |member| member.options[:optional] }.map(&:name)
64
+ allowed = declared.map(&:name) + conditions.flat_map { |condition| condition.frame.members }
65
+ unknown = keys - allowed
66
+ missing = required - keys
67
+
68
+ errors = []
69
+ errors << "unknown #{unknown.join(', ')}" if unknown.any?
70
+ errors << "missing #{missing.join(', ')}" if missing.any?
71
+ raise Error, "#{label}: #{errors.join('; ')}" if errors.any?
72
+
73
+ hash = {}
74
+ fields.each { |field| hash[field.name] = field.extract(entity, context, label) }
75
+ associations.each do |association|
76
+ value = entity.key?(association.name) ? entity[association.name] : entity[association.name.to_s]
77
+ hash[association.name] = association.frame.render_hash!(value, context:)
78
+ end
79
+ conditions.each { |condition| hash.merge!(condition.frame.render_hash(entity, context:)) if condition.applies?(entity) }
80
+ hash
81
+ end
82
+
83
+ # Only records of ActiveRecord are preloaded; a collection of anything else is rendered as it is.
84
+ def render_many_hash(enumerable, context: nil, skip_preload: false)
85
+ records = enumerable.to_a
86
+ Preloader.call(records, prepare_preload) if !skip_preload && defined?(::ActiveRecord::Base) && records.first.is_a?(::ActiveRecord::Base)
87
+
88
+ records.map { |entity| render_hash(entity, context:, skip_preload: true) }
89
+ end
90
+
91
+ def render_json(entity, root: :data)
92
+ { root => render_hash(entity) }.to_json
93
+ end
94
+
95
+ alias render render_json
96
+
97
+ def prepare_preload
98
+ @prepare_preload ||= begin
99
+ hash = associations.each_with_object({}) { |association, acc| acc.deep_merge!(association.prepare_preload) }
100
+ hash = fields.each_with_object(hash) { |field, acc| acc.deep_merge!(field.prepare_preload) }
101
+ conditions.each_with_object(hash) { |condition, acc| acc.deep_merge!(condition.prepare_preload) }
102
+ end
103
+ end
104
+
105
+ def label
106
+ @parent ? "#{@parent.label}.#{@const_name.to_s.underscore}" : "#{@owner.name}::#{@const_name}"
107
+ end
108
+
109
+ private
110
+
111
+ def nested(name, &block)
112
+ Frame.new(name, owner: @owner, parent: self, &block)
113
+ end
114
+
115
+ def check_name!(name)
116
+ raise Error, "#{label}: #{name} declared twice" if members.include?(name.to_sym)
117
+ end
118
+
119
+ def collection?(entity)
120
+ entity.is_a?(Enumerable) && !entity.is_a?(Hash) && !entity.is_a?(Struct)
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ported from the Framer of tcard_backend (lib/framer/preloader.rb). Preloads the associations of ActiveRecord
4
+ # records, reusing records already loaded instead of querying them again.
5
+ module ThunderFrame
6
+ class Preloader
7
+ def self.call(records, association)
8
+ new.call(records, association)
9
+ end
10
+
11
+ def initialize
12
+ @available_records = []
13
+ @cached_record_ids = Hash.new { |h, k| h[k] = Set.new }
14
+ end
15
+
16
+ def call(records, association)
17
+ records = records.to_a if records.respond_to?(:to_a) && !records.is_a?(Array)
18
+ return [] if records.empty?
19
+
20
+ case association
21
+ when Symbol, String
22
+ result = ActiveRecord::Associations::Preloader.new(
23
+ records:, associations: association, available_records: @available_records
24
+ ).call
25
+ cache_preloaded_records(result)
26
+ result
27
+ when Hash
28
+ association.each do |key, value|
29
+ preloader_result = call(records, key)
30
+
31
+ handle_preloader_result(preloader_result, value)
32
+ end
33
+ when Array
34
+ association.each do |value|
35
+ call(records, value)
36
+ end
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def all_records_from(result)
43
+ seen = Set.new
44
+ records = []
45
+
46
+ result.records_by_owner.each_value do |owner_records|
47
+ owner_records.each do |record|
48
+ next unless seen.add?(record.object_id)
49
+
50
+ records << record
51
+ end
52
+ end
53
+
54
+ return records if records.any?
55
+
56
+ result.send(:owners).each do |owner|
57
+ assoc = owner.association(result.send(:reflection).name)
58
+ next unless assoc.loaded?
59
+
60
+ target = assoc.target
61
+ targets = target.is_a?(Array) ? target : [target].compact
62
+ targets.each do |record|
63
+ next unless seen.add?(record.object_id)
64
+
65
+ records << record
66
+ end
67
+ end
68
+
69
+ records
70
+ end
71
+
72
+ def cache_preloaded_records(preloader_result)
73
+ preloader_result.each do |result|
74
+ all_records_from(result).each do |record|
75
+ key = record.class.base_class.name
76
+ next if @cached_record_ids[key].include?(record.id)
77
+
78
+ @cached_record_ids[key] << record.id
79
+ @available_records << record
80
+ end
81
+ end
82
+ end
83
+
84
+ def resolve_class_key(preloader_result, key)
85
+ indexed_result = preloader_result.index_by(&:klass)
86
+
87
+ return all_records_from(indexed_result[key]) if indexed_result[key]
88
+
89
+ parent_result = indexed_result.find { |klass, _| key < klass }&.last
90
+ return unless parent_result
91
+
92
+ all_records_from(parent_result).select { |r| r.is_a?(key) }
93
+ end
94
+
95
+ def handle_preloader_result(preloader_result, association)
96
+ return unless preloader_result.any?
97
+
98
+ case association
99
+ when Hash
100
+ association.each do |key, value|
101
+ if key.is_a?(Class)
102
+ records = resolve_class_key(preloader_result, key)
103
+ call(records, value) if records&.any?
104
+ else
105
+ preloader_result.each do |result|
106
+ sub_result = call(all_records_from(result), key)
107
+
108
+ handle_preloader_result(sub_result, value) if sub_result&.any?
109
+ end
110
+ end
111
+ end
112
+ when Array
113
+ association.each do |value|
114
+ case value
115
+ when Hash
116
+ handle_preloader_result(preloader_result, value)
117
+ else
118
+ preloader_result.each do |result|
119
+ call(all_records_from(result), value)
120
+ end
121
+ end
122
+ end
123
+ else
124
+ preloader_result.each do |result|
125
+ call(all_records_from(result), association)
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderFrame
4
+ VERSION = '0.2.0'
5
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext/string/inflections'
5
+ require 'active_support/core_ext/hash/deep_merge'
6
+ require 'json'
7
+ require 'thunderfoundation'
8
+
9
+ require_relative 'thunderframe/version'
10
+
11
+ module ThunderFrame
12
+ class Error < StandardError; end
13
+ end
14
+
15
+ require_relative 'thunderframe/field'
16
+ require_relative 'thunderframe/association'
17
+ require_relative 'thunderframe/condition'
18
+ require_relative 'thunderframe/preloader'
19
+ require_relative 'thunderframe/frame'
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: thunderframe
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Albert Alef
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '8.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '8.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: thunderfoundation
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - '='
31
+ - !ruby/object:Gem::Version
32
+ version: 0.2.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - '='
38
+ - !ruby/object:Gem::Version
39
+ version: 0.2.0
40
+ executables: []
41
+ extensions: []
42
+ extra_rdoc_files: []
43
+ files:
44
+ - README.md
45
+ - lib/thunderframe.rb
46
+ - lib/thunderframe/association.rb
47
+ - lib/thunderframe/condition.rb
48
+ - lib/thunderframe/field.rb
49
+ - lib/thunderframe/frame.rb
50
+ - lib/thunderframe/preloader.rb
51
+ - lib/thunderframe/version.rb
52
+ licenses: []
53
+ metadata:
54
+ rubygems_mfa_required: 'true'
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '3.4'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.6.9
70
+ specification_version: 4
71
+ summary: Declares the shape of a result and renders entities into immutable Data
72
+ test_files: []