fmrest-spyke 0.13.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fmrest/spyke"
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "spyke"
5
+ rescue LoadError => e
6
+ e.message << " (Did you include Spyke in your Gemfile?)" unless e.message.frozen?
7
+ raise e
8
+ end
9
+
10
+ require "fmrest"
11
+ require "fmrest/spyke/spyke_formatter"
12
+ require "fmrest/spyke/model"
13
+ require "fmrest/spyke/base"
14
+
15
+ module FmRest
16
+ module Spyke
17
+ def self.included(base)
18
+ base.include Model
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module Spyke
5
+ class Base < ::Spyke::Base
6
+ include FmRest::Spyke::Model
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module Spyke
5
+ class ContainerField
6
+
7
+ # @return [String] the name of the container field
8
+ attr_reader :name
9
+
10
+ # @param base [FmRest::Spyke::Base] the record this container belongs to
11
+ # @param name [Symbol] the name of the container field
12
+ def initialize(base, name)
13
+ @base = base
14
+ @name = name
15
+ end
16
+
17
+ # @return [String] the URL for the container
18
+ def url
19
+ @base.attributes[name]
20
+ end
21
+
22
+ # @return (see FmRest::V1::ContainerFields#fetch_container_data)
23
+ def download
24
+ FmRest::V1.fetch_container_data(url, @base.class.connection)
25
+ end
26
+
27
+ # @param filename_or_io [String, IO] a path to the file to upload or an
28
+ # IO object
29
+ # @param options [Hash]
30
+ # @option options [Integer] :repetition (1) The repetition to pass to the
31
+ # upload URL
32
+ # @option (see FmRest::V1::ContainerFields#upload_container_data)
33
+ def upload(filename_or_io, options = {})
34
+ raise ArgumentError, "Record needs to be saved before uploading to a container field" unless @base.persisted?
35
+
36
+ response =
37
+ FmRest::V1.upload_container_data(
38
+ @base.class.connection,
39
+ upload_path(options[:repetition] || 1),
40
+ filename_or_io,
41
+ options
42
+ )
43
+
44
+ # Update mod id on record
45
+ @base.__mod_id = response.body[:data][:__mod_id]
46
+
47
+ true
48
+ end
49
+
50
+ private
51
+
52
+ # @param repetition [Integer]
53
+ # @return [String] the path for uploading a file to the container
54
+ def upload_path(repetition)
55
+ FmRest::V1.container_field_path(@base.class.layout, @base.__record_id, name, repetition)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fmrest/spyke/model/connection"
4
+ require "fmrest/spyke/model/uri"
5
+ require "fmrest/spyke/model/record_id"
6
+ require "fmrest/spyke/model/attributes"
7
+ require "fmrest/spyke/model/serialization"
8
+ require "fmrest/spyke/model/associations"
9
+ require "fmrest/spyke/model/orm"
10
+ require "fmrest/spyke/model/container_fields"
11
+ require "fmrest/spyke/model/global_fields"
12
+ require "fmrest/spyke/model/http"
13
+ require "fmrest/spyke/model/auth"
14
+
15
+ module FmRest
16
+ module Spyke
17
+ module Model
18
+ extend ::ActiveSupport::Concern
19
+
20
+ include Connection
21
+ include URI
22
+ include RecordID
23
+ include Attributes
24
+ include Serialization
25
+ include Associations
26
+ include Orm
27
+ include ContainerFields
28
+ include GlobalFields
29
+ include Http
30
+ include Auth
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fmrest/spyke/portal"
4
+
5
+ module FmRest
6
+ module Spyke
7
+ module Model
8
+ # This module adds portal support to Spyke models.
9
+ #
10
+ module Associations
11
+ extend ::ActiveSupport::Concern
12
+
13
+ included do
14
+ # Keep track of portal options by their FM keys as we could need it
15
+ # to parse the portalData JSON in SpykeFormatter
16
+ class_attribute :portal_options, instance_accessor: false, instance_predicate: false
17
+
18
+ # class_attribute supports a :default option since ActiveSupport 5.2,
19
+ # but we want to support previous versions too so we set the default
20
+ # manually instead
21
+ self.portal_options = {}.freeze
22
+
23
+ class << self; private :portal_options=; end
24
+
25
+ set_callback :save, :after, :remove_marked_for_destruction
26
+ end
27
+
28
+ class_methods do
29
+ # Based on Spyke's `has_many`, but creates a special Portal
30
+ # association instead.
31
+ #
32
+ # @option :portal_key [String] The key used for the portal in the FM
33
+ # Data JSON portalData
34
+ # @option :attribute_prefix [String] The prefix used for portal
35
+ # attributes in the FM Data JSON
36
+ #
37
+ # @example
38
+ # class Person < FmRest::Spyke::Base
39
+ # has_portal :jobs, portal_key: "JobsTable", attribute_prefix: "Job"
40
+ # end
41
+ #
42
+ def has_portal(name, options = {})
43
+ create_association(name, Portal, options)
44
+
45
+ # Store options for SpykeFormatter to use if needed
46
+ portal_key = options[:portal_key] || name
47
+ self.portal_options = portal_options.merge(portal_key.to_s => options.dup.merge(name: name.to_s)).freeze
48
+
49
+ define_method "#{name.to_s.singularize}_ids" do
50
+ association(name).map(&:id)
51
+ end
52
+ end
53
+ end
54
+
55
+ # Spyke override -- Keep a cache of loaded portals. Spyke's default
56
+ # behavior is to reload the association each time.
57
+ #
58
+ def association(name)
59
+ @loaded_portals ||= {}
60
+
61
+ if @loaded_portals.has_key?(name.to_sym)
62
+ return @loaded_portals[name.to_sym]
63
+ end
64
+
65
+ super.tap do |assoc|
66
+ next unless assoc.kind_of?(FmRest::Spyke::Portal)
67
+ @loaded_portals[name.to_sym] = assoc
68
+ end
69
+ end
70
+
71
+ # Spyke override -- Add portals awareness
72
+ #
73
+ def reload(*_)
74
+ super.tap { @loaded_portals = nil }
75
+ end
76
+
77
+ # @return [Array<FmRest::Spyke::Portal>] A collection of portal
78
+ # relations for the record
79
+ #
80
+ def portals
81
+ self.class.associations.each_with_object([]) do |(key, _), portals|
82
+ candidate = association(key)
83
+ next unless candidate.kind_of?(FmRest::Spyke::Portal)
84
+ portals << candidate
85
+ end
86
+ end
87
+
88
+ # Signals that this record has been marked for being deleted next time
89
+ # its parent record is saved (e.g. in a portal association)
90
+ #
91
+ # This method is named after ActiveRecord's namesake
92
+ #
93
+ def mark_for_destruction
94
+ @marked_for_destruction = true
95
+ end
96
+ alias_method :mark_for_deletion, :mark_for_destruction
97
+
98
+ def marked_for_destruction?
99
+ !!@marked_for_destruction
100
+ end
101
+ alias_method :marked_for_deletion?, :marked_for_destruction?
102
+
103
+ # Signals that this record has been embedded in a portal so we can make
104
+ # sure to include it in the next update request
105
+ #
106
+ def embedded_in_portal
107
+ @embedded_in_portal = true
108
+ end
109
+
110
+ def embedded_in_portal?
111
+ !!@embedded_in_portal
112
+ end
113
+
114
+ # Override ActiveModel::Dirty's method to include clearing
115
+ # of `@embedded_in_portal` and `@marked_for_destruction`
116
+ #
117
+ def changes_applied
118
+ super
119
+ @embedded_in_portal = nil
120
+ @marked_for_destruction = nil
121
+ end
122
+
123
+ # Override ActiveModel::Dirty's method to include awareness
124
+ # of `@embedded_in_portal`
125
+ #
126
+ def changed?
127
+ super || embedded_in_portal?
128
+ end
129
+
130
+ # Takes care of updating the new portal record's recordIds and modIds.
131
+ #
132
+ # Called when saving a record with freshly added portal records, this
133
+ # method is not meant to be called manually.
134
+ #
135
+ # @param [Hash] data The hash containing newPortalData from the DAPI
136
+ # response
137
+ def __new_portal_record_info=(data)
138
+ data.each do |d|
139
+ table_name = d[:tableName]
140
+
141
+ portal_new_records =
142
+ portals.detect { |p| p.portal_key == table_name }.select { |r| !r.persisted? }
143
+
144
+ # The DAPI provides only one recordId for the entire portal in the
145
+ # newPortalRecordInfo object. This appears to be the recordId of
146
+ # the last portal record created, so we assume all portal records
147
+ # coming before it must have sequential recordIds up to the one we
148
+ # do have.
149
+ portal_new_records.reverse_each.with_index do |record, i|
150
+ record.__record_id = d[:recordId].to_i - i
151
+
152
+ # New records get a fresh modId
153
+ record.__mod_id = 0
154
+ end
155
+ end
156
+ end
157
+
158
+ private
159
+
160
+ def remove_marked_for_destruction
161
+ portals.each(&:_remove_marked_for_destruction)
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fmrest/spyke/model/orm"
4
+
5
+ module FmRest
6
+ module Spyke
7
+ module Model
8
+ # Extends Spyke models with support for mapped attributes,
9
+ # `ActiveModel::Dirty` and forbidden attributes (e.g. Rails'
10
+ # `params.permit`).
11
+ #
12
+ module Attributes
13
+ extend ::ActiveSupport::Concern
14
+
15
+ include Orm # Needed to extend custom save and reload
16
+
17
+ include ::ActiveModel::Dirty
18
+ include ::ActiveModel::ForbiddenAttributesProtection
19
+
20
+ included do
21
+ # Prevent the creation of plain (no prefix/suffix) attribute methods
22
+ # when calling ActiveModels' define_attribute_method, otherwise it
23
+ # will define an `attribute` method which overrides the one provided
24
+ # by Spyke
25
+ self.attribute_method_matchers.shift
26
+
27
+ # Keep track of attribute mappings so we can get the FM field names
28
+ # for changed attributes
29
+ class_attribute :mapped_attributes, instance_writer: false, instance_predicate: false
30
+
31
+ # class_attribute supports a :default option since ActiveSupport 5.2,
32
+ # but we want to support previous versions too so we set the default
33
+ # manually instead
34
+ self.mapped_attributes = ::ActiveSupport::HashWithIndifferentAccess.new.freeze
35
+
36
+ class << self; private :mapped_attributes=; end
37
+
38
+ set_callback :save, :after, :changes_applied_after_save
39
+ end
40
+
41
+ class_methods do
42
+ # Spyke override
43
+ #
44
+ # Similar to Spyke::Base.attributes, but allows defining attribute
45
+ # methods that map to FM attributes with different names.
46
+ #
47
+ # @example
48
+ #
49
+ # class Person < Spyke::Base
50
+ # include FmRest::Spyke::Model
51
+ #
52
+ # attributes first_name: "FstName", last_name: "LstName"
53
+ # end
54
+ #
55
+ # p = Person.new
56
+ # p.first_name = "Jojo"
57
+ # p.attributes # => { "FstName" => "Jojo" }
58
+ #
59
+ def attributes(*attrs)
60
+ if attrs.length == 1 && attrs.first.kind_of?(Hash)
61
+ attrs.first.each { |from, to| _fmrest_define_attribute(from, to) }
62
+ else
63
+ attrs.each { |attr| _fmrest_define_attribute(attr, attr) }
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ # Spyke override (private)
70
+ #
71
+ # Called whenever loading records from the HTTP API, so we can reset
72
+ # dirty info on freshly loaded records
73
+ #
74
+ # See: https://github.com/balvig/spyke/blob/master/lib/spyke/http.rb
75
+ #
76
+ def new_or_return(attributes_or_object, *_)
77
+ # In case of an existing Spyke object return it as is so that we
78
+ # don't accidentally remove dirty data from associations
79
+ return super if attributes_or_object.is_a?(::Spyke::Base)
80
+ super.tap do |record|
81
+ # In ActiveModel 4.x #clear_changes_information is a private
82
+ # method, so we need to call it with send() in that case, but
83
+ # keep calling it normally for AM5+
84
+ if record.respond_to?(:clear_changes_information)
85
+ record.clear_changes_information
86
+ else
87
+ record.send(:clear_changes_information)
88
+ end
89
+ end
90
+ end
91
+
92
+ def _fmrest_attribute_methods_container
93
+ @fmrest_attribute_methods_container ||= Module.new.tap { |mod| include mod }
94
+ end
95
+
96
+ def _fmrest_define_attribute(from, to)
97
+ # We use a setter here instead of injecting the hash key/value pair
98
+ # directly with #[]= so that we don't change the mapped_attributes
99
+ # hash on the parent class. The resulting hash is frozen for the
100
+ # same reason.
101
+ self.mapped_attributes = mapped_attributes.merge(from => to).freeze
102
+
103
+ _fmrest_attribute_methods_container.module_eval do
104
+ define_method(from) do
105
+ attribute(to)
106
+ end
107
+
108
+ define_method(:"#{from}=") do |value|
109
+ send("#{from}_will_change!") unless value == send(from)
110
+ set_attribute(to, value)
111
+ end
112
+ end
113
+
114
+ # Define ActiveModel::Dirty's methods
115
+ define_attribute_method(from)
116
+ end
117
+ end
118
+
119
+ # Spyke override -- Adds AM::Dirty support
120
+ #
121
+ def reload(*args)
122
+ super.tap { |r| clear_changes_information }
123
+ end
124
+
125
+ # Spyke override -- Adds support for forbidden attributes (i.e. Rails'
126
+ # `params.permit`, etc.)
127
+ #
128
+ def attributes=(new_attributes)
129
+ @spyke_attributes ||= ::Spyke::Attributes.new(scope.params)
130
+ return unless new_attributes && !new_attributes.empty?
131
+ use_setters(sanitize_for_mass_assignment(new_attributes))
132
+ end
133
+
134
+ private
135
+
136
+ def changed_params
137
+ attributes.to_params.slice(*mapped_changed)
138
+ end
139
+
140
+ def mapped_changed
141
+ mapped_attributes.values_at(*changed)
142
+ end
143
+
144
+ # Spyke override (private) -- Use known mapped_attributes for inspect
145
+ #
146
+ def inspect_attributes
147
+ mapped_attributes.except(primary_key).map do |k, v|
148
+ "#{k}: #{attribute(v).inspect}"
149
+ end.join(', ')
150
+ end
151
+
152
+ def changes_applied_after_save
153
+ changes_applied
154
+ portals.each(&:parent_changes_applied)
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end