activeitem 0.0.13 → 0.0.14
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 +73 -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: 54985c6329129ceac142d8f176c14dfcc68548992f7e00d54a116592ee2a9276
|
|
4
|
+
data.tar.gz: abee89f1984d42b3dd72991c5db573370ed356d4272be0bb0d4d21163a233f50
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 456eb037d79395d8a63916245a681af7ad4a98a933cb294226658bf532886a7feb4fa2f90a2593cd1df34d4b5abd6d9299b206af2ad72cb96a5f1aa4ce1d3334
|
|
7
|
+
data.tar.gz: a0626e95bf4d42f4b1f1c0c453223e50214cbad0be8de67e76539f42ff2c3449027bf78f62a6708303db14195c10558972b9715e7c2f9bbf95c080b5d4bcf509
|
|
@@ -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
|
@@ -1557,6 +1557,8 @@ module ActiveItem
|
|
|
1557
1557
|
preload_belongs_to(records, assoc_name, assoc_config)
|
|
1558
1558
|
when :has_many
|
|
1559
1559
|
preload_has_many_records(records, assoc_name, assoc_config)
|
|
1560
|
+
when :has_many_through
|
|
1561
|
+
preload_has_many_through(records, assoc_name, assoc_config)
|
|
1560
1562
|
end
|
|
1561
1563
|
end
|
|
1562
1564
|
|
|
@@ -1567,12 +1569,16 @@ module ActiveItem
|
|
|
1567
1569
|
|
|
1568
1570
|
case mode
|
|
1569
1571
|
when :count
|
|
1570
|
-
unless assoc_config[:type]
|
|
1572
|
+
unless %i[has_many has_many_through].include?(assoc_config[:type])
|
|
1571
1573
|
raise ArgumentError,
|
|
1572
1574
|
"count preloading only supported for has_many (got #{assoc_config[:type]} for #{assoc_name})"
|
|
1573
1575
|
end
|
|
1574
1576
|
|
|
1575
|
-
|
|
1577
|
+
if assoc_config[:type] == :has_many_through
|
|
1578
|
+
preload_has_many_through_counts(records, assoc_name, assoc_config)
|
|
1579
|
+
else
|
|
1580
|
+
preload_has_many_counts(records, assoc_name, assoc_config)
|
|
1581
|
+
end
|
|
1576
1582
|
when :records
|
|
1577
1583
|
preload_symbol_association(records, assoc_name)
|
|
1578
1584
|
else
|
|
@@ -1800,6 +1806,71 @@ module ActiveItem
|
|
|
1800
1806
|
record._preloaded_associations[assoc_name] = associated
|
|
1801
1807
|
end
|
|
1802
1808
|
end
|
|
1809
|
+
|
|
1810
|
+
# Preload has_many :through associations efficiently.
|
|
1811
|
+
# 3 total queries for N parent records:
|
|
1812
|
+
# 1. Load all intermediate (join) records for all parents (parallel GSI queries)
|
|
1813
|
+
# 2. Collect unique target IDs from intermediates
|
|
1814
|
+
# 3. Single batch_find for all target records
|
|
1815
|
+
#
|
|
1816
|
+
# @param records [Array] Parent records
|
|
1817
|
+
# @param assoc_name [Symbol] Association name (e.g., :authors)
|
|
1818
|
+
# @param config [Hash] Association config from _associations
|
|
1819
|
+
def preload_has_many_through(records, assoc_name, config)
|
|
1820
|
+
through_name = config[:through]
|
|
1821
|
+
source_name = config[:source]
|
|
1822
|
+
target_class = safe_constantize_model(config[:class_name])
|
|
1823
|
+
|
|
1824
|
+
through_config = resolved_model._associations[through_name]
|
|
1825
|
+
raise ArgumentError, "Unknown through association: #{through_name}" unless through_config
|
|
1826
|
+
|
|
1827
|
+
# Step 1: Preload the intermediate association on all records
|
|
1828
|
+
preload_has_many_records(records, through_name, through_config)
|
|
1829
|
+
|
|
1830
|
+
# Step 2: Collect all target IDs from intermediate records
|
|
1831
|
+
target_foreign_key = "#{source_name}_id"
|
|
1832
|
+
all_target_ids = []
|
|
1833
|
+
intermediate_by_parent = {}
|
|
1834
|
+
|
|
1835
|
+
records.each do |record|
|
|
1836
|
+
intermediates = record._preloaded_associations[through_name] || []
|
|
1837
|
+
ids = intermediates.filter_map { |r| r.send(target_foreign_key) }
|
|
1838
|
+
intermediate_by_parent[record.id] = ids
|
|
1839
|
+
all_target_ids.concat(ids)
|
|
1840
|
+
end
|
|
1841
|
+
|
|
1842
|
+
all_target_ids.uniq!
|
|
1843
|
+
|
|
1844
|
+
# Step 3: Single batch_find for all target records
|
|
1845
|
+
target_records_by_id = if all_target_ids.any?
|
|
1846
|
+
target_class.batch_find(all_target_ids).to_h { |r| [r.id, r] }
|
|
1847
|
+
else
|
|
1848
|
+
{}
|
|
1849
|
+
end
|
|
1850
|
+
|
|
1851
|
+
# Cache resolved target records on each parent
|
|
1852
|
+
records.each do |record|
|
|
1853
|
+
ids = intermediate_by_parent[record.id] || []
|
|
1854
|
+
associated = ids.filter_map { |tid| target_records_by_id[tid] }
|
|
1855
|
+
record._preloaded_associations[assoc_name] = associated
|
|
1856
|
+
end
|
|
1857
|
+
end
|
|
1858
|
+
|
|
1859
|
+
# Preload has_many :through counts efficiently.
|
|
1860
|
+
# Uses the intermediate association's count per parent (avoids loading target records).
|
|
1861
|
+
def preload_has_many_through_counts(records, assoc_name, config)
|
|
1862
|
+
through_name = config[:through]
|
|
1863
|
+
through_config = resolved_model._associations[through_name]
|
|
1864
|
+
raise ArgumentError, "Unknown through association: #{through_name}" unless through_config
|
|
1865
|
+
|
|
1866
|
+
# Count of through records == count of target records (1:1 via join)
|
|
1867
|
+
preload_has_many_counts(records, through_name, through_config)
|
|
1868
|
+
|
|
1869
|
+
# Copy the through counts as the through-association counts
|
|
1870
|
+
records.each do |record|
|
|
1871
|
+
record._preloaded_counts[assoc_name] = record._preloaded_counts[through_name] || 0
|
|
1872
|
+
end
|
|
1873
|
+
end
|
|
1803
1874
|
end
|
|
1804
1875
|
|
|
1805
1876
|
# 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.14
|
|
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-03 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: activemodel
|