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.
@@ -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