blueprinter-rb 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ # @api private
5
+ class BlockExtractor < Extractor
6
+ def extract(field_name, object, local_options, options = {})
7
+ options[:block].call(object, local_options)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ # @api private
5
+ class HashExtractor < Extractor
6
+ def extract(field_name, object, _local_options, _options = {})
7
+ object[field_name]
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ # @api private
5
+ class PublicSendExtractor < Extractor
6
+ def extract(field_name, object, local_options, options = {})
7
+ object.public_send(field_name)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @api private
4
+ class Blueprinter::Field
5
+ attr_reader :method, :name, :extractor, :options, :blueprint
6
+ def initialize(method, name, extractor, blueprint, options = {})
7
+ @method = method
8
+ @name = name
9
+ @extractor = extractor
10
+ @blueprint = blueprint
11
+ @options = options
12
+ end
13
+
14
+ def extract(object, local_options)
15
+ extractor.extract(method, object, local_options, options)
16
+ end
17
+
18
+ def skip?(field_name, object, local_options)
19
+ return true if if_callable && !if_callable.call(field_name, object, local_options)
20
+ unless_callable && unless_callable.call(field_name, object, local_options)
21
+ end
22
+
23
+ private
24
+
25
+ def if_callable
26
+ return @if_callable if defined?(@if_callable)
27
+ @if_callable = callable_from(:if)
28
+ end
29
+
30
+ def unless_callable
31
+ return @unless_callable if defined?(@unless_callable)
32
+ @unless_callable = callable_from(:unless)
33
+ end
34
+
35
+ def callable_from(condition)
36
+ callable = old_callable_from(condition)
37
+
38
+ if callable && callable.arity == 2
39
+ Blueprinter::Deprecation.report("`:#{condition}` conditions now expects 3 arguments instead of 2.")
40
+ ->(_field_name, obj, options) { callable.call(obj, options) }
41
+ else
42
+ callable
43
+ end
44
+ end
45
+
46
+ def old_callable_from(condition)
47
+ config = Blueprinter.configuration
48
+
49
+ # Use field-level callable, or when not defined, try global callable
50
+ tmp = if options.key?(condition)
51
+ options.fetch(condition)
52
+ elsif config.valid_callable?(condition)
53
+ config.public_send(condition)
54
+ end
55
+
56
+ return false unless tmp
57
+
58
+ case tmp
59
+ when Proc then tmp
60
+ when Symbol then blueprint.method(tmp)
61
+ else
62
+ raise ArgumentError, "#{tmp.class} is passed to :#{condition}"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ class DateTimeFormatter
5
+ InvalidDateTimeFormatterError = Class.new(BlueprinterError)
6
+
7
+ def format(value, options)
8
+ return value if value.nil?
9
+
10
+ field_format = options[:datetime_format]
11
+ if value.respond_to?(:strftime)
12
+ value = format_datetime(value, field_format)
13
+ elsif field_format
14
+ raise InvalidDateTimeFormatterError, 'Cannot format invalid DateTime object'
15
+ end
16
+ value
17
+ end
18
+
19
+ private
20
+
21
+ def format_datetime(value, field_format)
22
+ format = field_format || Blueprinter.configuration.datetime_format
23
+
24
+ case format
25
+ when NilClass then value
26
+ when Proc then format.call(value)
27
+ when String then value.strftime(format)
28
+ else
29
+ raise InvalidDateTimeFormatterError, 'Cannot format DateTime object with invalid formatter: #{format.class}'
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ module BaseHelpers
5
+ def self.included(base)
6
+ base.extend(SingletonMethods)
7
+ end
8
+
9
+ module SingletonMethods
10
+ include TypeHelpers
11
+
12
+ private
13
+
14
+ def prepare_for_render(object, options)
15
+ view_name = options.delete(:view) || :default
16
+ root = options.delete(:root)
17
+ meta = options.delete(:meta)
18
+ validate_root_and_meta!(root, meta)
19
+ prepare(object, view_name: view_name, local_options: options, root: root, meta: meta)
20
+ end
21
+
22
+ def prepare_data(object, view_name, local_options)
23
+ if array_like?(object)
24
+ object.map do |obj|
25
+ object_to_hash(obj,
26
+ view_name: view_name,
27
+ local_options: local_options)
28
+ end
29
+ else
30
+ object_to_hash(object,
31
+ view_name: view_name,
32
+ local_options: local_options)
33
+ end
34
+ end
35
+
36
+ def prepend_root_and_meta(data, root, meta)
37
+ return data unless root
38
+ ret = {root => data}
39
+ meta ? ret.merge!(meta: meta) : ret
40
+ end
41
+
42
+ def inherited(subclass)
43
+ subclass.send(:view_collection).inherit(view_collection)
44
+ end
45
+
46
+ def object_to_hash(object, view_name:, local_options:)
47
+ result_hash = view_collection.fields_for(view_name).each_with_object({}) do |field, hash|
48
+ next if field.skip?(field.name, object, local_options)
49
+ hash[field.name] = field.extract(object, local_options)
50
+ end
51
+ view_collection.transformers(view_name).each do |transformer|
52
+ transformer.transform(result_hash, object, local_options)
53
+ end
54
+ result_hash
55
+ end
56
+
57
+ def validate_root_and_meta!(root, meta)
58
+ case root
59
+ when String, Symbol
60
+ # no-op
61
+ when NilClass
62
+ raise BlueprinterError, "meta requires a root to be passed" if meta
63
+ else
64
+ raise BlueprinterError, "root should be one of String, Symbol, NilClass"
65
+ end
66
+ end
67
+
68
+ def dynamic_blueprint?(blueprint)
69
+ blueprint.is_a?(Proc)
70
+ end
71
+
72
+ def validate_blueprint!(blueprint, method)
73
+ validate_presence_of_blueprint!(blueprint)
74
+ unless dynamic_blueprint?(blueprint)
75
+ validate_blueprint_has_ancestors!(blueprint, method)
76
+ validate_blueprint_has_blueprinter_base_ancestor!(blueprint, method)
77
+ end
78
+ end
79
+
80
+ def validate_presence_of_blueprint!(blueprint)
81
+ raise BlueprinterError, 'Blueprint required' unless blueprint
82
+ end
83
+
84
+ def validate_blueprint_has_ancestors!(blueprint, association_name)
85
+ # If the class passed as a blueprint does not respond to ancestors
86
+ # it means it, at the very least, does not have Blueprinter::Base as
87
+ # one of its ancestor classes (e.g: Hash) and thus an error should
88
+ # be raised.
89
+ unless blueprint.respond_to?(:ancestors)
90
+ raise BlueprinterError, "Blueprint provided for #{association_name} "\
91
+ 'association is not valid.'
92
+ end
93
+ end
94
+
95
+ def validate_blueprint_has_blueprinter_base_ancestor!(blueprint, association_name)
96
+ # Guard clause in case Blueprinter::Base is present in the ancestor list
97
+ # for the blueprint class provided.
98
+ return if blueprint.ancestors.include? Blueprinter::Base
99
+
100
+ # Raise error describing what's wrong.
101
+ raise BlueprinterError, "Class #{blueprint.name} does not inherit from "\
102
+ 'Blueprinter::Base and is not a valid Blueprinter '\
103
+ "for #{association_name} association."
104
+ end
105
+
106
+ def jsonify(blob)
107
+ Blueprinter.configuration.jsonify(blob)
108
+ end
109
+
110
+ def current_view
111
+ @current_view ||= view_collection[:default]
112
+ end
113
+
114
+ def view_collection
115
+ @view_collection ||= ViewCollection.new
116
+ end
117
+
118
+ def associations(view_name = :default)
119
+ view_collection.fields_for(view_name).select { |f| f.options[:association] }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ module TypeHelpers
5
+ private
6
+ def active_record_relation?(object)
7
+ !!(defined?(ActiveRecord::Relation) &&
8
+ object.is_a?(ActiveRecord::Relation))
9
+ end
10
+
11
+ def array_like?(object)
12
+ object.is_a?(Array) || active_record_relation?(object)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ # @api private
5
+ class Transformer
6
+ def transform(_result_hash, _primary_obj, _options = {})
7
+ fail NotImplementedError, "A Transformer must implement #transform"
8
+ end
9
+
10
+ def self.transform(result_hash, primary_obj, options = {})
11
+ self.new.transform(result_hash, primary_obj, options)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ # @api private
5
+ DefinitionPlaceholder = Struct.new :name, :view?
6
+ class View
7
+ attr_reader :excluded_field_names, :fields, :included_view_names, :name, :view_transformers, :definition_order
8
+
9
+ def initialize(name, fields: {}, included_view_names: [], excluded_view_names: [], transformers: [])
10
+ @name = name
11
+ @fields = fields
12
+ @included_view_names = included_view_names
13
+ @excluded_field_names = excluded_view_names
14
+ @view_transformers = transformers
15
+ @definition_order = []
16
+ @sort_by_definition = Blueprinter.configuration.sort_fields_by.eql?(:definition)
17
+ end
18
+
19
+ def transformers
20
+ view_transformers.empty? ? Blueprinter.configuration.default_transformers : view_transformers
21
+ end
22
+
23
+ def track_definition_order(method, is_view = true)
24
+ if @sort_by_definition
25
+ @definition_order << DefinitionPlaceholder.new(method, is_view)
26
+ end
27
+ end
28
+
29
+ def inherit(view)
30
+ view.fields.values.each do |field|
31
+ self << field
32
+ end
33
+
34
+ view.included_view_names.each do |view_name|
35
+ include_view(view_name)
36
+ end
37
+
38
+ view.excluded_field_names.each do |field_name|
39
+ exclude_field(field_name)
40
+ end
41
+
42
+ view.view_transformers.each do |transformer|
43
+ add_transformer(transformer)
44
+ end
45
+ end
46
+
47
+ def include_view(view_name)
48
+ track_definition_order(view_name)
49
+ included_view_names << view_name
50
+ end
51
+
52
+ def include_views(view_names)
53
+ view_names.each do |view_name|
54
+ track_definition_order(view_name)
55
+ included_view_names << view_name
56
+ end
57
+ end
58
+
59
+ def exclude_field(field_name)
60
+ excluded_field_names << field_name
61
+ end
62
+
63
+ def exclude_fields(field_names)
64
+ field_names.each do |field_name|
65
+ excluded_field_names << field_name
66
+ end
67
+ end
68
+
69
+ def add_transformer(custom_transformer)
70
+ view_transformers << custom_transformer
71
+ end
72
+
73
+ def <<(field)
74
+ track_definition_order(field.name,false)
75
+ fields[field.name] = field
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ # @api private
5
+ class ViewCollection
6
+ attr_reader :views, :sort_by_definition
7
+ def initialize
8
+ @views = {
9
+ identifier: View.new(:identifier),
10
+ default: View.new(:default)
11
+ }
12
+ @sort_by_definition = Blueprinter.configuration.sort_fields_by.eql?(:definition)
13
+ end
14
+
15
+ def inherit(view_collection)
16
+ view_collection.views.each do |view_name, view|
17
+ self[view_name].inherit(view)
18
+ end
19
+ end
20
+
21
+ def has_view?(view_name)
22
+ views.has_key? view_name
23
+ end
24
+
25
+ def fields_for(view_name)
26
+ return identifier_fields if view_name == :identifier
27
+
28
+ fields, excluded_fields = sortable_fields(view_name)
29
+ sorted_fields = sort_by_definition ? sort_by_def(view_name, fields) : fields.values.sort_by(&:name)
30
+
31
+ (identifier_fields + sorted_fields).reject { |field| excluded_fields.include?(field.name) }
32
+ end
33
+
34
+ def transformers(view_name)
35
+ views[view_name].transformers
36
+ end
37
+
38
+ def [](view_name)
39
+ @views[view_name] ||= View.new(view_name)
40
+ end
41
+
42
+ private
43
+
44
+ def identifier_fields
45
+ views[:identifier].fields.values
46
+ end
47
+
48
+ # @param [String] view_name
49
+ # @return [Array<(Hash, Hash<String, NilClass>)>] fields, excluded_fields
50
+ def sortable_fields(view_name)
51
+ excluded_fields = {}
52
+ fields = views[:default].fields
53
+ views[view_name].included_view_names.each do |included_view_name|
54
+ next if view_name == included_view_name
55
+
56
+ view_fields, view_excluded_fields = sortable_fields(included_view_name)
57
+ fields = merge_fields(fields, view_fields)
58
+ excluded_fields.merge!(view_excluded_fields)
59
+ end
60
+ fields = merge_fields(fields, views[view_name].fields)
61
+
62
+ views[view_name].excluded_field_names.each { |name| excluded_fields[name] = nil }
63
+
64
+ [fields, excluded_fields]
65
+ end
66
+
67
+ # select and order members of fields according to traversal of the definition_orders
68
+ def sort_by_def(view_name, fields)
69
+ ordered_fields = {}
70
+ views[:default].definition_order.each { |definition| add_to_ordered_fields(ordered_fields, definition, fields, view_name) }
71
+ ordered_fields.values
72
+ end
73
+
74
+ # view_name_filter allows to follow definition order all the way down starting from the view_name given to sort_by_def()
75
+ # but include no others at the top-level
76
+ def add_to_ordered_fields(ordered_fields, definition, fields, view_name_filter = nil)
77
+ if definition.view?
78
+ if view_name_filter.nil? || view_name_filter == definition.name
79
+ views[definition.name].definition_order.each { |_definition| add_to_ordered_fields(ordered_fields, _definition, fields) }
80
+ end
81
+ else
82
+ ordered_fields[definition.name] = fields[definition.name]
83
+ end
84
+ end
85
+
86
+ def merge_fields(source_fields, included_fields)
87
+ source_fields.merge included_fields
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'blueprinter/base'
4
+
5
+ module Blueprinter
6
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ module Generators
5
+ class BlueprintGenerator < ::Rails::Generators::NamedBase
6
+ desc "Generates blueprint for ActiveRecord model with the given NAME."
7
+
8
+ attr_accessor :options
9
+
10
+ source_root File.expand_path("../templates", __FILE__)
11
+
12
+
13
+
14
+ class_option :blueprints_dir, default: "app/blueprints", desc: "path to new blueprint", aliases: "-d"
15
+
16
+
17
+
18
+ class_option :identifier, default: nil, desc: "Add an identifer to the generated blueprint, either uses :id or your specified value", aliases: "-i", banner: "id"
19
+
20
+
21
+
22
+ class_option :fields, type: :array, default: [], desc: "Manually add specified fields"
23
+
24
+ class_option :detect_fields, type: :boolean, default: false, desc: "Introspect on the model to set fields in the generated blueprint. Will be merged with any manually specified"
25
+
26
+
27
+
28
+ class_option :associations, type: :array, default: [], desc: "Manually add specified associations", aliases: "-a"
29
+
30
+ class_option :detect_associations, type: :boolean, default: false, desc: "Introspect on the model to set associations in the generated blueprint. Will be merged with any manually specified"
31
+
32
+
33
+
34
+ class_option :wrap_at, type: :numeric, default: 80, desc: "Maximum length of generated fields line", aliases: "-w"
35
+
36
+ class_option :indentation, type: :string, default: "two", desc: "Indentation of generated file", banner: "two|four|tab"
37
+
38
+
39
+
40
+ remove_class_option :skip_namespace
41
+
42
+ def ensure_blueprint_dir
43
+ FileUtils.mkdir_p(path) unless File.directory?(path)
44
+ end
45
+
46
+ def create_blueprint
47
+ template "blueprint.rb", File.join(path, "#{file_path}_blueprint.rb")
48
+ end
49
+
50
+
51
+
52
+ private
53
+
54
+ def path
55
+ options["blueprints_dir"]
56
+ end
57
+
58
+ def identifier_symbol
59
+ if options['identifier']
60
+ options['identifier'] == "identifier" ? :id : options['identifier']
61
+ end
62
+ end
63
+
64
+ def fields
65
+ fs = if options["detect_fields"]
66
+ Array.new(options["fields"]).concat(introspected_fields)
67
+ else
68
+ options["fields"]
69
+ end
70
+ fs.reject {|f| f.blank? }.uniq
71
+ end
72
+
73
+ def introspected_fields
74
+ class_name.constantize.columns_hash.keys
75
+ end
76
+
77
+ # split at wrap_at chars, two indentations
78
+ def formatted_fields
79
+ two_indents = indent * 2
80
+ fields_string = fields.reduce([]) do |memo, f|
81
+ if !memo.last.nil?
82
+ now = "#{memo.last} :#{f},"
83
+ if now.length > options["wrap_at"].to_i
84
+ memo << ":#{f},"
85
+ else
86
+ memo[memo.length - 1] = now
87
+ end
88
+ else
89
+ memo << " :#{f},"
90
+ end
91
+ memo
92
+ end.join("\n#{two_indents}")
93
+
94
+ fields_string[0,fields_string.length - 1]
95
+ end
96
+
97
+ def associations
98
+ as = if options["detect_associations"]
99
+ Array.new(options["associations"]).concat(introspected_associations.keys)
100
+ else
101
+ options["associations"]
102
+ end
103
+ as.reject {|f| f.blank? }.uniq
104
+ end
105
+
106
+ def introspected_associations
107
+ class_name.constantize.reflections
108
+ end
109
+
110
+ def association_blueprint(association_name)
111
+ ", blueprint: #{association_class(association_name)}"
112
+ end
113
+
114
+ def association_class(association_name)
115
+ introspected_name = if introspected_associations[association_name].respond_to?(:klass)
116
+ introspected_associations[association_name].klass.to_s
117
+ else
118
+ nil
119
+ end
120
+ "#{introspected_name || association_name.camelcase}Blueprint"
121
+ end
122
+
123
+ def indent
124
+ user_intended = {two: " ", four: " ", tab:"\t"}[options["indentation"].intern]
125
+ user_intended.nil? ? " " : user_intended
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %>Blueprint < Blueprinter::Base
4
+ <% if identifier_symbol -%>
5
+ <%= indent -%>identifier :<%= identifier_symbol %>
6
+
7
+ <% end -%>
8
+ <% if fields.any? -%>
9
+ <%= indent -%>fields<%= formatted_fields %>
10
+
11
+ <% end -%>
12
+ <% associations.each do |a| -%>
13
+ <%= indent -%>association :<%= a -%><%= association_blueprint(a) %>
14
+
15
+ <% end -%>
16
+ end