activeitem 0.0.6 → 0.0.8

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: 4bd08b09e77003fc3f79255ab24f24c760f243e9f903504f54ccd87ca57780bd
4
- data.tar.gz: 24bcccb1c051cc64a131fbc643b58c4709bb3421ac8a9c1de077586cdbd8fcf3
3
+ metadata.gz: 3e8035a54e42976c535c533e7187609138bf20f626e3ae031d311e1306ed7bf2
4
+ data.tar.gz: dabe50cd80d38b1708f028b126056c2e89f61ecaafe1fea3390108fde9cd1439
5
5
  SHA512:
6
- metadata.gz: 133c5a2229321e92d22f5318403d6ba3ddd5f59ad1aaa5da7b36d4b00e7a5794233885cafc38e748f23df3853bfa31fa8b4849c4442536d26be95d90e59dac9f
7
- data.tar.gz: '08b82fe9db0c16b8c4256bd2c372bcdf9472281029eb3054bb0e970ca6d4e8feec2eca6c772b601ec027a5fafa7bd17fcfad94c4c5ee51cfa527bb5079514d07'
6
+ metadata.gz: 1d167c59a7ae52de90c7a084740a96c271e88c8d74bdd389544c606977bf652d35e73614026c248ed5ad1b42ba44e8ee7b623060dc5b1852a519ccb93082614c
7
+ data.tar.gz: d3fb65a11b3fcfe603ca2f7abbd7c2dc54da5cc119ddb0ca423e535e6210d50503a59401e7f112f395c50b817cf8ec6a154526ca73370c35b78f6492823b75f4
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.8
4
+
5
+ ### Added
6
+
7
+ - **Array partition key fan-out** — `where(partition_key: [val1, val2, ...])` now queries each partition in parallel and merges results, instead of raising `ArgumentError`
8
+ - Works with `.to_a`, `.page(cursor, per_page:)`, `.count`, `.order(:desc)`, `.not(...)`, and all other chainable methods
9
+ - Pagination uses a `sort_val|id` cursor format for cross-partition consistency
10
+ - Thread-pooled execution (max 10 concurrent queries) matching existing `preload_has_many_counts` pattern
11
+
12
+ ## 0.0.7
13
+
14
+ ### Added
15
+
16
+ - `destroy!` — raises `ActiveItem::RecordNotDestroyed` when the record cannot be destroyed (e.g., callbacks halt the chain)
17
+
3
18
  ## 0.0.6
4
19
 
5
20
  ### Added
@@ -420,6 +420,10 @@ module ActiveItem
420
420
  false
421
421
  end
422
422
 
423
+ def destroy!
424
+ destroy || raise(RecordNotDestroyed.new(nil, self))
425
+ end
426
+
423
427
  def delete
424
428
  perform_destroy
425
429
  true
@@ -18,6 +18,16 @@ module ActiveItem
18
18
  end
19
19
  end
20
20
 
21
+ # Raised by destroy! when the record is not destroyed.
22
+ class RecordNotDestroyed < StandardError
23
+ attr_reader :record
24
+
25
+ def initialize(message = nil, record = nil)
26
+ @record = record
27
+ super(message || 'Failed to destroy the record')
28
+ end
29
+ end
30
+
21
31
  # Raised when a record cannot be deleted because dependent associations
22
32
  # with :restrict_with_exception still exist.
23
33
  class DeleteRestrictionError < StandardError
@@ -507,10 +507,20 @@ module ActiveItem
507
507
  return [[], nil] if conditions[:_empty]
508
508
 
509
509
  normalized_conditions = normalize_conditions(conditions)
510
- exclusive_start_key = decode_cursor(cursor)
511
510
 
512
511
  effective_index = index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions)
513
512
 
513
+ # Array partition key fan-out uses a simple string cursor (sort_val|id), not DynamoDB LastEvaluatedKey
514
+ if effective_index && normalized_conditions.any? && normalized_conditions.values.first.is_a?(Array)
515
+ index_config = resolved_model.indexes[effective_index] || {}
516
+ ruby_partition_key = normalized_conditions.keys.first.to_s
517
+ dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
518
+ return fanout_paginated_query(effective_index, normalized_conditions, index_config, dynamo_partition_key,
519
+ normalized_conditions.values.first, cursor, per_page)
520
+ end
521
+
522
+ exclusive_start_key = decode_cursor(cursor)
523
+
514
524
  if effective_index && normalized_conditions.any?
515
525
  paginated_query_with_index(effective_index, normalized_conditions, exclusive_start_key, per_page)
516
526
  else
@@ -755,6 +765,9 @@ module ActiveItem
755
765
 
756
766
  effective_index = (index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions) if normalized_conditions.any?)
757
767
 
768
+ # Array partition key: fan-out count queries in parallel
769
+ return fanout_count_query(effective_index, normalized_conditions) if effective_index && normalized_conditions.any? && normalized_conditions.values.first.is_a?(Array)
770
+
758
771
  total = 0
759
772
  exclusive_start_key = nil
760
773
 
@@ -1034,7 +1047,7 @@ module ActiveItem
1034
1047
  index_config = resolved_model.indexes[idx_name] || {}
1035
1048
  dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
1036
1049
 
1037
- raise ArgumentError, 'Array values not supported for partition key queries. Use scan instead.' if partition_value.is_a?(Array)
1050
+ return fanout_query(idx_name, normalized_conditions, index_config, dynamo_partition_key, partition_value) if partition_value.is_a?(Array)
1038
1051
 
1039
1052
  raise ArgumentError, 'Range values not supported for partition key queries. Use scan instead.' if partition_value.is_a?(Range)
1040
1053
 
@@ -1120,6 +1133,281 @@ module ActiveItem
1120
1133
  all_items
1121
1134
  end
1122
1135
 
1136
+ # Fan-out parallel queries when partition key is an Array.
1137
+ # Queries each partition key in parallel, merges and optionally sorts results.
1138
+ def fanout_query(idx_name, _normalized_conditions, index_config, dynamo_partition_key, partition_values)
1139
+ return [] if partition_values.empty?
1140
+
1141
+ sort_key = index_config[:sort_key]&.to_s
1142
+ remaining_conditions = conditions.to_a[1..]
1143
+ filter_parts, filter_names, filter_values = build_fanout_filters(sort_key, remaining_conditions)
1144
+
1145
+ all_items = []
1146
+ mutex = Mutex.new
1147
+ max_concurrency = 10
1148
+
1149
+ partition_values.each_slice(max_concurrency) do |batch|
1150
+ threads = batch.map do |pk_val|
1151
+ Thread.new do
1152
+ params = {
1153
+ table_name: resolved_model.table_name,
1154
+ index_name: idx_name,
1155
+ key_condition_expression: '#pk = :pk_val',
1156
+ expression_attribute_names: { '#pk' => dynamo_partition_key }.merge(filter_names),
1157
+ expression_attribute_values: { ':pk_val' => pk_val }.merge(filter_values)
1158
+ }
1159
+
1160
+ apply_sort_key_to_params!(params, sort_key, remaining_conditions)
1161
+ params[:filter_expression] = filter_parts.join(' AND ') if filter_parts.any?
1162
+ params[:limit] = limit_value if limit_value
1163
+ params[:scan_index_forward] = (order_direction != :desc) unless order_direction.nil?
1164
+ apply_projection_expression!(params)
1165
+
1166
+ items = []
1167
+ exclusive_start_key = nil
1168
+ loop do
1169
+ params[:exclusive_start_key] = exclusive_start_key if exclusive_start_key
1170
+ response = resolved_model.dynamodb.query(params)
1171
+ items.concat(response.items.map { |item| resolved_model.instantiate(item) })
1172
+ exclusive_start_key = response.last_evaluated_key
1173
+ break unless exclusive_start_key
1174
+ break if limit_value && items.length >= limit_value
1175
+ end
1176
+ mutex.synchronize { all_items.concat(items) }
1177
+ end
1178
+ end
1179
+ threads.each(&:join)
1180
+ end
1181
+
1182
+ all_items = apply_ilike_filter(all_items)
1183
+ all_items = sort_fanout_results(all_items, sort_key)
1184
+ all_items = all_items.first(limit_value) if limit_value
1185
+ all_items
1186
+ end
1187
+
1188
+ # Fan-out paginated query across multiple partition keys.
1189
+ # Uses a composite cursor that encodes sort_key|id position for cross-partition pagination.
1190
+ def fanout_paginated_query(idx_name, _normalized_conditions, index_config, dynamo_partition_key, partition_values,
1191
+ cursor, per_page)
1192
+ return [[], nil] if partition_values.empty?
1193
+
1194
+ sort_key = index_config[:sort_key]&.to_s
1195
+ remaining_conditions = conditions.to_a[1..]
1196
+ filter_parts, filter_names, filter_values = build_fanout_filters(sort_key, remaining_conditions)
1197
+ cursor_sort_val, cursor_id = decode_fanout_cursor(cursor)
1198
+
1199
+ all_items = []
1200
+ mutex = Mutex.new
1201
+ max_concurrency = 10
1202
+
1203
+ partition_values.each_slice(max_concurrency) do |batch|
1204
+ threads = batch.map do |pk_val|
1205
+ Thread.new do
1206
+ params = {
1207
+ table_name: resolved_model.table_name,
1208
+ index_name: idx_name,
1209
+ key_condition_expression: '#pk = :pk_val',
1210
+ expression_attribute_names: { '#pk' => dynamo_partition_key }.merge(filter_names),
1211
+ expression_attribute_values: { ':pk_val' => pk_val }.merge(filter_values)
1212
+ }
1213
+
1214
+ apply_sort_key_to_params!(params, sort_key, remaining_conditions)
1215
+
1216
+ # Apply cursor range condition to skip already-seen items
1217
+ if cursor_sort_val && sort_key
1218
+ sk_placeholder = '#fanout_sk'
1219
+ val_placeholder = ':fanout_sk_val'
1220
+ op = order_direction == :desc ? '<=' : '>='
1221
+ params[:key_condition_expression] += " AND #{sk_placeholder} #{op} #{val_placeholder}"
1222
+ params[:expression_attribute_names][sk_placeholder] = sort_key
1223
+ params[:expression_attribute_values][val_placeholder] = cursor_sort_val
1224
+ end
1225
+
1226
+ params[:filter_expression] = filter_parts.join(' AND ') if filter_parts.any?
1227
+ params[:scan_index_forward] = (order_direction != :desc) unless order_direction.nil?
1228
+ # Fetch enough to fill a page after merge
1229
+ params[:limit] = per_page + 1
1230
+
1231
+ items = []
1232
+ exclusive_start_key = nil
1233
+ loop do
1234
+ params[:exclusive_start_key] = exclusive_start_key if exclusive_start_key
1235
+ response = resolved_model.dynamodb.query(params)
1236
+ items.concat(response.items.map { |item| resolved_model.instantiate(item) })
1237
+ exclusive_start_key = response.last_evaluated_key
1238
+ break unless exclusive_start_key
1239
+ break if items.length >= per_page + 1
1240
+ end
1241
+ mutex.synchronize { all_items.concat(items) }
1242
+ end
1243
+ end
1244
+ threads.each(&:join)
1245
+ end
1246
+
1247
+ all_items = apply_ilike_filter(all_items)
1248
+ all_items = sort_fanout_results(all_items, sort_key)
1249
+
1250
+ # Skip items at or before the cursor position
1251
+ if cursor_sort_val && cursor_id
1252
+ all_items = all_items.drop_while do |item|
1253
+ sk_val = if sort_key
1254
+ item.respond_to?(sort_key) ? item.send(sort_key) : item.send(underscore(sort_key))
1255
+ end
1256
+ sk_val ||= item.respond_to?(:created_at) ? item.created_at : nil
1257
+ cmp = compare_with_nil(sk_val, cursor_sort_val)
1258
+ if order_direction == :desc
1259
+ cmp.positive? || (cmp.zero? && item.id >= cursor_id)
1260
+ else
1261
+ cmp.negative? || (cmp.zero? && item.id <= cursor_id)
1262
+ end
1263
+ end
1264
+ end
1265
+
1266
+ page_items = all_items.first(per_page)
1267
+ next_cursor = (encode_fanout_cursor(page_items.last, sort_key) if all_items.length > per_page && page_items.last)
1268
+
1269
+ [page_items, next_cursor]
1270
+ end
1271
+
1272
+ def build_fanout_filters(sort_key, remaining_conditions)
1273
+ filter_parts = []
1274
+ filter_names = {}
1275
+ filter_values = {}
1276
+
1277
+ # Exclude sort key conditions — those go into key_condition_expression
1278
+ non_sort_conditions = remaining_conditions.reject do |k, _|
1279
+ sort_key && (resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key)
1280
+ end
1281
+
1282
+ non_sort_conditions.each_with_index do |(attr, val), idx|
1283
+ expr, names, values = resolved_model.send(:build_condition_expression, attr, val, idx, ilike: ilike)
1284
+ filter_parts << expr
1285
+ filter_names.merge!(names)
1286
+ filter_values.merge!(values) if values.any?
1287
+ end
1288
+
1289
+ not_conditions.each_with_index do |(attr, val), idx|
1290
+ expr, names, values = build_not_condition_expression(attr, val, "not#{idx}")
1291
+ filter_parts << expr
1292
+ filter_names.merge!(names)
1293
+ filter_values.merge!(values) if values.any?
1294
+ end
1295
+
1296
+ [filter_parts, filter_names, filter_values]
1297
+ end
1298
+
1299
+ def apply_sort_key_to_params!(params, sort_key, remaining_conditions)
1300
+ return unless sort_key && remaining_conditions.any?
1301
+
1302
+ sort_condition = remaining_conditions.find do |k, _|
1303
+ resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
1304
+ end
1305
+ return unless sort_condition
1306
+
1307
+ _, sort_value = sort_condition
1308
+ if sort_value.is_a?(Range)
1309
+ range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
1310
+ params[:key_condition_expression] += " AND #{range_condition[:expression]}"
1311
+ params[:expression_attribute_names].merge!(range_condition[:names])
1312
+ params[:expression_attribute_values].merge!(range_condition[:values])
1313
+ else
1314
+ params[:key_condition_expression] += ' AND #sk = :sk_val'
1315
+ params[:expression_attribute_names]['#sk'] = sort_key
1316
+ params[:expression_attribute_values][':sk_val'] = sort_value
1317
+ end
1318
+ end
1319
+
1320
+ def sort_fanout_results(items, sort_key)
1321
+ return items unless sort_key
1322
+
1323
+ sorted = items.sort_by do |item|
1324
+ sk_val = item.respond_to?(sort_key) ? item.send(sort_key) : item.send(underscore(sort_key))
1325
+ sk_val ||= ''
1326
+ if order_direction == :desc
1327
+ [sk_val, item.id].map { |v| v.is_a?(String) ? v : v.to_s }
1328
+ else
1329
+ [sk_val, item.id]
1330
+ end
1331
+ end
1332
+
1333
+ order_direction == :desc ? sorted.reverse : sorted
1334
+ end
1335
+
1336
+ def decode_fanout_cursor(cursor)
1337
+ return [nil, nil] if cursor.nil? || cursor.empty?
1338
+
1339
+ sort_val, id = cursor.split('|', 2)
1340
+ [sort_val, id]
1341
+ rescue StandardError
1342
+ [nil, nil]
1343
+ end
1344
+
1345
+ def encode_fanout_cursor(item, sort_key)
1346
+ sk_val = if sort_key
1347
+ item.respond_to?(sort_key) ? item.send(sort_key) : item.send(underscore(sort_key))
1348
+ end
1349
+ sk_val ||= item.respond_to?(:created_at) ? item.created_at : nil
1350
+ "#{sk_val}|#{item.id}"
1351
+ end
1352
+
1353
+ def compare_with_nil(left, right)
1354
+ return 0 if left.nil? && right.nil?
1355
+ return -1 if left.nil?
1356
+ return 1 if right.nil?
1357
+
1358
+ left <=> right
1359
+ end
1360
+
1361
+ def underscore(str)
1362
+ str.gsub(/([A-Z])/, '_\1').sub(/^_/, '').downcase
1363
+ end
1364
+
1365
+ def fanout_count_query(idx_name, normalized_conditions)
1366
+ partition_values = normalized_conditions.values.first
1367
+ index_config = resolved_model.indexes[idx_name] || {}
1368
+ ruby_partition_key = normalized_conditions.keys.first.to_s
1369
+ dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
1370
+ sort_key = index_config[:sort_key]&.to_s
1371
+ remaining_conditions = conditions.to_a[1..]
1372
+ filter_parts, filter_names, filter_values = build_fanout_filters(sort_key, remaining_conditions)
1373
+
1374
+ total = 0
1375
+ mutex = Mutex.new
1376
+ max_concurrency = 10
1377
+
1378
+ partition_values.each_slice(max_concurrency) do |batch|
1379
+ threads = batch.map do |pk_val|
1380
+ Thread.new do
1381
+ params = {
1382
+ table_name: resolved_model.table_name,
1383
+ index_name: idx_name,
1384
+ select: 'COUNT',
1385
+ key_condition_expression: '#pk = :pk_val',
1386
+ expression_attribute_names: { '#pk' => dynamo_partition_key }.merge(filter_names),
1387
+ expression_attribute_values: { ':pk_val' => pk_val }.merge(filter_values)
1388
+ }
1389
+
1390
+ apply_sort_key_to_params!(params, sort_key, remaining_conditions)
1391
+ params[:filter_expression] = filter_parts.join(' AND ') if filter_parts.any?
1392
+
1393
+ count = 0
1394
+ exclusive_start_key = nil
1395
+ loop do
1396
+ params[:exclusive_start_key] = exclusive_start_key if exclusive_start_key
1397
+ response = resolved_model.dynamodb.query(params)
1398
+ count += response.count
1399
+ exclusive_start_key = response.last_evaluated_key
1400
+ break unless exclusive_start_key
1401
+ end
1402
+ mutex.synchronize { total += count }
1403
+ end
1404
+ end
1405
+ threads.each(&:join)
1406
+ end
1407
+
1408
+ limit_value ? [total, limit_value].min : total
1409
+ end
1410
+
1123
1411
  def scan_with_conditions_normalized(normalized_conditions)
1124
1412
  filter_parts = []
1125
1413
  filter_values = {}
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveItem
4
- VERSION = '0.0.6'
4
+ VERSION = '0.0.8'
5
5
  end
data.tar.gz.sig CHANGED
Binary file
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.6
4
+ version: 0.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis
@@ -41,7 +41,7 @@ cert_chain:
41
41
  mR0ObwJ14vsQVbJibq0eRHIg8G4yV19pvEdJCli02eLl1+451M63HZAqBNuyJ9Ny
42
42
  I1fxbbEBAzf7WHfoKdwFMuRZq7hpdLykCA8YQJFlLLFoXT0g41ug9iOKBtGg
43
43
  -----END CERTIFICATE-----
44
- date: 2026-05-27 00:00:00.000000000 Z
44
+ date: 2026-06-06 00:00:00.000000000 Z
45
45
  dependencies:
46
46
  - !ruby/object:Gem::Dependency
47
47
  name: activemodel
metadata.gz.sig CHANGED
Binary file