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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e20ffb5a359af1d27e15e389e30505d40a742edbe2c9c98ab76e4498fd70239e
4
- data.tar.gz: 031b9d01ad6f636f1fb27c4752e36b10eedecf2d3909b2e11939f4c49368db4a
3
+ metadata.gz: 54985c6329129ceac142d8f176c14dfcc68548992f7e00d54a116592ee2a9276
4
+ data.tar.gz: abee89f1984d42b3dd72991c5db573370ed356d4272be0bb0d4d21163a233f50
5
5
  SHA512:
6
- metadata.gz: a6bc6e95cc4f1a87c81bf6afc15191a43bbdd037ac8fdf98dd2216585ab21903eaca17c30b6ce9e41f1e4ae5f4a01c31d8992cab1a628d132e7c0e9dadf911f2
7
- data.tar.gz: 19b6490facb7f9b3fa4224ab7e97867b65fde49fa99da5c202eec89db36f1da6b7dc70ec1bae53d5fc5f4003a2628db0f071120f88b6ec1a1074d4eada5a0646
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
@@ -327,7 +327,7 @@ module ActiveItem
327
327
  if index_definitions
328
328
  @index_definitions = index_definitions
329
329
  else
330
- RECENT_INDEX.merge(@index_definitions || {})
330
+ RECENT_INDEX.merge(@_belongs_to_indexes || {}).merge(@index_definitions || {})
331
331
  end
332
332
  end
333
333
 
@@ -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] == :has_many
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
- preload_has_many_counts(records, assoc_name, assoc_config)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveItem
4
- VERSION = '0.0.13'
4
+ VERSION = '0.0.14'
5
5
  end
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.13
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-07-31 00:00:00.000000000 Z
12
+ date: 2026-08-03 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activemodel