mara 0.1.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.
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