fmrest-spyke 0.13.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.
@@ -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