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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a08a2b03026d0380bc6232ad035c49ca911bb5ee96c8e7d55de3dd23ebc3dcba
|
|
4
|
+
data.tar.gz: 1778c6afe64631e5f40ef13b69a761109475befffdea301cb4144a5ea0abc7bb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c03b044f31d8a843fd37f066a6f5ae25a406165db25541a717e547bee044f20f09f45399dc4beaa133fb696611d86ee0938d09d9e67d3cdb50616e9493005c8b
|
|
7
|
+
data.tar.gz: 024f823d6e9d76a4e7f9b9a77ac1e9212d213a549e9fc735051e7fdc2c5ac507f28c894aa9d2d209b589a772f5db534063c3e33d7f8db64cc8eefeea02b8955d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.0.1
|
|
4
|
+
|
|
5
|
+
- Initial release
|
|
6
|
+
- Core ORM: find, save, create, update, destroy
|
|
7
|
+
- Chainable query builder (Relation) with where, not, limit, order, select
|
|
8
|
+
- Associations: has_many, belongs_to with dependent options
|
|
9
|
+
- Callbacks: before/after save, create, update, destroy, validation
|
|
10
|
+
- Dirty tracking: attribute_changed?, changes, previous_changes
|
|
11
|
+
- Validations: uniqueness, length, numericality, format (via ActiveModel)
|
|
12
|
+
- Transactions: TransactWriteItems and TransactGetItems
|
|
13
|
+
- Pagination: cursor-based with PaginatedResult
|
|
14
|
+
- Composed of: value object aggregation
|
|
15
|
+
- Batch operations: batch_find, batch_write
|
|
16
|
+
- Configurable table naming, logger, and DynamoDB client
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Andy Davis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# ActiveItem
|
|
2
|
+
|
|
3
|
+
ActiveRecord-like ORM for AWS DynamoDB.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem 'activeitem'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Configuration
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
ActiveItem.configure do |config|
|
|
15
|
+
config.table_prefix = 'myapp'
|
|
16
|
+
config.environment = 'production'
|
|
17
|
+
config.logger = Rails.logger # or any Logger-compatible object
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Table names are generated as `{prefix}-{environment}-{model-name-pluralized}`.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
class User < ActiveItem::Base
|
|
27
|
+
self.primary_key = :user_id
|
|
28
|
+
|
|
29
|
+
attr_accessor :email, :name, :status
|
|
30
|
+
|
|
31
|
+
indexes(
|
|
32
|
+
'EmailIndex' => { partition_key: 'email' },
|
|
33
|
+
'StatusIndex' => { partition_key: 'status', sort_key: 'createdAt' }
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
validates :email, presence: true
|
|
37
|
+
validates :email, uniqueness: true
|
|
38
|
+
|
|
39
|
+
scope :active, -> { where(status: 'active') }
|
|
40
|
+
|
|
41
|
+
before_create :set_defaults
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def set_defaults
|
|
46
|
+
self.status ||= 'active'
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### CRUD
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
user = User.create!(email: 'alice@example.com', name: 'Alice')
|
|
55
|
+
user = User.find('user-123')
|
|
56
|
+
user.update(name: 'Alice Smith')
|
|
57
|
+
user.destroy
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Querying
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
User.where(status: 'active', index: 'StatusIndex')
|
|
64
|
+
User.where(email: 'alice@example.com', index: 'EmailIndex').first
|
|
65
|
+
User.where.not(status: 'deleted')
|
|
66
|
+
User.all.limit(50)
|
|
67
|
+
User.count
|
|
68
|
+
User.exists?('user-123')
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Associations
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
class Post < ActiveItem::Base
|
|
75
|
+
belongs_to :user
|
|
76
|
+
has_many :comments, foreign_key: 'post_id', index: 'PostIndex'
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Transactions
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
ActiveItem::Base.transaction do |txn|
|
|
84
|
+
txn.put(new_record)
|
|
85
|
+
txn.update(existing_record)
|
|
86
|
+
txn.delete(old_record)
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Pagination
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
result = Post.where(user_id: id, index: 'UserIndex').page(cursor, per_page: 25)
|
|
94
|
+
result.items # => [Post, Post, ...]
|
|
95
|
+
result.pagination_metadata # => { next_cursor: "...", has_more: true, per_page: 25 }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Composed Of (Value Objects)
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
class Customer < ActiveItem::Base
|
|
102
|
+
attr_accessor :street, :city, :state, :zip_code
|
|
103
|
+
|
|
104
|
+
composed_of :address, class_name: 'Address', mapping: {
|
|
105
|
+
street: :street, city: :city, state: :state, zip_code: :zip_code
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
require 'active_support/inflector'
|
|
5
|
+
require_relative 'model_loader'
|
|
6
|
+
|
|
7
|
+
module ActiveItem
|
|
8
|
+
module Associations
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
include ModelLoader
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
class_attribute :_associations, default: {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def check_dependent_associations
|
|
17
|
+
self.class._associations.each do |name, config|
|
|
18
|
+
next unless config[:type] == :has_many && config[:dependent]
|
|
19
|
+
|
|
20
|
+
associated_records = send(name)
|
|
21
|
+
has_records = associated_records.respond_to?(:any?) ? associated_records.any? : false
|
|
22
|
+
|
|
23
|
+
next unless has_records
|
|
24
|
+
|
|
25
|
+
case config[:dependent]
|
|
26
|
+
when :restrict_with_exception
|
|
27
|
+
raise DeleteRestrictionError.new(name)
|
|
28
|
+
when :restrict_with_error
|
|
29
|
+
error_message = config[:message] || "Cannot delete #{self.class.name} because dependent #{name} exist"
|
|
30
|
+
errors.add(:base, error_message)
|
|
31
|
+
throw(:abort)
|
|
32
|
+
when :destroy
|
|
33
|
+
associated_records.each(&:destroy)
|
|
34
|
+
when :delete_all
|
|
35
|
+
associated_records.each(&:delete)
|
|
36
|
+
when :nullify
|
|
37
|
+
foreign_key = config[:foreign_key]
|
|
38
|
+
associated_records.each { |record| record.update(foreign_key => nil) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class_methods do
|
|
44
|
+
def has_many(name, scope_or_options = nil, options = {})
|
|
45
|
+
if scope_or_options.is_a?(Proc)
|
|
46
|
+
scope = scope_or_options
|
|
47
|
+
elsif scope_or_options.is_a?(Hash)
|
|
48
|
+
options = scope_or_options
|
|
49
|
+
scope = nil
|
|
50
|
+
else
|
|
51
|
+
scope = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
association_name = name.to_sym
|
|
55
|
+
class_name = options[:class_name] || name.to_s.singularize.camelize
|
|
56
|
+
foreign_key = options[:foreign_key] || "#{self.name.underscore}_id"
|
|
57
|
+
index_name = options[:index]
|
|
58
|
+
local_key = options[:primary_key] || primary_key
|
|
59
|
+
|
|
60
|
+
self._associations = _associations.merge(
|
|
61
|
+
association_name => {
|
|
62
|
+
type: :has_many, class_name: class_name, foreign_key: foreign_key,
|
|
63
|
+
index: index_name, primary_key: local_key, scope: scope,
|
|
64
|
+
dependent: options[:dependent], message: options[:message]
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
define_method(association_name) { load_has_many_association(association_name) }
|
|
69
|
+
|
|
70
|
+
define_method(:"#{association_name}_count") do
|
|
71
|
+
_preloaded_counts.key?(association_name) ? _preloaded_counts[association_name] : send(association_name).length
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def belongs_to(name, options = {})
|
|
76
|
+
association_name = name.to_sym
|
|
77
|
+
class_name = options[:class_name] || name.to_s.camelize
|
|
78
|
+
foreign_key = options[:foreign_key] || "#{name}_id"
|
|
79
|
+
remote_primary_key = options[:primary_key]
|
|
80
|
+
optional = options.fetch(:optional, false)
|
|
81
|
+
|
|
82
|
+
self._associations = _associations.merge(
|
|
83
|
+
association_name => {
|
|
84
|
+
type: :belongs_to, class_name: class_name, foreign_key: foreign_key,
|
|
85
|
+
primary_key: remote_primary_key, optional: optional
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
foreign_key_sym = foreign_key.to_sym
|
|
90
|
+
unless method_defined?(foreign_key_sym) || private_method_defined?(foreign_key_sym)
|
|
91
|
+
attr_accessor foreign_key_sym
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
validates foreign_key_sym, presence: true unless optional
|
|
95
|
+
|
|
96
|
+
define_method(association_name) { load_belongs_to_association(association_name) }
|
|
97
|
+
|
|
98
|
+
define_method("#{association_name}=") { |record| set_belongs_to_association(association_name, record) }
|
|
99
|
+
|
|
100
|
+
default_foreign_key = "#{association_name}_id"
|
|
101
|
+
if foreign_key.to_s != default_foreign_key
|
|
102
|
+
define_method(default_foreign_key) { send(foreign_key) }
|
|
103
|
+
define_method("#{default_foreign_key}=") { |value| send("#{foreign_key}=", value) }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def load_has_many_association(name)
|
|
111
|
+
config = self.class._associations[name]
|
|
112
|
+
return Relation.new(Object, conditions: { _empty: true }) unless config
|
|
113
|
+
|
|
114
|
+
if _preloaded_associations.key?(name)
|
|
115
|
+
return Relation.new(nil, preloaded_records: _preloaded_associations[name], class_name: config[:class_name])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
local_key_value = send(config[:primary_key])
|
|
119
|
+
return Relation.new(nil, conditions: { _empty: true }, class_name: config[:class_name]) if local_key_value.nil?
|
|
120
|
+
|
|
121
|
+
conditions = { config[:foreign_key].to_sym => local_key_value }
|
|
122
|
+
relation = Relation.new(nil, conditions: conditions, index_name: config[:index],
|
|
123
|
+
class_name: config[:class_name], owner: self)
|
|
124
|
+
|
|
125
|
+
if config[:scope]
|
|
126
|
+
if config[:scope].arity == 0
|
|
127
|
+
relation.instance_exec(&config[:scope]) || relation
|
|
128
|
+
else
|
|
129
|
+
config[:scope].call(relation)
|
|
130
|
+
end
|
|
131
|
+
else
|
|
132
|
+
relation
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def load_belongs_to_association(name)
|
|
137
|
+
config = self.class._associations[name]
|
|
138
|
+
return nil unless config
|
|
139
|
+
|
|
140
|
+
cache_var = :"@_association_cache_#{name}"
|
|
141
|
+
return instance_variable_get(cache_var) if instance_variable_defined?(cache_var)
|
|
142
|
+
|
|
143
|
+
foreign_key_value = send(config[:foreign_key])
|
|
144
|
+
return nil if foreign_key_value.nil?
|
|
145
|
+
|
|
146
|
+
klass = safe_constantize_model(config[:class_name])
|
|
147
|
+
|
|
148
|
+
begin
|
|
149
|
+
record = klass.find(foreign_key_value)
|
|
150
|
+
rescue ActiveItem::RecordNotFound
|
|
151
|
+
raise unless config[:optional]
|
|
152
|
+
record = nil
|
|
153
|
+
end
|
|
154
|
+
instance_variable_set(cache_var, record)
|
|
155
|
+
record
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def set_belongs_to_association(name, record)
|
|
159
|
+
config = self.class._associations[name]
|
|
160
|
+
return unless config
|
|
161
|
+
|
|
162
|
+
cache_var = :"@_association_cache_#{name}"
|
|
163
|
+
instance_variable_set(cache_var, record)
|
|
164
|
+
|
|
165
|
+
if record.nil?
|
|
166
|
+
send("#{config[:foreign_key]}=", nil)
|
|
167
|
+
else
|
|
168
|
+
pk = config[:primary_key] || record.class.primary_key
|
|
169
|
+
send("#{config[:foreign_key]}=", record.send(pk))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
hook_method = :"after_set_#{name}_association"
|
|
173
|
+
send(hook_method, record) if respond_to?(hook_method, true)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|