mara 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c8a17c4c4d301602b87a18e8c682297d1cd24c9c5085d2b0aa2115d70b819d40
4
+ data.tar.gz: e31b906856bc625c4630661a6867cdbdd65c0cb1c22627859f217173eab83cb1
5
+ SHA512:
6
+ metadata.gz: 5fb35cfd11fec4f646e2b2ea6cc632526dee9eb550ac5e79f4655cb0bdf7d81580f8e48068bc8cf17551ff388c90de6f12fe4d0a00dea45fed2188af5de5b7d9
7
+ data.tar.gz: ef06b2d073323d716031c97a20e5c71d53b20099916154b48e2f507b0ba1bb2277a7eea3684a94a186e809175cb323371efddf62c54b44ff34229f7d4df42b57
data/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # Mara
2
+
3
+ DynamoDB client.
data/Rakefile ADDED
@@ -0,0 +1,26 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+ require 'yard'
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task default: :spec
8
+
9
+ namespace :docs do
10
+ desc 'Generate docs'
11
+ task :generate do
12
+ YARD::CLI::Yardoc.run
13
+ end
14
+
15
+ desc 'Get docs stats'
16
+ task :stats do
17
+ YARD::CLI::Stats.run('--list-undoc')
18
+ end
19
+
20
+ desc 'View docs'
21
+ task :view do
22
+ # Viewing should always generate the freshest
23
+ Rake::Task['docs:generate'].invoke
24
+ exec("open #{File.expand_path('./doc/index.html', __dir__)}")
25
+ end
26
+ end
data/lib/mara.rb ADDED
@@ -0,0 +1,13 @@
1
+ require 'mara/batch'
2
+ require 'mara/client'
3
+ require 'mara/configure'
4
+ require 'mara/error'
5
+ require 'mara/model'
6
+ require 'mara/table'
7
+ require 'mara/version'
8
+
9
+ ##
10
+ # Mara is a wrapper around DynamoDB that adds a model layer and a bunch of
11
+ # convenience functions.
12
+ module Mara
13
+ end
@@ -0,0 +1,161 @@
1
+ require 'aws-sdk-dynamodb'
2
+
3
+ require_relative 'error'
4
+ require_relative 'null_value'
5
+
6
+ module Mara
7
+ ##
8
+ # Helper class that provides Attribute Formatting for DynamoDB values.
9
+ #
10
+ # @author Maddie Schipper
11
+ # @since 1.0.0
12
+ class AttributeFormatter
13
+ ##
14
+ # The error that is raised if an attribute value fails to be converted into
15
+ # a valid DynamoDB value.
16
+ #
17
+ # @author Maddie Schipper
18
+ # @since 1.0.0
19
+ class Error < Mara::Error; end
20
+
21
+ class << self
22
+ ##
23
+ # @private
24
+ #
25
+ # Format a value into a DynamoDB valid format.
26
+ #
27
+ # @param value [Object] The value to be formatted.
28
+ #
29
+ # @return [Hash]
30
+ def format(value)
31
+ case value
32
+ when true, false
33
+ { bool: value }
34
+ when nil, Mara::NullValue
35
+ { null: true }
36
+ when String
37
+ { s: value }
38
+ when Symbol
39
+ { s: value.to_s }
40
+ when Numeric
41
+ { n: value.to_s }
42
+ when Time
43
+ { n: value.utc.to_i.to_s }
44
+ when DateTime, Date
45
+ format(value.to_time)
46
+ when Hash
47
+ format_hash(value)
48
+ when Array
49
+ format_array(value)
50
+ when Set
51
+ format_set(value)
52
+ else
53
+ raise Error, "Unexpected value type #{value.class.name} <#{value}>"
54
+ end
55
+ end
56
+
57
+ ##
58
+ # @private
59
+ #
60
+ # Convert a Aws::DynamoDB::Types::AttributeValue to a raw value
61
+ #
62
+ # @param value [Aws::DynamoDB::Types::AttributeValue] The value to convert
63
+ #
64
+ # @return [Object]
65
+ def flatten(value)
66
+ unless value.is_a?(Aws::DynamoDB::Types::AttributeValue)
67
+ raise ArgumentError, 'Not an attribute type'
68
+ end
69
+
70
+ if value.s.present?
71
+ value.s
72
+ elsif value.n.present?
73
+ format_number(value.n)
74
+ elsif value.ss.present?
75
+ Set.new(value.ss)
76
+ elsif value.ns.present?
77
+ Set.new(value.ns.map { |v| format_number(v) })
78
+ elsif value.m.present?
79
+ flatten_hash(value.m)
80
+ elsif value.l.present?
81
+ flatten_array(value.l)
82
+ elsif value.null
83
+ Mara::NULL
84
+ elsif !value.bool.nil?
85
+ value.bool
86
+ else
87
+ raise Error, 'Unexpected value type from DynamoDB'
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def format_number(number)
94
+ if number.include?('.')
95
+ number.to_f
96
+ else
97
+ number.to_i
98
+ end
99
+ end
100
+
101
+ def flatten_hash(hash)
102
+ hash.each_with_object({}) do |(key, value), object|
103
+ object[key] = flatten(value)
104
+ end
105
+ end
106
+
107
+ def flatten_array(array)
108
+ array.map { |value| flatten(value) }
109
+ end
110
+
111
+ def format_array(array)
112
+ value = Array(array)
113
+ if value.empty?
114
+ return format(nil)
115
+ end
116
+
117
+ values = value.map do |val|
118
+ format(val)
119
+ end
120
+
121
+ { l: values }
122
+ end
123
+
124
+ def format_hash(hash)
125
+ value = Hash(hash)
126
+ if value.empty?
127
+ return format(nil)
128
+ end
129
+
130
+ formatted = value.each_with_object({}) do |(key, sub_value), object|
131
+ next unless value.present?
132
+
133
+ object[key.to_s] = format(sub_value)
134
+ end
135
+
136
+ { m: formatted }
137
+ end
138
+
139
+ def format_set(set)
140
+ value = Set.new(set.to_a)
141
+ if value.empty?
142
+ return format(nil)
143
+ end
144
+
145
+ kind = value.map(&:class).uniq
146
+
147
+ if kind.count != 1 && kind.to_a.reject { |v| v.ancestors.include?(Numeric) }.any?
148
+ raise Error, "Set type must only contain 1 type #{value.class.name}"
149
+ end
150
+
151
+ if kind.first == String || kind.first == Symbol
152
+ { ss: value.to_a.map(&:to_s) }
153
+ elsif kind.first.ancestors.include?(Numeric)
154
+ { ns: value.to_a.map(&:to_s) }
155
+ else
156
+ raise Error, "Unexpected Set type #{kind.first.class.name}"
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
data/lib/mara/batch.rb ADDED
@@ -0,0 +1,223 @@
1
+ require_relative 'persistence'
2
+ require_relative 'instrument'
3
+
4
+ module Mara
5
+ ##
6
+ # Raise this within a { Mara::Batch#in_batch} to quietly rollback the batch.
7
+ #
8
+ # @note All current operations will be dropped.
9
+ #
10
+ # @author Maddie Schipper
11
+ # @since 1.0.0
12
+ class Rollback < StandardError; end
13
+
14
+ ##
15
+ # Perform operations in batches.
16
+ #
17
+ # @note This is not the same as a transaction. It only saves on the number
18
+ # of API calls to DynamoDB.
19
+ #
20
+ # @example Saving Multiple Records
21
+ # Mara::Batch.in_batch do
22
+ # person1.save
23
+ # person2.save
24
+ # end
25
+ #
26
+ # @author Maddie Schipper
27
+ # @since 1.0.0
28
+ class Batch
29
+ ##
30
+ # @private
31
+ #
32
+ # The name of the thread variable that holds the current batch spec.
33
+ BATCH_STACK_VAR_NAME = 'mara_batch'.freeze
34
+
35
+ class << self
36
+ ##
37
+ # Perform in a batch.
38
+ #
39
+ # All save/destroy calls on a model will be routed into the current batch.
40
+ #
41
+ # If there is a error raised all operations will be dropped.
42
+ #
43
+ # If the error is a { Mara::Rollback} the batch will silently rollback.
44
+ # If not, it will be re-thrown after the rollback.
45
+ #
46
+ # @yield The batch operation.
47
+ def in_batch
48
+ begin_new_batch
49
+ begin
50
+ yield
51
+ rescue Mara::Rollback
52
+ abort_current_batch
53
+ # rubocop:disable Lint/RescueException
54
+ rescue Exception => exception
55
+ # rubocop:enable Lint/RescueException
56
+ abort_current_batch
57
+ raise exception
58
+ else
59
+ commit_current_batch
60
+ end
61
+ end
62
+
63
+ ##
64
+ # @private
65
+ #
66
+ # Perform a save model. If there is a current batch it is added to the
67
+ # operation queue. If there is no current batch, this will be forwarded
68
+ # directly to the { Mara::Persistence}.
69
+ #
70
+ # @param item [Hash] The model to perform the action with.
71
+ def save_model(item)
72
+ perform_for_model(:save_model, item)
73
+ end
74
+
75
+ ##
76
+ # @private
77
+ #
78
+ # Perform a save model. If there is a current batch it is added to the
79
+ # operation queue. If there is no current batch, this will be forwarded
80
+ # directly to the { Mara::Persistence}.
81
+ #
82
+ # @param item [Hash] The model to perform the action with.
83
+ def save_model!(item)
84
+ perform_for_model(:save_model!, item)
85
+ end
86
+
87
+ ##
88
+ # @private
89
+ #
90
+ # Perform a delete model. If there is a current batch it is added to the
91
+ # operation queue. If there is no current batch, this will be forwarded
92
+ # directly to the { Mara::Persistence}.
93
+ #
94
+ # @param item [Hash] The model to perform the action with.
95
+ def delete_model(item)
96
+ perform_for_model(:delete_model, item)
97
+ end
98
+
99
+ ##
100
+ # @private
101
+ #
102
+ # Perform a delete model. If there is a current batch it is added to the
103
+ # operation queue. If there is no current batch, this will be forwarded
104
+ # directly to the { Mara::Persistence}.
105
+ #
106
+ # @param item [Hash] The model to perform the action with.
107
+ def delete_model!(item)
108
+ perform_for_model(:delete_model!, item)
109
+ end
110
+
111
+ private
112
+
113
+ def perform_for_model(action_name, item)
114
+ if (batch = current_batch)
115
+ batch.add(action_name, item)
116
+ else
117
+ Mara::Persistence.send(action_name, item)
118
+ end
119
+ end
120
+
121
+ def current_batch
122
+ batch_stack.first
123
+ end
124
+
125
+ def batch_stack
126
+ Thread.current[BATCH_STACK_VAR_NAME] ||= []
127
+ end
128
+
129
+ def begin_new_batch
130
+ batch_stack << Mara::Batch.new
131
+ end
132
+
133
+ def commit_current_batch
134
+ batch_stack.pop.commit_batch
135
+ end
136
+
137
+ def abort_current_batch
138
+ batch_stack.pop.abort_batch
139
+ end
140
+ end
141
+
142
+ ##
143
+ # @private
144
+ #
145
+ # The queue of operations to perform on commit.
146
+ #
147
+ # @return [Array<Array<Symbol, Hash>>]
148
+ attr_reader :operations
149
+
150
+ ##
151
+ # The current batch id.
152
+ #
153
+ # @return [String]
154
+ attr_reader :batch_id
155
+
156
+ ##
157
+ # @private
158
+ #
159
+ # Create a new batch.
160
+ def initialize
161
+ @batch_id = SecureRandom.uuid
162
+ @operations = []
163
+ end
164
+
165
+ ##
166
+ # @private
167
+ #
168
+ # Add an item to the operation queue.
169
+ #
170
+ # @param action_name [Symbol] The action to perform for the item.
171
+ # @param item [Hash] The hash of data for the action.
172
+ #
173
+ # @return [void]
174
+ def add(action_name, item)
175
+ Mara.instrument('batch.add_item', batch_id: batch_id, action: action_name, item: item) do
176
+ operations << [action_name, item]
177
+ end
178
+ end
179
+
180
+ ##
181
+ # @private
182
+ #
183
+ # Perform all the operations in the queue.
184
+ #
185
+ # @return [void]
186
+ def commit_batch
187
+ Mara.instrument('batch.commit', batch_id: batch_id) do
188
+ execute_commit
189
+ end
190
+ end
191
+
192
+ ##
193
+ # @private
194
+ #
195
+ # Abort the batch and clear the current batch operations.
196
+ #
197
+ # @return [void]
198
+ def abort_batch
199
+ @operations = []
200
+ Mara.instrument('batch.abort', batch_id: batch_id)
201
+ end
202
+
203
+ private
204
+
205
+ def execute_commit
206
+ ops = operations.map do |action_name, item|
207
+ case action_name
208
+ when :save_model, :save_model!
209
+ Mara::Persistence::CreateRequest.new(item)
210
+ when :delete_model, :delete_model!
211
+ Mara::Persistence::DestroyRequest.new(item)
212
+ else
213
+ raise "Unexpected operation action name #{action_name}"
214
+ end
215
+ end
216
+ Mara::Persistence.perform_requests(
217
+ Mara::Client.shared,
218
+ Mara.config.dynamodb.table_name,
219
+ ops
220
+ )
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,43 @@
1
+ require 'aws-sdk-dynamodb'
2
+
3
+ require_relative 'configure'
4
+
5
+ module Mara
6
+ ##
7
+ # @private
8
+ #
9
+ # Internal DynamoDB client.
10
+ #
11
+ # @author Maddie Schipper
12
+ # @since 1.0.0
13
+ class Client
14
+ class << self
15
+ ##
16
+ # @private
17
+ #
18
+ # Create a new DynamoDB client.
19
+ #
20
+ # @return [Aws::DynamoDB::Client]
21
+ def create_client
22
+ params = {
23
+ region: Mara.config.aws.region,
24
+ simple_attributes: false
25
+ }
26
+ if (endpoint = Mara.config.dynamodb.endpoint)
27
+ params[:endpoint] = endpoint
28
+ end
29
+ Aws::DynamoDB::Client.new(params)
30
+ end
31
+
32
+ ##
33
+ # @private
34
+ #
35
+ # The shared client.
36
+ #
37
+ # @return [Aws::DynamoDB::Client]
38
+ def shared
39
+ @shared ||= create_client
40
+ end
41
+ end
42
+ end
43
+ end