activeitem 0.0.13 → 0.0.15
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 +4 -4
- data/lib/active_item/associations.rb +76 -0
- data/lib/active_item/query_helpers.rb +1 -1
- data/lib/active_item/relation.rb +106 -2
- data/lib/active_item/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2706d94d7f88c19fdb9fc14d8a9408d1f8dc088a5eabf2e583d60351173b9870
|
|
4
|
+
data.tar.gz: 41c8e00d8b5a45ec26b58123efc86437795e1921c59dc8c44f3a770727496702
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 41689642db2d18371ae7f9c7987a72d001f3aa18d5e7bb3f0a7248100e5de2a8aa6cb039742057c176bfb6f9eeaa02a240847d50c64f6f02c54372da6c069020
|
|
7
|
+
data.tar.gz: 5c1a35c85c0b6d641fd630681fccb4995637eb733c50eada9a197a1c2fd503cc62855c1ad14eb81e19ccf324e1da2941ab93cbf87b72e5116285b8b5e49ed7a8
|
|
@@ -52,6 +52,12 @@ module ActiveItem
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
association_name = name.to_sym
|
|
55
|
+
|
|
56
|
+
if options[:through]
|
|
57
|
+
define_has_many_through(association_name, options)
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
55
61
|
class_name = options[:class_name] || name.to_s.singularize.camelize
|
|
56
62
|
foreign_key = options[:foreign_key] || "#{self.name.underscore}_id"
|
|
57
63
|
index_name = options[:index]
|
|
@@ -72,12 +78,36 @@ module ActiveItem
|
|
|
72
78
|
end
|
|
73
79
|
end
|
|
74
80
|
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def define_has_many_through(association_name, options)
|
|
84
|
+
through_name = options[:through].to_sym
|
|
85
|
+
source_name = options[:source]&.to_sym || association_name.to_s.singularize.to_sym
|
|
86
|
+
class_name = options[:class_name] || association_name.to_s.singularize.camelize
|
|
87
|
+
|
|
88
|
+
self._associations = _associations.merge(
|
|
89
|
+
association_name => {
|
|
90
|
+
type: :has_many_through, through: through_name, source: source_name,
|
|
91
|
+
class_name: class_name
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
define_method(association_name) { load_has_many_through_association(association_name) }
|
|
96
|
+
|
|
97
|
+
define_method(:"#{association_name}_count") do
|
|
98
|
+
_preloaded_counts.key?(association_name) ? _preloaded_counts[association_name] : send(association_name).length
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
public
|
|
103
|
+
|
|
75
104
|
def belongs_to(name, options = {})
|
|
76
105
|
association_name = name.to_sym
|
|
77
106
|
class_name = options[:class_name] || name.to_s.camelize
|
|
78
107
|
foreign_key = options[:foreign_key] || "#{name}_id"
|
|
79
108
|
remote_primary_key = options[:primary_key]
|
|
80
109
|
optional = options.fetch(:optional, false)
|
|
110
|
+
index_option = options.fetch(:index, true)
|
|
81
111
|
|
|
82
112
|
self._associations = _associations.merge(
|
|
83
113
|
association_name => {
|
|
@@ -91,6 +121,8 @@ module ActiveItem
|
|
|
91
121
|
|
|
92
122
|
validates foreign_key_sym, presence: true unless optional
|
|
93
123
|
|
|
124
|
+
register_belongs_to_index(association_name, foreign_key, index_option)
|
|
125
|
+
|
|
94
126
|
define_method(association_name) { load_belongs_to_association(association_name) }
|
|
95
127
|
|
|
96
128
|
define_method("#{association_name}=") { |record| set_belongs_to_association(association_name, record) }
|
|
@@ -101,6 +133,21 @@ module ActiveItem
|
|
|
101
133
|
define_method(default_foreign_key) { send(foreign_key) }
|
|
102
134
|
define_method("#{default_foreign_key}=") { |value| send("#{foreign_key}=", value) }
|
|
103
135
|
end
|
|
136
|
+
|
|
137
|
+
def register_belongs_to_index(association_name, foreign_key, index_option)
|
|
138
|
+
return if index_option == false
|
|
139
|
+
|
|
140
|
+
index_name = if index_option.is_a?(String)
|
|
141
|
+
index_option
|
|
142
|
+
else
|
|
143
|
+
"#{association_name.to_s.camelize}Index"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
@_belongs_to_indexes ||= {}
|
|
147
|
+
@_belongs_to_indexes = @_belongs_to_indexes.merge(
|
|
148
|
+
index_name => { partition_key: foreign_key }
|
|
149
|
+
)
|
|
150
|
+
end
|
|
104
151
|
end
|
|
105
152
|
|
|
106
153
|
private
|
|
@@ -129,6 +176,35 @@ module ActiveItem
|
|
|
129
176
|
end
|
|
130
177
|
end
|
|
131
178
|
|
|
179
|
+
# Load a has_many :through association.
|
|
180
|
+
# Two queries: GSI query on join table → batch_find on target table.
|
|
181
|
+
def load_has_many_through_association(name)
|
|
182
|
+
config = self.class._associations[name]
|
|
183
|
+
return Relation.new(Object, conditions: { _empty: true }) unless config
|
|
184
|
+
|
|
185
|
+
# If preloaded (via includes), return cached records
|
|
186
|
+
return Relation.new(nil, preloaded_records: _preloaded_associations[name], class_name: config[:class_name]) if _preloaded_associations.key?(name)
|
|
187
|
+
|
|
188
|
+
through_name = config[:through]
|
|
189
|
+
source_name = config[:source]
|
|
190
|
+
target_class = safe_constantize_model(config[:class_name])
|
|
191
|
+
|
|
192
|
+
# Query 1: Load intermediate records via the through association
|
|
193
|
+
intermediate_records = send(through_name).to_a
|
|
194
|
+
|
|
195
|
+
# Determine the foreign key on the join model that points to the target
|
|
196
|
+
# Convention: source_name + "_id" (e.g., :author → "author_id")
|
|
197
|
+
target_foreign_key = "#{source_name}_id"
|
|
198
|
+
target_ids = intermediate_records.filter_map { |r| r.send(target_foreign_key) }.uniq
|
|
199
|
+
|
|
200
|
+
return Relation.new(nil, preloaded_records: [], class_name: config[:class_name]) if target_ids.empty?
|
|
201
|
+
|
|
202
|
+
# Query 2: Batch load target records
|
|
203
|
+
target_records = target_class.batch_find(target_ids)
|
|
204
|
+
|
|
205
|
+
Relation.new(nil, preloaded_records: target_records, class_name: config[:class_name])
|
|
206
|
+
end
|
|
207
|
+
|
|
132
208
|
def load_belongs_to_association(name)
|
|
133
209
|
config = self.class._associations[name]
|
|
134
210
|
return nil unless config
|
data/lib/active_item/relation.rb
CHANGED
|
@@ -374,6 +374,39 @@ module ActiveItem
|
|
|
374
374
|
record
|
|
375
375
|
end
|
|
376
376
|
|
|
377
|
+
# Build a new record with the foreign key pre-set (does not save).
|
|
378
|
+
# Usage: conversation.messages.build(role: "user", body: "hello")
|
|
379
|
+
# @param attributes [Hash] Attributes for the new record
|
|
380
|
+
# @return [ActiveItem::Base] The unsaved record
|
|
381
|
+
def build(attributes = {})
|
|
382
|
+
foreign_key, foreign_value = conditions.first
|
|
383
|
+
resolved_model.new(attributes.merge(foreign_key => foreign_value))
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Build and save a new record with the foreign key pre-set.
|
|
387
|
+
# Returns the record (may be invalid — check .persisted? or .errors).
|
|
388
|
+
# Usage: conversation.messages.create(role: "user", body: "hello")
|
|
389
|
+
# @param attributes [Hash] Attributes for the new record
|
|
390
|
+
# @return [ActiveItem::Base] The record (saved if valid)
|
|
391
|
+
def create(attributes = {})
|
|
392
|
+
record = build(attributes)
|
|
393
|
+
record.save
|
|
394
|
+
reload
|
|
395
|
+
record
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Build and save a new record with the foreign key pre-set.
|
|
399
|
+
# Raises ActiveItem::RecordInvalid if validations fail.
|
|
400
|
+
# Usage: conversation.messages.create!(role: "user", body: "hello")
|
|
401
|
+
# @param attributes [Hash] Attributes for the new record
|
|
402
|
+
# @return [ActiveItem::Base] The persisted record
|
|
403
|
+
def create!(attributes = {})
|
|
404
|
+
record = build(attributes)
|
|
405
|
+
record.save!
|
|
406
|
+
reload
|
|
407
|
+
record
|
|
408
|
+
end
|
|
409
|
+
|
|
377
410
|
# Returns the DynamoDB operation that would be executed, without running it.
|
|
378
411
|
# Analogous to ActiveRecord's .to_sql — shows the operation type, table, index,
|
|
379
412
|
# key conditions, filters, and limits in DynamoDB terms.
|
|
@@ -1557,6 +1590,8 @@ module ActiveItem
|
|
|
1557
1590
|
preload_belongs_to(records, assoc_name, assoc_config)
|
|
1558
1591
|
when :has_many
|
|
1559
1592
|
preload_has_many_records(records, assoc_name, assoc_config)
|
|
1593
|
+
when :has_many_through
|
|
1594
|
+
preload_has_many_through(records, assoc_name, assoc_config)
|
|
1560
1595
|
end
|
|
1561
1596
|
end
|
|
1562
1597
|
|
|
@@ -1567,12 +1602,16 @@ module ActiveItem
|
|
|
1567
1602
|
|
|
1568
1603
|
case mode
|
|
1569
1604
|
when :count
|
|
1570
|
-
unless assoc_config[:type]
|
|
1605
|
+
unless %i[has_many has_many_through].include?(assoc_config[:type])
|
|
1571
1606
|
raise ArgumentError,
|
|
1572
1607
|
"count preloading only supported for has_many (got #{assoc_config[:type]} for #{assoc_name})"
|
|
1573
1608
|
end
|
|
1574
1609
|
|
|
1575
|
-
|
|
1610
|
+
if assoc_config[:type] == :has_many_through
|
|
1611
|
+
preload_has_many_through_counts(records, assoc_name, assoc_config)
|
|
1612
|
+
else
|
|
1613
|
+
preload_has_many_counts(records, assoc_name, assoc_config)
|
|
1614
|
+
end
|
|
1576
1615
|
when :records
|
|
1577
1616
|
preload_symbol_association(records, assoc_name)
|
|
1578
1617
|
else
|
|
@@ -1800,6 +1839,71 @@ module ActiveItem
|
|
|
1800
1839
|
record._preloaded_associations[assoc_name] = associated
|
|
1801
1840
|
end
|
|
1802
1841
|
end
|
|
1842
|
+
|
|
1843
|
+
# Preload has_many :through associations efficiently.
|
|
1844
|
+
# 3 total queries for N parent records:
|
|
1845
|
+
# 1. Load all intermediate (join) records for all parents (parallel GSI queries)
|
|
1846
|
+
# 2. Collect unique target IDs from intermediates
|
|
1847
|
+
# 3. Single batch_find for all target records
|
|
1848
|
+
#
|
|
1849
|
+
# @param records [Array] Parent records
|
|
1850
|
+
# @param assoc_name [Symbol] Association name (e.g., :authors)
|
|
1851
|
+
# @param config [Hash] Association config from _associations
|
|
1852
|
+
def preload_has_many_through(records, assoc_name, config)
|
|
1853
|
+
through_name = config[:through]
|
|
1854
|
+
source_name = config[:source]
|
|
1855
|
+
target_class = safe_constantize_model(config[:class_name])
|
|
1856
|
+
|
|
1857
|
+
through_config = resolved_model._associations[through_name]
|
|
1858
|
+
raise ArgumentError, "Unknown through association: #{through_name}" unless through_config
|
|
1859
|
+
|
|
1860
|
+
# Step 1: Preload the intermediate association on all records
|
|
1861
|
+
preload_has_many_records(records, through_name, through_config)
|
|
1862
|
+
|
|
1863
|
+
# Step 2: Collect all target IDs from intermediate records
|
|
1864
|
+
target_foreign_key = "#{source_name}_id"
|
|
1865
|
+
all_target_ids = []
|
|
1866
|
+
intermediate_by_parent = {}
|
|
1867
|
+
|
|
1868
|
+
records.each do |record|
|
|
1869
|
+
intermediates = record._preloaded_associations[through_name] || []
|
|
1870
|
+
ids = intermediates.filter_map { |r| r.send(target_foreign_key) }
|
|
1871
|
+
intermediate_by_parent[record.id] = ids
|
|
1872
|
+
all_target_ids.concat(ids)
|
|
1873
|
+
end
|
|
1874
|
+
|
|
1875
|
+
all_target_ids.uniq!
|
|
1876
|
+
|
|
1877
|
+
# Step 3: Single batch_find for all target records
|
|
1878
|
+
target_records_by_id = if all_target_ids.any?
|
|
1879
|
+
target_class.batch_find(all_target_ids).to_h { |r| [r.id, r] }
|
|
1880
|
+
else
|
|
1881
|
+
{}
|
|
1882
|
+
end
|
|
1883
|
+
|
|
1884
|
+
# Cache resolved target records on each parent
|
|
1885
|
+
records.each do |record|
|
|
1886
|
+
ids = intermediate_by_parent[record.id] || []
|
|
1887
|
+
associated = ids.filter_map { |tid| target_records_by_id[tid] }
|
|
1888
|
+
record._preloaded_associations[assoc_name] = associated
|
|
1889
|
+
end
|
|
1890
|
+
end
|
|
1891
|
+
|
|
1892
|
+
# Preload has_many :through counts efficiently.
|
|
1893
|
+
# Uses the intermediate association's count per parent (avoids loading target records).
|
|
1894
|
+
def preload_has_many_through_counts(records, assoc_name, config)
|
|
1895
|
+
through_name = config[:through]
|
|
1896
|
+
through_config = resolved_model._associations[through_name]
|
|
1897
|
+
raise ArgumentError, "Unknown through association: #{through_name}" unless through_config
|
|
1898
|
+
|
|
1899
|
+
# Count of through records == count of target records (1:1 via join)
|
|
1900
|
+
preload_has_many_counts(records, through_name, through_config)
|
|
1901
|
+
|
|
1902
|
+
# Copy the through counts as the through-association counts
|
|
1903
|
+
records.each do |record|
|
|
1904
|
+
record._preloaded_counts[assoc_name] = record._preloaded_counts[through_name] || 0
|
|
1905
|
+
end
|
|
1906
|
+
end
|
|
1803
1907
|
end
|
|
1804
1908
|
|
|
1805
1909
|
# WhereChain enables the where.not(...) syntax
|
data/lib/active_item/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activeitem
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.15
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Davis
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: bin
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-
|
|
12
|
+
date: 2026-08-04 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: activemodel
|