activeitem 0.0.1
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 +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +112 -0
- data/lib/active_item/associations.rb +176 -0
- data/lib/active_item/base.rb +591 -0
- data/lib/active_item/composed_of.rb +195 -0
- data/lib/active_item/configuration.rb +24 -0
- data/lib/active_item/database_helpers.rb +84 -0
- data/lib/active_item/errors.rb +28 -0
- data/lib/active_item/logging.rb +19 -0
- data/lib/active_item/model_loader.rb +23 -0
- data/lib/active_item/pagination.rb +51 -0
- data/lib/active_item/query_helpers.rb +637 -0
- data/lib/active_item/relation.rb +1509 -0
- data/lib/active_item/transaction.rb +97 -0
- data/lib/active_item/validations.rb +95 -0
- data/lib/active_item/version.rb +5 -0
- data/lib/activeitem.rb +31 -0
- metadata +134 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module ActiveItem
|
|
6
|
+
module ComposedOf
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
def populate_composed_attributes_from_item(item)
|
|
10
|
+
self.class.compositions.each do |part_id, config|
|
|
11
|
+
dynamo_key = self.class.to_dynamo_key(part_id.to_s)
|
|
12
|
+
vo_class = Object.const_get(config[:class_name])
|
|
13
|
+
|
|
14
|
+
if item[dynamo_key].is_a?(Hash)
|
|
15
|
+
vo = if vo_class.respond_to?(:from_dynamo_map)
|
|
16
|
+
vo_class.from_dynamo_map(item[dynamo_key])
|
|
17
|
+
else
|
|
18
|
+
kwargs = {}
|
|
19
|
+
config[:mapping].each do |_model_attr, vo_attr|
|
|
20
|
+
key = self.class.to_dynamo_key(vo_attr.to_s)
|
|
21
|
+
kwargs[vo_attr] = item[dynamo_key][key] || item[dynamo_key][vo_attr.to_s]
|
|
22
|
+
end
|
|
23
|
+
vo_class.new(**kwargs)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
instance_variable_set("@_composed_#{part_id}", vo)
|
|
27
|
+
config[:mapping].each do |model_attr, vo_attr|
|
|
28
|
+
instance_variable_set("@#{model_attr}", vo.send(vo_attr))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def build_dynamodb_item
|
|
37
|
+
item = super
|
|
38
|
+
|
|
39
|
+
self.class.compositions.each do |part_id, config|
|
|
40
|
+
vo = send(part_id)
|
|
41
|
+
dynamo_key = self.class.to_dynamo_key(part_id.to_s)
|
|
42
|
+
|
|
43
|
+
if vo.nil?
|
|
44
|
+
item.delete(dynamo_key)
|
|
45
|
+
elsif vo.respond_to?(:to_dynamo_map)
|
|
46
|
+
item[dynamo_key] = vo.to_dynamo_map
|
|
47
|
+
else
|
|
48
|
+
map = {}
|
|
49
|
+
config[:mapping].each do |_model_attr, vo_attr|
|
|
50
|
+
key = self.class.to_dynamo_key(vo_attr.to_s)
|
|
51
|
+
map[key] = vo.send(vo_attr)
|
|
52
|
+
end
|
|
53
|
+
item[dynamo_key] = map.compact
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
config[:mapping].each_key do |model_attr|
|
|
57
|
+
flat_key = self.class.to_dynamo_key(model_attr.to_s)
|
|
58
|
+
item.delete(flat_key)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
item
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def perform_update
|
|
66
|
+
return if changes.empty?
|
|
67
|
+
|
|
68
|
+
compositions = self.class.compositions
|
|
69
|
+
return super if compositions.empty?
|
|
70
|
+
|
|
71
|
+
changed_compositions = {}
|
|
72
|
+
compositions.each do |part_id, config|
|
|
73
|
+
changed_attrs = config[:mapping].keys.map(&:to_s) & changes.keys
|
|
74
|
+
changed_compositions[part_id] = config if changed_attrs.any?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
return super if changed_compositions.empty?
|
|
78
|
+
|
|
79
|
+
update_parts = []
|
|
80
|
+
remove_parts = []
|
|
81
|
+
attr_values = {}
|
|
82
|
+
attr_names = {}
|
|
83
|
+
idx = 0
|
|
84
|
+
|
|
85
|
+
composed_flat_keys = compositions.values.flat_map { |c| c[:mapping].keys.map(&:to_s) }.to_set
|
|
86
|
+
|
|
87
|
+
changes.each do |field, (_old_val, new_val)|
|
|
88
|
+
next if composed_flat_keys.include?(field)
|
|
89
|
+
dynamo_key = self.class.to_dynamo_key(field)
|
|
90
|
+
if new_val.nil?
|
|
91
|
+
remove_parts << "#field#{idx}"
|
|
92
|
+
attr_names["#field#{idx}"] = dynamo_key
|
|
93
|
+
else
|
|
94
|
+
update_parts << "#field#{idx} = :val#{idx}"
|
|
95
|
+
attr_names["#field#{idx}"] = dynamo_key
|
|
96
|
+
attr_values[":val#{idx}"] = new_val
|
|
97
|
+
end
|
|
98
|
+
idx += 1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
changed_compositions.each do |part_id, _config|
|
|
102
|
+
remove_instance_variable("@_composed_#{part_id}") if instance_variable_defined?("@_composed_#{part_id}")
|
|
103
|
+
vo = send(part_id)
|
|
104
|
+
dynamo_key = self.class.to_dynamo_key(part_id.to_s)
|
|
105
|
+
|
|
106
|
+
if vo.nil?
|
|
107
|
+
remove_parts << "#field#{idx}"
|
|
108
|
+
attr_names["#field#{idx}"] = dynamo_key
|
|
109
|
+
else
|
|
110
|
+
map_value = vo.respond_to?(:to_dynamo_map) ? vo.to_dynamo_map : vo.to_h
|
|
111
|
+
update_parts << "#field#{idx} = :val#{idx}"
|
|
112
|
+
attr_names["#field#{idx}"] = dynamo_key
|
|
113
|
+
attr_values[":val#{idx}"] = map_value
|
|
114
|
+
end
|
|
115
|
+
idx += 1
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
update_parts << 'updatedAt = :updatedAt'
|
|
119
|
+
attr_values[':updatedAt'] = Time.now.utc.iso8601
|
|
120
|
+
|
|
121
|
+
update_expression = "SET #{update_parts.join(', ')}"
|
|
122
|
+
update_expression += " REMOVE #{remove_parts.join(', ')}" if remove_parts.any?
|
|
123
|
+
|
|
124
|
+
params = {
|
|
125
|
+
table_name: table_name,
|
|
126
|
+
key: { self.class.primary_key.to_s => id },
|
|
127
|
+
update_expression: update_expression,
|
|
128
|
+
expression_attribute_values: attr_values
|
|
129
|
+
}
|
|
130
|
+
params[:expression_attribute_names] = attr_names if attr_names.any?
|
|
131
|
+
|
|
132
|
+
dynamodb.update_item(params)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
module ClassMethods
|
|
136
|
+
def compositions
|
|
137
|
+
@_compositions ||= {}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def composed_of(part_id, options = {})
|
|
141
|
+
class_name = options[:class_name] || part_id.to_s.camelize
|
|
142
|
+
mapping = options[:mapping] || {}
|
|
143
|
+
allow_nil = options.fetch(:allow_nil, true)
|
|
144
|
+
constructor = options.fetch(:constructor, :new)
|
|
145
|
+
converter = options[:converter]
|
|
146
|
+
|
|
147
|
+
compositions[part_id] = { class_name: class_name, mapping: mapping, allow_nil: allow_nil,
|
|
148
|
+
constructor: constructor, converter: converter }
|
|
149
|
+
|
|
150
|
+
define_method(part_id) do
|
|
151
|
+
ivar = "@_composed_#{part_id}"
|
|
152
|
+
return instance_variable_get(ivar) if instance_variable_defined?(ivar)
|
|
153
|
+
|
|
154
|
+
vo_class = Object.const_get(class_name)
|
|
155
|
+
values = mapping.map { |model_attr, _vo_attr| send(model_attr) }
|
|
156
|
+
|
|
157
|
+
if allow_nil && values.all? { |v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
|
|
158
|
+
instance_variable_set(ivar, nil)
|
|
159
|
+
return nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
vo = if constructor.is_a?(Proc)
|
|
163
|
+
constructor.call(*values)
|
|
164
|
+
else
|
|
165
|
+
kwargs = {}
|
|
166
|
+
mapping.each { |model_attr, vo_attr| kwargs[vo_attr] = send(model_attr) }
|
|
167
|
+
vo_class.send(constructor, **kwargs)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
instance_variable_set(ivar, vo)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
define_method("#{part_id}=") do |value|
|
|
174
|
+
ivar = "@_composed_#{part_id}"
|
|
175
|
+
|
|
176
|
+
if value.nil?
|
|
177
|
+
mapping.each_key { |model_attr| send("#{model_attr}=", nil) }
|
|
178
|
+
instance_variable_set(ivar, nil)
|
|
179
|
+
elsif value.is_a?(Object.const_get(class_name))
|
|
180
|
+
mapping.each { |model_attr, vo_attr| send("#{model_attr}=", value.send(vo_attr)) }
|
|
181
|
+
instance_variable_set(ivar, value)
|
|
182
|
+
elsif converter
|
|
183
|
+
converted = converter.is_a?(Proc) ? converter.call(value) : Object.const_get(class_name).send(converter, value)
|
|
184
|
+
send("#{part_id}=", converted)
|
|
185
|
+
elsif value.is_a?(Hash)
|
|
186
|
+
vo = Object.const_get(class_name).new(**value.transform_keys(&:to_sym))
|
|
187
|
+
send("#{part_id}=", vo)
|
|
188
|
+
else
|
|
189
|
+
raise ArgumentError, "Cannot assign #{value.class} to #{part_id}. Expected #{class_name}, Hash, or nil."
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module ActiveItem
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_accessor :logger, :table_prefix, :environment
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@logger = NullLogger.new
|
|
11
|
+
@table_prefix = nil
|
|
12
|
+
@environment = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Generates table name from model class name using configured prefix/env
|
|
16
|
+
# Pattern: {prefix}-{env}-{model-name-pluralized}
|
|
17
|
+
# If no prefix configured, just uses the model name pluralized+dasherized
|
|
18
|
+
def table_name_for(class_name)
|
|
19
|
+
base = class_name.underscore.dasherize.pluralize
|
|
20
|
+
parts = [table_prefix, environment, base].compact
|
|
21
|
+
parts.join('-')
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module ActiveItem
|
|
6
|
+
module DatabaseHelpers
|
|
7
|
+
def get(key)
|
|
8
|
+
response = dynamodb.get_item(table_name: table_name, key: key)
|
|
9
|
+
response.item
|
|
10
|
+
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
|
|
11
|
+
raise_access_denied('GetItem', e)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def put(item)
|
|
15
|
+
dynamodb.put_item(table_name: table_name, item: item)
|
|
16
|
+
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
|
|
17
|
+
raise_access_denied('PutItem', e)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def delete(key)
|
|
21
|
+
dynamodb.delete_item(table_name: table_name, key: key)
|
|
22
|
+
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
|
|
23
|
+
raise_access_denied('DeleteItem', e)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def exists?(key)
|
|
27
|
+
!get(key).nil?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def query(key_condition_expression:, expression_attribute_values:, **options)
|
|
31
|
+
query_params = {
|
|
32
|
+
table_name: table_name,
|
|
33
|
+
key_condition_expression: key_condition_expression,
|
|
34
|
+
expression_attribute_values: expression_attribute_values
|
|
35
|
+
}.merge(options)
|
|
36
|
+
|
|
37
|
+
response = dynamodb.query(query_params)
|
|
38
|
+
response.items
|
|
39
|
+
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
|
|
40
|
+
raise_access_denied('Query', e)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def scan(limit: nil, filter_expression: nil, expression_attribute_names: nil, expression_attribute_values: nil, projection_expression: nil)
|
|
44
|
+
params = { table_name: table_name }
|
|
45
|
+
params[:limit] = limit if limit
|
|
46
|
+
params[:filter_expression] = filter_expression if filter_expression
|
|
47
|
+
params[:expression_attribute_names] = expression_attribute_names if expression_attribute_names
|
|
48
|
+
params[:expression_attribute_values] = expression_attribute_values if expression_attribute_values
|
|
49
|
+
params[:projection_expression] = projection_expression if projection_expression
|
|
50
|
+
|
|
51
|
+
items = []
|
|
52
|
+
seen_ids = Set.new
|
|
53
|
+
last_evaluated_key = nil
|
|
54
|
+
|
|
55
|
+
loop do
|
|
56
|
+
params[:exclusive_start_key] = last_evaluated_key if last_evaluated_key
|
|
57
|
+
response = dynamodb.scan(params)
|
|
58
|
+
|
|
59
|
+
response.items.each do |item|
|
|
60
|
+
pk_value = item[primary_key.to_s]
|
|
61
|
+
unless seen_ids.include?(pk_value)
|
|
62
|
+
seen_ids.add(pk_value)
|
|
63
|
+
items << item
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
last_evaluated_key = response.last_evaluated_key
|
|
68
|
+
break unless last_evaluated_key
|
|
69
|
+
break if limit && items.length >= limit
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
items
|
|
73
|
+
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
|
|
74
|
+
raise_access_denied('Scan', e)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def raise_access_denied(operation, original_error)
|
|
80
|
+
raise ActiveItem::AccessDeniedError.new(model_name: name, table: table_name,
|
|
81
|
+
operation: operation, original_error: original_error)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveItem
|
|
4
|
+
class RecordNotFound < StandardError; end
|
|
5
|
+
class TransactionError < StandardError; end
|
|
6
|
+
|
|
7
|
+
class AccessDeniedError < StandardError
|
|
8
|
+
attr_reader :model_name, :table, :operation, :original_error
|
|
9
|
+
|
|
10
|
+
def initialize(model_name:, table:, operation:, original_error:)
|
|
11
|
+
@model_name = model_name
|
|
12
|
+
@table = table
|
|
13
|
+
@operation = operation
|
|
14
|
+
@original_error = original_error
|
|
15
|
+
super("#{model_name} is not allowed to #{operation} on #{table}. " \
|
|
16
|
+
"Ensure the IAM role has access to this table.")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class DeleteRestrictionError < StandardError
|
|
21
|
+
attr_reader :association_name
|
|
22
|
+
|
|
23
|
+
def initialize(association_name)
|
|
24
|
+
@association_name = association_name
|
|
25
|
+
super("Cannot delete record because dependent #{association_name} exist")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveItem
|
|
4
|
+
# Minimal logger that discards all output. Used as default when no logger configured.
|
|
5
|
+
class NullLogger
|
|
6
|
+
def info(*); end
|
|
7
|
+
def warn(*); end
|
|
8
|
+
def error(*); end
|
|
9
|
+
def debug(*); end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module Logging
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def dynamo_logger
|
|
16
|
+
ActiveItem.logger
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/inflector'
|
|
4
|
+
|
|
5
|
+
module ActiveItem
|
|
6
|
+
module ModelLoader
|
|
7
|
+
def safe_constantize_model(class_name)
|
|
8
|
+
return class_name.constantize if Object.const_defined?(class_name)
|
|
9
|
+
|
|
10
|
+
file_name = class_name.underscore
|
|
11
|
+
# Try common model paths
|
|
12
|
+
['.', 'models', 'app/models'].each do |dir|
|
|
13
|
+
path = File.join(dir, "#{file_name}.rb")
|
|
14
|
+
if File.exist?(path)
|
|
15
|
+
require path
|
|
16
|
+
break
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class_name.constantize
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveItem
|
|
4
|
+
module Pagination
|
|
5
|
+
DEFAULT_PER_PAGE = 25
|
|
6
|
+
MAX_PER_PAGE = 100
|
|
7
|
+
|
|
8
|
+
def self.paginate_array(items, cursor = nil, per_page: DEFAULT_PER_PAGE)
|
|
9
|
+
per_page = [[per_page.to_i, 1].max, MAX_PER_PAGE].min
|
|
10
|
+
|
|
11
|
+
if cursor && !cursor.empty?
|
|
12
|
+
cursor_time, cursor_id = cursor.split('|', 2)
|
|
13
|
+
items = items.drop_while { |i| ([(i.created_at || ''), i.id] <=> [cursor_time, cursor_id]) >= 0 }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
page_items = items.first(per_page)
|
|
17
|
+
has_more = items.length > per_page
|
|
18
|
+
last = page_items.last
|
|
19
|
+
next_cursor = has_more && last ? "#{last.created_at}|#{last.id}" : nil
|
|
20
|
+
|
|
21
|
+
PaginatedResult.new(items: page_items, next_cursor: next_cursor, per_page: per_page)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class PaginatedResult
|
|
25
|
+
include Enumerable
|
|
26
|
+
|
|
27
|
+
attr_reader :items, :next_cursor, :per_page
|
|
28
|
+
|
|
29
|
+
def initialize(items:, next_cursor:, per_page:)
|
|
30
|
+
@items = items
|
|
31
|
+
@next_cursor = next_cursor
|
|
32
|
+
@per_page = per_page
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def has_more?
|
|
36
|
+
!next_cursor.nil?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def pagination_metadata
|
|
40
|
+
{ next_cursor: next_cursor, has_more: has_more?, per_page: per_page }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def each(&block) = items.each(&block)
|
|
44
|
+
def length = items.length
|
|
45
|
+
alias_method :size, :length
|
|
46
|
+
alias_method :count, :length
|
|
47
|
+
def empty? = items.empty?
|
|
48
|
+
def to_a = items
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|