mara 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,208 @@
1
+ require_relative '../error'
2
+
3
+ module Mara
4
+ module Model
5
+ ##
6
+ # An error raised if the index requested in
7
+ # {Dsl::ClassMethods.global_secondary_index} or
8
+ # {Dsl::ClassMethods.global_secondary_index} are not found.
9
+ #
10
+ # @author Maddie Schipper
11
+ # @since 1.0.0
12
+ class IndexError < Mara::Error; end
13
+
14
+ ##
15
+ # Represents a DynamoDB Local Secondary Index.
16
+ #
17
+ # @see Mara::Model::Dsl::ClassMethods#add_lsi
18
+ #
19
+ # @!attribute [rw] name
20
+ # The name of the index.
21
+ #
22
+ # @return [String]
23
+ #
24
+ # @!attribute [rw] key_name
25
+ # The name of the LSI sort_key.
26
+ #
27
+ # @return [String]
28
+ LocalSecondaryIndex = Struct.new(:name, :key_name)
29
+
30
+ ##
31
+ # Represents a DynamoDB Global Secondary Index.
32
+ #
33
+ # @see Mara::Model::Dsl::ClassMethods#add_gsi
34
+ #
35
+ # @!attribute [rw] name
36
+ # The name of the index.
37
+ #
38
+ # @return [String]
39
+ #
40
+ # @!attribute [rw] partition_key
41
+ # The name of the GSI partion key.
42
+ #
43
+ # @return [String]
44
+ #
45
+ # @!attribute [rw] sort_key
46
+ # The name of the GSI sort_key.
47
+ #
48
+ # @return [String, nil]
49
+ GlobalSecondaryIndex = Struct.new(:name, :partition_key, :sort_key)
50
+
51
+ ##
52
+ # Helper DSL methods for Base class.
53
+ #
54
+ # @author Maddie Schipper
55
+ # @since 1.0.0
56
+ module Dsl
57
+ ##
58
+ # @private
59
+ def self.included(klass)
60
+ klass.extend(ClassMethods)
61
+ end
62
+
63
+ ##
64
+ # Helper method added at the class level.
65
+ #
66
+ # @author Maddie Schipper
67
+ # @since 1.0.0
68
+ module ClassMethods
69
+ ##
70
+ # Set a partion_key and sort_key for a model.
71
+ #
72
+ # @see #partition_key
73
+ #
74
+ # @see #sort_key
75
+ #
76
+ # @example Setting the partition_key & sort_key
77
+ # class Person < Mara::Model::Base
78
+ # primary_key('PartionKeyName', 'SortKeyName')
79
+ # # ...
80
+ #
81
+ # @param partition_key [#to_s] The name of the DynamoDB table's partion
82
+ # key.
83
+ #
84
+ # @param sort_key [#to_s, nil] The name of the DynamoDB table's sort
85
+ # key.
86
+ #
87
+ # @return [void]
88
+ def primary_key(partition_key, sort_key = nil)
89
+ partition_key(partition_key)
90
+ sort_key(sort_key)
91
+ end
92
+
93
+ ##
94
+ # Set the partion key name for the model. This value is required.
95
+ #
96
+ # @param partition_key [#to_s] The name of the partion key.
97
+ #
98
+ # @return [String]
99
+ def partition_key(partition_key = nil)
100
+ unless partition_key.nil?
101
+ @partition_key = partition_key.to_s
102
+ validates_presence_of :partition_key
103
+ end
104
+ @partition_key
105
+ end
106
+
107
+ ##
108
+ # Set the sort key name for the model.
109
+ #
110
+ # @param sort_key [#to_s] The name of the sort key.
111
+ #
112
+ # @return [String]
113
+ def sort_key(sort_key = nil)
114
+ unless sort_key.nil?
115
+ @sort_key = sort_key.to_s
116
+ validates_presence_of :sort_key
117
+ end
118
+
119
+ @sort_key
120
+ end
121
+
122
+ ##
123
+ # Add a local secondary index definition.
124
+ #
125
+ # @note This is only required for querying.
126
+ #
127
+ # @param name [String] The name of the index.
128
+ #
129
+ # @param key_name [String] The name of the LSI sort key.
130
+ #
131
+ # @return [void]
132
+ def add_lsi(name, key_name)
133
+ local_secondary_indices[name.to_s] = LocalSecondaryIndex.new(name.to_s, key_name.to_s)
134
+ end
135
+
136
+ ##
137
+ # @private
138
+ #
139
+ # All registered local secondary indices
140
+ #
141
+ # @return [Hash<String, LocalSecondaryIndex>]
142
+ def local_secondary_indices
143
+ @local_secondary_indices ||= {}
144
+ end
145
+
146
+ ##
147
+ # Get a defined local secondary index by name.
148
+ #
149
+ # @param name [#to_s] The name of the LSI to get.
150
+ #
151
+ # @raise [IndexError] The index is not registered on the model.
152
+ #
153
+ # @return [LocalSecondaryIndex]
154
+ def local_secondary_index(name)
155
+ index = local_secondary_indices[name.to_s]
156
+ if index.nil?
157
+ raise Mara::Model::IndexError, "Can't find a LSI with the name `#{name}`"
158
+ end
159
+
160
+ index
161
+ end
162
+
163
+ ##
164
+ # Add a global secondary index definition.
165
+ #
166
+ # @note This is only required for querying.
167
+ #
168
+ # @param name [#to_s] The name of the index.
169
+ #
170
+ # @param partition_key [#to_s] The name of the GSI partition key.
171
+ #
172
+ # @param sort_key [String, nil] The name of the GSI sort key.
173
+ #
174
+ # @return [void]
175
+ def add_gsi(name, partition_key, sort_key = nil)
176
+ global_secondary_indices[name.to_s] = GlobalSecondaryIndex.new(name.to_s, partition_key.to_s, sort_key)
177
+ end
178
+
179
+ ##
180
+ # @private
181
+ #
182
+ # All registered global secondary indices
183
+ #
184
+ # @return [Hash<String, GlobalSecondaryIndex>]
185
+ def global_secondary_indices
186
+ @global_secondary_indices ||= {}
187
+ end
188
+
189
+ ##
190
+ # Get a defined global secondary index by name.
191
+ #
192
+ # @param name [#to_s] The name of the GSI to get.
193
+ #
194
+ # @raise [IndexError] The index is not registered on the model.
195
+ #
196
+ # @return [GlobalSecondaryIndex]
197
+ def global_secondary_index(name)
198
+ index = global_secondary_indices[name.to_s]
199
+ if index.nil?
200
+ raise Mara::Model::IndexError, "Can't find a GSI with the name `#{name}`"
201
+ end
202
+
203
+ index
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,120 @@
1
+ require_relative '../attribute_formatter'
2
+ require_relative '../batch'
3
+ require_relative '../instrument'
4
+
5
+ module Mara
6
+ module Model
7
+ ##
8
+ # Methods that save/update/delete a model.
9
+ #
10
+ # @author Maddie Schipper
11
+ # @since 1.0.0
12
+ module Persistence
13
+ ##
14
+ # @private
15
+ def self.included(klass)
16
+ klass.extend(ClassMethods)
17
+ end
18
+
19
+ ##
20
+ # Helper method added at the class level.
21
+ #
22
+ # @author Maddie Schipper
23
+ # @since 1.0.0
24
+ module ClassMethods
25
+ end
26
+
27
+ ##
28
+ # @private
29
+ #
30
+ # Converts the attributes into a DynamoDB compatable hash.
31
+ #
32
+ # @return [Hash]
33
+ def to_dynamo
34
+ {}.tap do |formatted|
35
+ attributes.each do |key, value|
36
+ formatted[key] = Mara::AttributeFormatter.format(value)
37
+ end
38
+ end
39
+ end
40
+
41
+ ##
42
+ # @private
43
+ #
44
+ # Get a primary key attribute for the item.
45
+ #
46
+ # @return [Hash]
47
+ def primary_key
48
+ {}.tap do |base|
49
+ base[self.class.partition_key] = Mara::AttributeFormatter.format(partition_key)
50
+
51
+ unless self.class.sort_key.blank?
52
+ base[self.class.sort_key] = Mara::AttributeFormatter.format(sort_key)
53
+ end
54
+ end
55
+ end
56
+
57
+ ##
58
+ # @private
59
+ #
60
+ # Create a DynamoDB representation of the model.
61
+ #
62
+ # @return [Hash]
63
+ def to_item
64
+ to_dynamo.merge(primary_key)
65
+ end
66
+
67
+ ##
68
+ # Perform validation and save the model.
69
+ #
70
+ # @return [true, false]
71
+ def save
72
+ Mara.instrument('model.save', model: self) do
73
+ next false unless valid?
74
+
75
+ Mara::Batch.save_model(to_item)
76
+ end
77
+ end
78
+
79
+ ##
80
+ # Perform validation and save the model.
81
+ #
82
+ # @see #save
83
+ #
84
+ # @note Same as {#save} but will raise an error on validation faiure and
85
+ # save failure
86
+ #
87
+ # @return [void]
88
+ def save!
89
+ Mara.instrument('model.save', model: self) do
90
+ validate!
91
+ Mara::Batch.save_model!(to_item)
92
+ end
93
+ end
94
+
95
+ ##
96
+ # Perform a destroy on the model.
97
+ #
98
+ # @return [true, false]
99
+ def destroy
100
+ Mara.instrument('model.destroy', model: self) do
101
+ Mara::Batch.delete_model(primary_key)
102
+ end
103
+ end
104
+
105
+ ##
106
+ # Perform a destroy on the model.
107
+ #
108
+ # @see #destroy
109
+ #
110
+ # @note Same as {#destroy} but will raise an error on delete failure.
111
+ #
112
+ # @return [void]
113
+ def destroy!
114
+ Mara.instrument('model.destroy', model: self) do
115
+ Mara::Batch.delete_model!(primary_key)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,97 @@
1
+ require_relative '../error'
2
+ require_relative '../query'
3
+ require_relative '../attribute_formatter'
4
+ require_relative '../instrument'
5
+
6
+ module Mara
7
+ module Model
8
+ ##
9
+ # Error raised when a find method fails to find the requested object.
10
+ #
11
+ # @author Maddie Schipper
12
+ # @since 1.0.0
13
+ class NotFoundError < Error; end
14
+
15
+ ##
16
+ # Methods to query for a model.
17
+ #
18
+ # @author Maddie Schipper
19
+ # @since 1.0.0
20
+ module Query
21
+ ##
22
+ # @private
23
+ def self.included(klass)
24
+ klass.extend(ClassMethods)
25
+ end
26
+
27
+ ##
28
+ # Helper methods defined on the class.
29
+ #
30
+ # @author Maddie Schipper
31
+ # @since 1.0.0
32
+ module ClassMethods
33
+ ##
34
+ # Find a single object with the matching partition_key and sort key.
35
+ #
36
+ # @param partition_key [#to_s] The value for the partition key.
37
+ #
38
+ # @param sort_key [#to_s, nil] The value for the sort key.
39
+ #
40
+ # @raise [NotFoundError] If the object doesn't exist in the table for
41
+ # the requested primary key.
42
+ #
43
+ # @raise [ArgumentError] If the +partition_key+ is blank, or the
44
+ # +sort_key+ is blank and the class defines a sort_key name.
45
+ #
46
+ # @return [ Mara::Model::Base]
47
+ def find(partition_key, sort_key = nil)
48
+ Mara.instrument('model.find', class_name: name, partition_key: partition_key, sort_key: sort_key) do
49
+ _find(partition_key, sort_key)
50
+ end
51
+ end
52
+
53
+ ##
54
+ # @private
55
+ #
56
+ # @see #find
57
+ def _find(partition_key, sort_key = nil)
58
+ if partition_key.blank?
59
+ raise ArgumentError, 'Must specify a valid partition key value'
60
+ end
61
+
62
+ if sort_key.nil? && !self.sort_key.blank?
63
+ raise ArgumentError, "#{self.class.name} specifies a sort key, but no sort key value was given."
64
+ end
65
+
66
+ key_params = {}
67
+ key_params[self.partition_key] = Mara::AttributeFormatter.format(partition_key)
68
+ if self.sort_key.present?
69
+ key_params[self.sort_key] = Mara::AttributeFormatter.format(sort_key)
70
+ end
71
+
72
+ response = Mara::Query.get_item(key: key_params)
73
+
74
+ if response.nil? || response.items.empty?
75
+ raise NotFoundError, "Can't find item with pk=#{partition_key} sk=#{sort_key}"
76
+ end
77
+
78
+ item = response.items[0]
79
+
80
+ construct(item)
81
+ end
82
+ end
83
+
84
+ ##
85
+ # Checks if a the model exists in the table?
86
+ #
87
+ # @return [true, false]
88
+ def exist?
89
+ pk = partition_key
90
+ sk = conditional_sort_key
91
+ self.class.find(pk, sk).present?
92
+ rescue Mara::Model::NotFoundError
93
+ false
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,13 @@
1
+ module Mara
2
+ ##
3
+ # @private
4
+ #
5
+ # The null value placeholder
6
+ class NullValue; end
7
+
8
+ ##
9
+ # @private
10
+ #
11
+ # Default NULL value
12
+ NULL = NullValue.new.freeze
13
+ end
@@ -0,0 +1,204 @@
1
+ require_relative 'error'
2
+ require_relative 'client'
3
+ require_relative 'configure'
4
+ require_relative 'instrument'
5
+ require_relative 'dynamo_helpers'
6
+
7
+ module Mara
8
+ ##
9
+ # @private
10
+ #
11
+ # Perform calls to DynamoDB for saving/updating/deleting
12
+ #
13
+ # @author Maddie Schipper
14
+ # @since 1.0.0
15
+ class Persistence
16
+ include DynamoHelpers
17
+
18
+ ##
19
+ # @private
20
+ #
21
+ # A wrapper for a create request.
22
+ #
23
+ # @!attribute [rw] record
24
+ # The record hash to be created.
25
+ #
26
+ # @return [Hash]
27
+ #
28
+ # @author Maddie Schipper
29
+ # @since 1.0.0
30
+ CreateRequest = Struct.new(:record) do
31
+ ##
32
+ # Converts the CreateRequest to JSON
33
+ #
34
+ # @return [Hash]
35
+ def as_json
36
+ {
37
+ put_request: {
38
+ item: record
39
+ }
40
+ }
41
+ end
42
+ end
43
+
44
+ ##
45
+ # @private
46
+ #
47
+ # A wrapper for a destroy request.
48
+ #
49
+ # @!attribute [rw] record
50
+ # The record hash to be destroyed.
51
+ #
52
+ # @return [Hash]
53
+ #
54
+ # @author Maddie Schipper
55
+ # @since 1.0.0
56
+ DestroyRequest = Struct.new(:record) do
57
+ ##
58
+ # Converts the DestroyRequest to JSON
59
+ #
60
+ # @return [Hash]
61
+ def as_json
62
+ {
63
+ delete_request: {
64
+ key: record
65
+ }
66
+ }
67
+ end
68
+ end
69
+
70
+ ##
71
+ # @private The response for a save operation.
72
+ #
73
+ # @!attribute [r] consumed_capacity
74
+ # The total consumed capacity for the request.
75
+ #
76
+ # @return [Float]
77
+ #
78
+ # @!attribute [r] operation_count
79
+ # The total number of API calls required to perform the operation.
80
+ #
81
+ # @return [Integer]
82
+ #
83
+ # @author Maddie Schipper
84
+ # @since 1.0.0
85
+ Response = Struct.new(:consumed_capacity, :operation_count)
86
+
87
+ ##
88
+ # @private
89
+ #
90
+ # Error thrown by Persistence calls.
91
+ #
92
+ # @author Maddie Schipper
93
+ # @since 1.0.0
94
+ class Error < Mara::Error; end
95
+
96
+ class << self
97
+ ##
98
+ # Perform a save on a item.
99
+ #
100
+ # @param item [Hash] The item to be saved.
101
+ #
102
+ # @return [true, false]
103
+ def save_model(item)
104
+ create = CreateRequest.new(item)
105
+ response = perform_requests(
106
+ Mara::Client.shared,
107
+ Mara.config.dynamodb.table_name,
108
+ [create]
109
+ )
110
+ !response.nil?
111
+ end
112
+
113
+ ##
114
+ # Perform a save on the a item.
115
+ #
116
+ # @see .save_model
117
+ #
118
+ # @param item [Hash] The item to be saved.
119
+ #
120
+ # @raise Error If the save operation fails.
121
+ #
122
+ # @return [void]
123
+ def save_model!(item)
124
+ return if save_model(item)
125
+
126
+ raise Error, 'Failed to save!'
127
+ end
128
+
129
+ ##
130
+ # Delete an item.
131
+ #
132
+ # @param item [Hash] The item to be deleted.
133
+ #
134
+ # @return [true, false]
135
+ def delete_model(item)
136
+ delete = DestroyRequest.new(item)
137
+ response = perform_requests(
138
+ Mara::Client.shared,
139
+ Mara.config.dynamodb.table_name,
140
+ [delete]
141
+ )
142
+ !response.nil?
143
+ end
144
+
145
+ ##
146
+ # Delete an item.
147
+ #
148
+ # @see .delete_model
149
+ #
150
+ # @param item [Hash] The item to be deleted.
151
+ #
152
+ # @raise Error If the delete operation fails.
153
+ #
154
+ # @return [void]
155
+ def delete_model!(item)
156
+ return if delete_model(item)
157
+
158
+ raise Error, 'Failed to delete!'
159
+ end
160
+
161
+ ##
162
+ # Perform a batch of save requests.
163
+ def perform_requests(client, table_name, requests, group_size = 10)
164
+ results = Mara.instrument('save_batch_records', requests: requests, table_name: table_name) do
165
+ requests.each_slice(group_size).map do |sub_requests|
166
+ _perform_requests(client, table_name, sub_requests)
167
+ end
168
+ end
169
+ Response.new(
170
+ results.map(&:consumed_capacity).sum,
171
+ results.map(&:operation_count).sum
172
+ )
173
+ end
174
+
175
+ private
176
+
177
+ def _base_batch_write_item_params(table_name)
178
+ params = {
179
+ return_consumed_capacity: 'TOTAL',
180
+ return_item_collection_metrics: 'SIZE',
181
+ request_items: {}
182
+ }
183
+ if block_given?
184
+ params[:request_items][table_name] = yield
185
+ end
186
+ params
187
+ end
188
+
189
+ def _perform_requests(client, table_name, requests)
190
+ params = _base_batch_write_item_params(table_name) { requests.map(&:as_json) }
191
+ response = Mara.instrument('save_batch_record_operation', requests: requests, table_name: table_name) do
192
+ client.batch_write_item(params)
193
+ end
194
+
195
+ cc = calculate_consumed_capacity(response.consumed_capacity, table_name)
196
+
197
+ Response.new(
198
+ cc,
199
+ 1
200
+ )
201
+ end
202
+ end
203
+ end
204
+ end