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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e20ffb5a359af1d27e15e389e30505d40a742edbe2c9c98ab76e4498fd70239e
4
- data.tar.gz: 031b9d01ad6f636f1fb27c4752e36b10eedecf2d3909b2e11939f4c49368db4a
3
+ metadata.gz: 2706d94d7f88c19fdb9fc14d8a9408d1f8dc088a5eabf2e583d60351173b9870
4
+ data.tar.gz: 41c8e00d8b5a45ec26b58123efc86437795e1921c59dc8c44f3a770727496702
5
5
  SHA512:
6
- metadata.gz: a6bc6e95cc4f1a87c81bf6afc15191a43bbdd037ac8fdf98dd2216585ab21903eaca17c30b6ce9e41f1e4ae5f4a01c31d8992cab1a628d132e7c0e9dadf911f2
7
- data.tar.gz: 19b6490facb7f9b3fa4224ab7e97867b65fde49fa99da5c202eec89db36f1da6b7dc70ec1bae53d5fc5f4003a2628db0f071120f88b6ec1a1074d4eada5a0646
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
@@ -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
 
@@ -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] == :has_many
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
- preload_has_many_counts(records, assoc_name, assoc_config)
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
@@ -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.15'
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.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-07-31 00:00:00.000000000 Z
12
+ date: 2026-08-04 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activemodel