hquery 1.0.0

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.
data/test.md ADDED
@@ -0,0 +1,1466 @@
1
+
2
+ # File: ./context_test.rb
3
+
4
+ require_relative 'test_helper'
5
+
6
+ module Hq
7
+ class TestDefinitionContext < Minitest::Test
8
+ def setup
9
+ @context = DefinitionContext.new(:test_model, 'data')
10
+ end
11
+
12
+ def test_from_array_with_valid_array
13
+ @context.from_array([{ id: 1, name: 'Test' }, { id: 2, name: 'Another' }])
14
+ assert_equal 2, @context.loaded_data.size
15
+ assert @context.loaded_data.all?(&:frozen?)
16
+ end
17
+
18
+ def test_from_array_with_non_array
19
+ error = assert_raises(DataFileError) { @context.from_array('not an array') }
20
+ assert_match /expects an Array of hashes, but got String/, error.message
21
+ end
22
+
23
+ def test_from_array_with_non_hash_item
24
+ error = assert_raises(DataFileError) { @context.from_array(['not a hash']) }
25
+ assert_match /array item 1 should be a Hash, but got String/, error.message
26
+ end
27
+
28
+ def test_scope_definition
29
+ @context.scope({ test_scope: -> { self } })
30
+ assert @context.scopes.key?(:test_scope)
31
+ end
32
+
33
+ def test_scope_non_callable
34
+ assert_raises(Hq::ScopeError) { @context.scope({ test_scope: 'not callable' }) }
35
+ end
36
+
37
+ def test_load_with_files
38
+ Dir.mktmpdir do |dir|
39
+ File.write(File.join(dir, 'file1.rb'), "{ id: 1, title: 'First' }")
40
+ File.write(File.join(dir, 'file2.rb'), "{ id: 2, title: 'Second' }")
41
+
42
+ context = DefinitionContext.new(:test, dir)
43
+ context.load('*.rb')
44
+
45
+ assert_equal 2, context.loaded_data.size
46
+ assert_equal({ id: 1, title: 'First' }, context.loaded_data[0])
47
+ assert_equal({ id: 2, title: 'Second' }, context.loaded_data[1])
48
+ assert context.loaded_data.all?(&:frozen?)
49
+ end
50
+ end
51
+
52
+ def test_load_no_files
53
+ Dir.mktmpdir do |dir|
54
+ context = DefinitionContext.new(:test, dir)
55
+ error = assert_raises(Hq::DataFileError) { context.load('*.rb') }
56
+ assert_match /No files found matching pattern/, error.message
57
+ end
58
+ end
59
+
60
+ def test_load_non_hash
61
+ Dir.mktmpdir do |dir|
62
+ File.write(File.join(dir, 'invalid.rb'), "'string'")
63
+ context = DefinitionContext.new(:test, dir)
64
+ error = assert_raises(DataFileError) { context.load('*.rb') }
65
+ assert_match /should return a Hash, but returned String/, error.message
66
+ end
67
+ end
68
+
69
+ def test_load_syntax_error
70
+ Dir.mktmpdir do |dir|
71
+ File.write(File.join(dir, 'invalid.rb'), "{ id: 1,, }")
72
+ context = DefinitionContext.new(:test, dir)
73
+ error = assert_raises(DataFileError) { context.load('*.rb') }
74
+ assert_match /has a syntax error/, error.message
75
+ end
76
+ end
77
+
78
+ def test_load_generic_error
79
+ Dir.mktmpdir do |dir|
80
+ File.write(File.join(dir, 'error.rb'), "raise 'boom'")
81
+ context = DefinitionContext.new(:test, dir)
82
+ error = assert_raises(DataFileError) { context.load('*.rb') }
83
+ assert_match /couldn't be loaded:\nboom/, error.message
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+
90
+ # File: ./data_test.rb
91
+
92
+ require_relative 'test_helper'
93
+ module Hq
94
+ class TestHqData < Minitest::Test
95
+ def setup
96
+ Hq.reload # Clear collections before each test
97
+ # Reset data path to default
98
+ Hq.configure(data_path: 'data')
99
+ end
100
+
101
+ def teardown
102
+ Hq.reload # Clean up after each test
103
+ end
104
+
105
+ def test_configure
106
+ Hq.configure(data_path: 'custom_data')
107
+ assert_equal 'custom_data', Hq.instance_variable_get(:@data_path)
108
+ end
109
+
110
+ def test_define_and_query
111
+ Hq.define :test_model do
112
+ from_array([{ id: 1 }])
113
+ end
114
+ assert_instance_of Query, Hq.query(:test_model)
115
+ end
116
+
117
+ def test_undefined_model_raises
118
+ error = assert_raises(RuntimeError) { Hq.query(:undefined) }
119
+ assert_equal "Collection undefined not defined. Use Hq.define :undefined to define it.", error.message
120
+ end
121
+
122
+ def test_reload
123
+ Hq.define :test_model do
124
+ from_array([])
125
+ end
126
+ Hq.reload
127
+ assert_empty Hq.defined_models
128
+ end
129
+
130
+ def test_defined_models
131
+ Hq.define :model1 do
132
+ from_array([])
133
+ end
134
+ Hq.define :model2 do
135
+ from_array([])
136
+ end
137
+ assert_equal [:model1, :model2], Hq.defined_models.sort
138
+ end
139
+
140
+ def test_global_helper
141
+ Hq.define :test_model do
142
+ from_array([{ id: 1 }])
143
+ end
144
+
145
+ # Test the global hq() helper method
146
+ assert_instance_of Query, hq(:test_model)
147
+ end
148
+ end
149
+
150
+
151
+
152
+
153
+
154
+
155
+
156
+
157
+
158
+ class TestPagination < Minitest::Test
159
+ def setup
160
+ Hq.reload
161
+ # Create a collection with 23 items for thorough pagination testing
162
+ Hq.define :paginated_posts do
163
+ from_array((1..23).map do |i|
164
+ {
165
+ id: i,
166
+ title: "Post #{i}",
167
+ content: "Content for post #{i}",
168
+ priority: i % 3, # 0, 1, 2, 0, 1, 2, ...
169
+ published: i <= 20 # First 20 are published
170
+ }
171
+ end)
172
+
173
+ scope({
174
+ published: -> { where(published: true) },
175
+ by_priority: ->(p) { where(priority: p) }
176
+ })
177
+
178
+ item({
179
+ slug: -> { "post-#{self[:id]}" },
180
+ excerpt: -> { "#{self[:content][0..20]}..." }
181
+ })
182
+ end
183
+ @query = Hq.query(:paginated_posts)
184
+ end
185
+
186
+ def teardown
187
+ Hq.reload
188
+ end
189
+
190
+ def test_basic_pagination
191
+ pages = @query.paginate(per_page: 5)
192
+
193
+ # Should have 5 pages (23 items / 5 per page = 4.6, rounded up to 5)
194
+ assert_equal 5, pages.length
195
+
196
+ # Test first page
197
+ first_page = pages.first
198
+ assert_instance_of Hq::Page, first_page
199
+ assert_equal 5, first_page.items.size
200
+ assert_equal 1, first_page.current_page
201
+ assert_equal 5, first_page.total_pages
202
+ assert_equal 23, first_page.total_items
203
+ assert_nil first_page.prev_page
204
+ assert_equal 2, first_page.next_page
205
+ assert first_page.is_first_page
206
+ refute first_page.is_last_page
207
+
208
+ # Test middle page
209
+ middle_page = pages[2] # Page 3
210
+ assert_equal 5, middle_page.items.size
211
+ assert_equal 3, middle_page.current_page
212
+ assert_equal 2, middle_page.prev_page
213
+ assert_equal 4, middle_page.next_page
214
+ refute middle_page.is_first_page
215
+ refute middle_page.is_last_page
216
+
217
+ # Test last page (should have only 3 items: 21, 22, 23)
218
+ last_page = pages.last
219
+ assert_equal 3, last_page.items.size
220
+ assert_equal 5, last_page.current_page
221
+ assert_equal 4, last_page.prev_page
222
+ assert_nil last_page.next_page
223
+ refute last_page.is_first_page
224
+ assert last_page.is_last_page
225
+ end
226
+
227
+ def test_pagination_with_chaining
228
+ # Test pagination with where conditions
229
+ published_pages = @query.published.paginate(per_page: 8)
230
+
231
+ # Should have 3 pages (20 published items / 8 per page = 2.5, rounded up to 3)
232
+ assert_equal 3, published_pages.length
233
+ assert_equal 20, published_pages.first.total_items
234
+
235
+ # Debug the descending order issue
236
+ ordered_query = @query.order(:id, desc: true)
237
+
238
+ ordered_pages = ordered_query.paginate(per_page: 6)
239
+ first_page_items = ordered_pages.first.items
240
+ expected_ids = [23, 22, 21, 20, 19, 18]
241
+ assert_equal expected_ids, first_page_items.map { |item| item[:id] }
242
+
243
+ # Test pagination with scopes
244
+ priority_pages = @query.by_priority(1).paginate(per_page: 3)
245
+ # Priority 1 items: 1, 4, 7, 10, 13, 16, 19, 22 (8 items total)
246
+ assert_equal 3, priority_pages.length # 8 items / 3 per page = 2.67, rounded up to 3
247
+ assert_equal 8, priority_pages.first.total_items
248
+ end
249
+
250
+ def test_single_page_collection
251
+ # Apply limit first, then paginate the limited results
252
+ limited_query = @query.limit(5)
253
+ small_pages = limited_query.paginate(per_page: 10)
254
+
255
+ assert_equal 1, small_pages.length
256
+ page = small_pages.first
257
+ assert_equal 5, page.items.size
258
+ assert_equal 1, page.current_page
259
+ assert_equal 1, page.total_pages
260
+ assert_equal 5, page.total_items
261
+ assert_nil page.prev_page
262
+ assert_nil page.next_page
263
+ assert page.is_first_page
264
+ assert page.is_last_page
265
+ end
266
+
267
+ def test_empty_collection_pagination
268
+ empty_pages = @query.where(id: 999).paginate(per_page: 5)
269
+
270
+ assert_equal 1, empty_pages.length
271
+ page = empty_pages.first
272
+ assert_equal [], page.items
273
+ assert_equal 1, page.current_page
274
+ assert_equal 1, page.total_pages
275
+ assert_equal 0, page.total_items
276
+ end
277
+
278
+ def test_pagination_error_handling
279
+ # Test with invalid per_page values
280
+ error = assert_raises(ArgumentError) { @query.paginate(per_page: 0) }
281
+ assert_match /per_page must be positive/, error.message
282
+
283
+ error = assert_raises(ArgumentError) { @query.paginate(per_page: -5) }
284
+ assert_match /per_page must be positive/, error.message
285
+ end
286
+
287
+ def test_page_object_methods
288
+ pages = @query.paginate(per_page: 7)
289
+ page = pages[1] # Second page
290
+
291
+ # Test item methods work on paginated items
292
+ first_item = page.items.first
293
+ assert_equal "post-#{first_item[:id]}", first_item.slug
294
+ assert_match /Content for post \d+\.\.\./, first_item.excerpt
295
+
296
+ # Test posts alias
297
+ assert_equal page.items, page.posts
298
+
299
+ # Test to_h method
300
+ page_hash = page.to_h
301
+ expected_keys = [:items, :current_page, :total_pages, :total_items,
302
+ :prev_page, :next_page, :is_first_page, :is_last_page]
303
+ assert_equal expected_keys.sort, page_hash.keys.sort
304
+ assert_equal page.current_page, page_hash[:current_page]
305
+ assert_equal page.total_items, page_hash[:total_items]
306
+ assert_equal page.is_first_page, page_hash[:is_first_page]
307
+ end
308
+
309
+ def test_pagination_maintains_order
310
+ # Test that pagination preserves ordering - but our implementation only supports single field sorting
311
+ ordered_pages = @query.order(:id).paginate(per_page: 6)
312
+
313
+ all_items = []
314
+ ordered_pages.each { |page| all_items.concat(page.items) }
315
+
316
+ # Should maintain ID ordering across pages (1,2,3,4,5,6,7,8,9,10...)
317
+ ids = all_items.map { |item| item[:id] }
318
+ assert_equal (1..23).to_a, ids
319
+ end
320
+
321
+ def test_pagination_edge_cases
322
+ # Test with per_page exactly equal to total items
323
+ exact_pages = @query.limit(10).paginate(per_page: 10)
324
+ assert_equal 1, exact_pages.length
325
+ assert_equal 10, exact_pages.first.items.size
326
+
327
+ # Test with per_page larger than total items - limit to 3 items first
328
+ limited_query = @query.limit(3)
329
+ large_pages = limited_query.paginate(per_page: 10)
330
+ assert_equal 1, large_pages.length
331
+ assert_equal 3, large_pages.first.items.size
332
+ assert_equal 3, large_pages.first.total_items # Total should also be 3
333
+
334
+ # Test with per_page = 1 (maximum pages) - limit to 5 items first
335
+ limited_query_5 = @query.limit(5)
336
+ single_pages = limited_query_5.paginate(per_page: 1)
337
+ assert_equal 5, single_pages.length
338
+ assert_equal 5, single_pages.first.total_items # Total should be 5
339
+ single_pages.each_with_index do |page, index|
340
+ assert_equal 1, page.items.size
341
+ assert_equal index + 1, page.current_page
342
+ assert_equal 5, page.total_pages
343
+ end
344
+ end
345
+ end
346
+
347
+ end
348
+
349
+
350
+ # File: ./edge_test.rb
351
+
352
+ require_relative 'test_helper'
353
+ class TestEdgeCases < Minitest::Test
354
+ def setup
355
+ Hq.reload
356
+ end
357
+
358
+ def teardown
359
+ Hq.reload
360
+ end
361
+
362
+ def test_empty_collections
363
+ Hq.define :empty do
364
+ from_array([])
365
+ end
366
+
367
+ query = Hq.query(:empty)
368
+ assert_equal 0, query.size
369
+ assert_equal [], query.to_a
370
+ assert_nil query.first
371
+ assert_nil query.last
372
+ assert_equal [], query.where(id: 1).to_a
373
+ assert_equal [], query.order(:id).to_a
374
+ assert_equal [], query.limit(5).to_a
375
+ assert_equal [], query.offset(2).to_a
376
+ end
377
+
378
+ def test_single_item_collection
379
+ Hq.define :single do
380
+ from_array([{ id: 1, title: 'Only One' }])
381
+ end
382
+
383
+ query = Hq.query(:single)
384
+ assert_equal 1, query.count
385
+ assert_equal 1, query.first[:id]
386
+ assert_equal 1, query.last[:id]
387
+ assert_equal [1], query.all.map { |item| item[:id] }
388
+ end
389
+
390
+ def test_collections_with_duplicate_data
391
+ Hq.define :duplicates do
392
+ from_array([
393
+ { id: 1, name: 'John' },
394
+ { id: 2, name: 'John' },
395
+ { id: 3, name: 'John' }
396
+ ])
397
+ end
398
+
399
+ query = Hq.query(:duplicates)
400
+ johns = query.where(name: 'John')
401
+ assert_equal 3, johns.size
402
+ assert_equal [1, 2, 3], johns.map { |item| item[:id] }.sort
403
+ end
404
+
405
+ def test_collections_with_all_nils
406
+ Hq.define :nils do
407
+ from_array([
408
+ { id: 1, value: nil },
409
+ { id: 2, value: nil },
410
+ { id: 3, value: nil }
411
+ ])
412
+ end
413
+
414
+ query = Hq.query(:nils)
415
+ results = query.where(value: { exists: false })
416
+ assert_equal 3, results.size
417
+
418
+ results = query.where(value: { empty: true })
419
+ assert_equal 3, results.size
420
+ end
421
+
422
+ def test_large_offset_and_limit
423
+ Hq.define :numbers do
424
+ from_array((1..100).map { |n| { id: n, value: n * 2 } })
425
+ end
426
+
427
+ query = Hq.query(:numbers)
428
+
429
+ # Test large offset beyond collection size
430
+ results = query.offset(200)
431
+ assert_equal [], results.to_a
432
+
433
+ # Test large limit on small remaining items
434
+ results = query.offset(95).limit(10)
435
+ assert_equal 5, results.size # Only 5 items left after offset 95
436
+
437
+ # Test zero limit
438
+ results = query.limit(0)
439
+ assert_equal [], results.to_a
440
+
441
+ # Test negative offset (should be treated as 0)
442
+ results = query.offset(-5).limit(3)
443
+ assert_equal 3, results.size
444
+ end
445
+
446
+ def test_complex_chaining_with_no_results
447
+ Hq.define :complex do
448
+ from_array([
449
+ { id: 1, category: 'A', published: true, views: 100 },
450
+ { id: 2, category: 'B', published: false, views: 200 }
451
+ ])
452
+ end
453
+
454
+ query = Hq.query(:complex)
455
+
456
+ # Chain filters that result in no matches
457
+ results = query.where(category: 'A').where(published: false)
458
+ assert_equal [], results.to_a
459
+
460
+ results = query.where(views: { greater_than: 300 })
461
+ assert_equal [], results.to_a
462
+
463
+ results = query.where(id: [10, 20, 30])
464
+ assert_equal [], results.to_a
465
+ end
466
+
467
+ def test_scope_with_parameters_edge_cases
468
+ Hq.define :parameterized do
469
+ from_array([
470
+ { id: 1, score: 85 },
471
+ { id: 2, score: 92 },
472
+ { id: 3, score: 78 },
473
+ { id: 4, score: 95 }
474
+ ])
475
+
476
+ scope({
477
+ by_score: ->(min_score) { where(score: { greater_than_or_equal: min_score }) },
478
+ top_n: ->(n) { order(:score, desc: true).limit(n) },
479
+ score_range: ->(min, max) { where(score: { from: min, to: max }) }
480
+ })
481
+ end
482
+
483
+ query = Hq.query(:parameterized)
484
+
485
+ results = query.by_score(100)
486
+ assert_equal [], results.to_a
487
+
488
+ results = query.by_score(0)
489
+ assert_equal 4, results.size
490
+
491
+ results = query.top_n(0)
492
+ assert_equal [], results.to_a
493
+
494
+ results = query.score_range(90, 100)
495
+ assert_equal [2, 4].sort, results.map { |item| item[:id] }.sort # <-- Corrected
496
+ end
497
+
498
+ def test_method_name_conflicts
499
+ Hq.define :conflicts do
500
+ from_array([{ id: 1, hash: 'test_hash', class: 'test_class' }])
501
+ end
502
+
503
+ query = Hq.query(:conflicts)
504
+ item = query.first
505
+
506
+ # Access conflicting keys via [] - they should NOT have method access
507
+ assert_equal 'test_hash', item[:hash]
508
+ assert_equal 'test_class', item[:class]
509
+
510
+ # Check that conflicting keys do NOT respond to method calls
511
+ # These should be FALSE because we skip defining methods for conflicting keys
512
+ assert item.respond_to?(:hash) # Should be false - we don't define this method
513
+ assert item.respond_to?(:class) # Should be false - we don't define this method
514
+
515
+ # Check built-in methods still work
516
+ assert item.object_id.is_a?(Integer)
517
+ assert item.hash.is_a?(Integer) # Built-in hash method works
518
+ assert_equal Hq::InstanceWrapper, item.class # Built-in class method works
519
+
520
+ # Verify we can't access the hash values as methods (should raise NoMethodError)
521
+ # But since respond_to? returns false, we don't need to test this
522
+ end
523
+
524
+
525
+
526
+ def test_ordering_with_mixed_types
527
+ # Test ordering when values are of different types (should handle gracefully)
528
+ Hq.define :mixed do
529
+ from_array([
530
+ { id: 1, value: 'string' },
531
+ { id: 2, value: 42 },
532
+ { id: 3, value: nil },
533
+ { id: 4, value: Date.new(2024, 1, 1) }
534
+ ])
535
+ end
536
+
537
+ query = Hq.query(:mixed)
538
+
539
+ # Should not raise an error, even with mixed types
540
+ results = query.order(:value)
541
+ assert_equal 4, results.size
542
+
543
+ # nil should be at the end
544
+ assert_equal 3, results.last[:id]
545
+ end
546
+ end
547
+
548
+
549
+ # File: ./instance_test.rb
550
+
551
+ require_relative 'test_helper'
552
+
553
+ module Hq
554
+ class TestInstanceWrapper < Minitest::Test
555
+ def setup
556
+ @hash = { id: 1, title: 'Test', content: 'Long content here for testing purpose', views: 100, published_at: Date.new(2024, 1, 1) }.freeze
557
+
558
+ Hq.present :test_model do
559
+ def excerpt(words = 2)
560
+ self[:content].split.take(words).join(' ') + '...'
561
+ end
562
+
563
+ def title_upper
564
+ self[:title].upcase
565
+ end
566
+
567
+ def formatted_date
568
+ self[:published_at]&.strftime("%B %d, %Y")
569
+ end
570
+
571
+ def view_category
572
+ self[:views] && self[:views] > 150 ? 'popular' : 'standard'
573
+ end
574
+
575
+ def word_count
576
+ self[:content].split.size
577
+ end
578
+
579
+ def summary(max_words = 10)
580
+ words = self[:content].split.take(max_words)
581
+ "#{words.join(' ')}#{words.size >= max_words ? '...' : ''}"
582
+ end
583
+ end
584
+
585
+ @wrapper = Hq::InstanceWrapper.new(@hash, :test_model)
586
+ end
587
+
588
+ def teardown
589
+ Hq.reload
590
+ end
591
+
592
+ def test_dot_notation_access_for_hash_keys
593
+ assert_equal @hash[:id], @wrapper.id
594
+ assert_equal @hash[:title], @wrapper.title
595
+ assert_equal @hash[:content], @wrapper.content
596
+ assert_equal @hash[:views], @wrapper.views
597
+ assert_equal @hash[:published_at], @wrapper.published_at
598
+ assert @wrapper.respond_to?(:id)
599
+ assert @wrapper.respond_to?(:title)
600
+ refute @wrapper.respond_to?(:some_random_method_that_does_not_exist)
601
+ end
602
+
603
+ def test_dot_notation_access_for_presenter_methods
604
+ assert_equal 'Long content...', @wrapper.excerpt
605
+ assert_equal 'Long...', @wrapper.excerpt(1)
606
+ assert_equal 'TEST', @wrapper.title_upper
607
+ assert @wrapper.respond_to?(:excerpt)
608
+ assert @wrapper.respond_to?(:title_upper)
609
+ end
610
+
611
+ def test_hash_access
612
+ assert_equal 1, @wrapper[:id]
613
+ assert_equal 'Test', @wrapper[:title]
614
+ assert_equal 'Test', @wrapper['title']
615
+ assert_equal 100, @wrapper[:views]
616
+ assert_nil @wrapper[:non_existent]
617
+ end
618
+
619
+ def test_symbol_string_key_normalization
620
+ assert_equal 'Test', @wrapper[:title]
621
+ assert_equal 'Test', @wrapper['title']
622
+ assert_equal 1, @wrapper[:id]
623
+ assert_equal 1, @wrapper['id']
624
+ end
625
+
626
+ def test_immutability
627
+ error = assert_raises(RuntimeError) { @wrapper[:id] = 2 }
628
+ assert_match /immutable/, error.message
629
+ error = assert_raises(RuntimeError) { @wrapper['title'] = 'New Title' }
630
+ assert_match /immutable/, error.message
631
+ end
632
+
633
+ def test_presenter_methods
634
+ assert_equal 'Long content...', @wrapper.excerpt
635
+ assert_equal 'Long...', @wrapper.excerpt(1)
636
+ assert_equal 'Long content here...', @wrapper.excerpt(3)
637
+ assert_equal 'TEST', @wrapper.title_upper
638
+ assert_equal 'January 01, 2024', @wrapper.formatted_date
639
+ assert_equal 'standard', @wrapper.view_category
640
+ assert_equal 6, @wrapper.word_count
641
+ end
642
+
643
+ def test_presenter_methods_with_parameters
644
+ assert_equal 'Long content...', @wrapper.excerpt(2)
645
+ assert_equal 'Long content here for...', @wrapper.excerpt(4)
646
+ assert_equal 'Long content here for testing...', @wrapper.summary(5)
647
+ assert_equal 'Long content here for testing purpose', @wrapper.summary(10)
648
+ end
649
+
650
+ def test_memoization
651
+ result1 = @wrapper.excerpt
652
+ result2 = @wrapper.excerpt
653
+ assert_equal result1, result2
654
+ assert_equal 'Long content...', result1
655
+ result3 = @wrapper.excerpt(1)
656
+ assert_equal 'Long...', result3
657
+ refute_equal result1, result3
658
+ result4 = @wrapper.excerpt(1)
659
+ assert_equal result3, result4
660
+ date1 = @wrapper.formatted_date
661
+ date2 = @wrapper.formatted_date
662
+ assert_equal date1, date2
663
+ assert_equal 'January 01, 2024', date1
664
+ end
665
+
666
+ def test_presenter_method_with_nil_handling
667
+ nil_hash = { id: 1, title: nil, content: nil, published_at: nil }.freeze
668
+
669
+ Hq.present :nil_test_model do
670
+ def safe_title
671
+ self[:title] || 'Untitled'
672
+ end
673
+
674
+ def safe_date
675
+ self[:published_at]&.strftime("%Y-%m-%d") || 'No date'
676
+ end
677
+
678
+ def content_length
679
+ (self[:content] || '').length
680
+ end
681
+ end
682
+
683
+ wrapper = InstanceWrapper.new(nil_hash, :nil_test_model)
684
+ assert_equal 'Untitled', wrapper.safe_title
685
+ assert_equal 'No date', wrapper.safe_date
686
+ assert_equal 0, wrapper.content_length
687
+ end
688
+
689
+ def test_presenter_method_error
690
+ Hq.present :error_test_model do
691
+ def bad
692
+ raise 'boom'
693
+ end
694
+
695
+ def another_bad
696
+ undefined_method_call
697
+ end
698
+ end
699
+
700
+ wrapper = Hq::InstanceWrapper.new(@hash, :error_test_model)
701
+ error = assert_raises(Hq::ItemMethodError) { wrapper.bad }
702
+ assert_match /Error executing presenter method :bad.*boom/, error.message
703
+ error = assert_raises(Hq::ItemMethodError) { wrapper.another_bad }
704
+ assert_match /Error executing presenter method :another_bad/, error.message
705
+ end
706
+
707
+ def test_hash_methods
708
+ expected_keys = [:id, :title, :content, :views, :published_at]
709
+ assert_equal expected_keys.sort, @wrapper.to_h.keys.sort
710
+ assert_equal @hash.values.size, @wrapper.to_h.values.size
711
+ assert_equal @hash, @wrapper.to_h
712
+ assert_equal @hash, @wrapper.to_hash
713
+ end
714
+
715
+ def test_method_missing_for_hash
716
+ assert_equal 1, @wrapper[:id]
717
+ assert_equal 'Test', @wrapper[:title]
718
+ assert_equal 5, @wrapper.to_h.size
719
+ assert @wrapper.respond_to?(:id)
720
+ assert_equal 1, @wrapper.id
721
+ assert @wrapper.to_h.has_key?(:id)
722
+ assert @wrapper.to_h.has_key?(:title)
723
+ refute @wrapper.to_h.has_key?(:non_existent)
724
+ assert @wrapper.to_h.include?(:id)
725
+ assert_equal @hash.length, @wrapper.to_h.length
726
+ assert_equal false, @wrapper.to_h.empty?
727
+ assert @wrapper.respond_to?(:to_h)
728
+ assert @wrapper.respond_to?(:[])
729
+ end
730
+
731
+ def test_respond_to_for_dynamic_methods
732
+ assert @wrapper.respond_to?(:id)
733
+ assert @wrapper.respond_to?(:title)
734
+ assert @wrapper.respond_to?(:excerpt)
735
+ assert @wrapper.respond_to?(:title_upper)
736
+ assert @wrapper.respond_to?(:formatted_date)
737
+ refute @wrapper.to_h.has_key?(:non_existent)
738
+ assert @wrapper.respond_to?(:class)
739
+ assert @wrapper.respond_to?(:object_id)
740
+ assert @wrapper.respond_to?(:to_h)
741
+ refute @wrapper.respond_to?(:keys)
742
+ refute @wrapper.respond_to?(:values)
743
+ refute @wrapper.respond_to?(:size)
744
+ end
745
+
746
+ def test_inspect
747
+ assert_match /#<Hq::Instance {.*}>/, @wrapper.inspect
748
+ assert_match /id.*1/, @wrapper.inspect
749
+ assert_match /title.*"Test"/, @wrapper.inspect
750
+ end
751
+
752
+ def test_complex_presenter_methods
753
+ complex_hash = {
754
+ tags: ['ruby', 'web', 'programming'],
755
+ metadata: { author: 'John', category: 'tech' },
756
+ scores: [85, 90, 78, 92]
757
+ }.freeze
758
+
759
+ Hq.present :complex_test_model do
760
+ def tag_list
761
+ self[:tags].join(', ')
762
+ end
763
+
764
+ def author_name
765
+ self[:metadata][:author]
766
+ end
767
+
768
+ def average_score
769
+ self[:scores].sum.to_f / self[:scores].length
770
+ end
771
+
772
+ def top_score
773
+ self[:scores].max
774
+ end
775
+
776
+ def tag_count
777
+ self[:tags].length
778
+ end
779
+ end
780
+
781
+ wrapper = InstanceWrapper.new(complex_hash, :complex_test_model)
782
+ assert_equal 'ruby, web, programming', wrapper.tag_list
783
+ assert_equal 'John', wrapper.author_name
784
+ assert_equal 86.25, wrapper.average_score
785
+ assert_equal 92, wrapper.top_score
786
+ assert_equal 3, wrapper.tag_count
787
+ end
788
+ end
789
+ end
790
+
791
+
792
+ # File: ./paginate_test.rb
793
+
794
+ require_relative 'test_helper'
795
+
796
+ class TestPagination < Minitest::Test
797
+ def setup
798
+ Hq.reload
799
+ Hq.define :paginated_posts do
800
+ from_array((1..23).map do |i|
801
+ {
802
+ id: i,
803
+ title: "Post #{i}",
804
+ content: "Content for post #{i}",
805
+ priority: i % 3,
806
+ published: i <= 20
807
+ }
808
+ end)
809
+
810
+ scope({
811
+ published: -> { where(published: true) },
812
+ by_priority: ->(p) { where(priority: p) }
813
+ })
814
+ end
815
+
816
+ Hq.present :paginated_posts do
817
+ def slug
818
+ "post-#{self[:id]}"
819
+ end
820
+
821
+ def excerpt
822
+ "#{self[:content][0..20]}..."
823
+ end
824
+ end
825
+
826
+ @query = Hq.query(:paginated_posts)
827
+ end
828
+
829
+ def teardown
830
+ Hq.reload
831
+ end
832
+
833
+ def test_basic_pagination
834
+ pages = @query.paginate(per_page: 5)
835
+
836
+ assert_equal 5, pages.length
837
+
838
+ first_page = pages.first
839
+ assert_instance_of Hq::Page, first_page
840
+ assert_equal 5, first_page.items.size
841
+ assert_equal 1, first_page.current_page
842
+ assert_equal 5, first_page.total_pages
843
+ assert_equal 23, first_page.total_items
844
+ assert_nil first_page.prev_page
845
+ assert_equal 2, first_page.next_page
846
+ assert first_page.is_first_page
847
+ refute first_page.is_last_page
848
+
849
+ middle_page = pages[2]
850
+ assert_equal 5, middle_page.items.size
851
+ assert_equal 3, middle_page.current_page
852
+ assert_equal 2, middle_page.prev_page
853
+ assert_equal 4, middle_page.next_page
854
+ refute middle_page.is_first_page
855
+ refute middle_page.is_last_page
856
+
857
+ last_page = pages.last
858
+ assert_equal 3, last_page.items.size
859
+ assert_equal 5, last_page.current_page
860
+ assert_equal 4, last_page.prev_page
861
+ assert_nil last_page.next_page
862
+ refute last_page.is_first_page
863
+ assert last_page.is_last_page
864
+ end
865
+
866
+ def test_pagination_with_chaining
867
+ published_pages = @query.published.paginate(per_page: 8)
868
+
869
+ assert_equal 3, published_pages.length
870
+ assert_equal 20, published_pages.first.total_items
871
+
872
+ ordered_query = @query.order(:id, desc: true)
873
+
874
+ ordered_pages = ordered_query.paginate(per_page: 6)
875
+ first_page_items = ordered_pages.first.items
876
+ expected_ids = [23, 22, 21, 20, 19, 18]
877
+ assert_equal expected_ids, first_page_items.map { |item| item[:id] }
878
+
879
+ priority_pages = @query.by_priority(1).paginate(per_page: 3)
880
+ assert_equal 3, priority_pages.length
881
+ assert_equal 8, priority_pages.first.total_items
882
+ end
883
+
884
+ def test_single_page_collection
885
+ limited_query = @query.limit(5)
886
+ small_pages = limited_query.paginate(per_page: 10)
887
+
888
+ assert_equal 1, small_pages.length
889
+ page = small_pages.first
890
+ assert_equal 5, page.items.size
891
+ assert_equal 1, page.current_page
892
+ assert_equal 1, page.total_pages
893
+ assert_equal 5, page.total_items
894
+ assert_nil page.prev_page
895
+ assert_nil page.next_page
896
+ assert page.is_first_page
897
+ assert page.is_last_page
898
+ end
899
+
900
+ def test_empty_collection_pagination
901
+ empty_pages = @query.where(id: 999).paginate(per_page: 5)
902
+
903
+ assert_equal 1, empty_pages.length
904
+ page = empty_pages.first
905
+ assert_equal [], page.items
906
+ assert_equal 1, page.current_page
907
+ assert_equal 1, page.total_pages
908
+ assert_equal 0, page.total_items
909
+ end
910
+
911
+ def test_pagination_error_handling
912
+ error = assert_raises(ArgumentError) { @query.paginate(per_page: 0) }
913
+ assert_match /per_page must be positive/, error.message
914
+
915
+ error = assert_raises(ArgumentError) { @query.paginate(per_page: -5) }
916
+ assert_match /per_page must be positive/, error.message
917
+ end
918
+
919
+ def test_page_object_methods
920
+ pages = @query.paginate(per_page: 7)
921
+ page = pages[1]
922
+
923
+ first_item = page.items.first
924
+ assert_equal "post-#{first_item[:id]}", first_item.slug
925
+ assert_match /Content for post \d+\.\.\./, first_item.excerpt
926
+
927
+ assert_equal page.items, page.posts
928
+
929
+ page_hash = page.to_h
930
+ expected_keys = [:items, :current_page, :total_pages, :total_items,
931
+ :prev_page, :next_page, :is_first_page, :is_last_page]
932
+ assert_equal expected_keys.sort, page_hash.keys.sort
933
+ assert_equal page.current_page, page_hash[:current_page]
934
+ assert_equal page.total_items, page_hash[:total_items]
935
+ assert_equal page.is_first_page, page_hash[:is_first_page]
936
+ end
937
+
938
+ def test_pagination_maintains_order
939
+ ordered_pages = @query.order(:id).paginate(per_page: 6)
940
+
941
+ all_items = []
942
+ ordered_pages.each { |page| all_items.concat(page.items) }
943
+
944
+ ids = all_items.map { |item| item[:id] }
945
+ assert_equal (1..23).to_a, ids
946
+ end
947
+
948
+ def test_pagination_edge_cases
949
+ exact_pages = @query.limit(10).paginate(per_page: 10)
950
+ assert_equal 1, exact_pages.length
951
+ assert_equal 10, exact_pages.first.items.size
952
+
953
+ limited_query = @query.limit(3)
954
+ large_pages = limited_query.paginate(per_page: 10)
955
+ assert_equal 1, large_pages.length
956
+ assert_equal 3, large_pages.first.items.size
957
+ assert_equal 3, large_pages.first.total_items
958
+
959
+ limited_query_5 = @query.limit(5)
960
+ single_pages = limited_query_5.paginate(per_page: 1)
961
+ assert_equal 5, single_pages.length
962
+ assert_equal 5, single_pages.first.total_items
963
+ single_pages.each_with_index do |page, index|
964
+ assert_equal 1, page.items.size
965
+ assert_equal index + 1, page.current_page
966
+ assert_equal 5, page.total_pages
967
+ end
968
+ end
969
+ end
970
+
971
+
972
+ # File: ./query_test.rb
973
+
974
+ require_relative 'test_helper'
975
+
976
+ module Hq
977
+ class TestQuery < Minitest::Test
978
+ def setup
979
+ Hq.reload
980
+ Hq.define :posts do
981
+ from_array([
982
+ { id: 1, title: 'Post 1', published_at: Date.new(2024, 1, 1), featured: true, tags: ['ruby', 'web'], views: 100 },
983
+ { id: 2, title: 'Post 2', published_at: Date.new(2024, 2, 1), featured: false, tags: ['tools'], views: 200 },
984
+ { id: 3, title: 'Post 3', published_at: Date.new(2023, 12, 1), featured: true, tags: ['ruby'], views: 150 },
985
+ { id: 4, title: nil, published_at: nil, featured: nil, tags: [], views: nil }
986
+ ])
987
+
988
+ scope({
989
+ featured: -> { where(featured: true) },
990
+ recent: ->(n=2) { order(:published_at, desc: true).limit(n) },
991
+ tagged: ->(tag) { where(tags: { contains: tag }) }
992
+ })
993
+ end
994
+
995
+ Hq.present :posts do
996
+ def title_upper
997
+ self[:title]&.upcase
998
+ end
999
+
1000
+ def view_category
1001
+ self[:views] && self[:views] > 150 ? 'popular' : 'standard'
1002
+ end
1003
+ end
1004
+
1005
+ @query = Hq.query(:posts)
1006
+ end
1007
+
1008
+ def teardown
1009
+ Hq.reload
1010
+ end
1011
+
1012
+ def test_all
1013
+ assert_equal 4, @query.all.size
1014
+ assert @query.all.all? { |item| item.is_a?(InstanceWrapper) }
1015
+ end
1016
+
1017
+ def test_first_and_last
1018
+ assert_equal 1, @query.first[:id]
1019
+ assert_equal 4, @query.last[:id]
1020
+ end
1021
+
1022
+ def test_count
1023
+ assert_equal 4, @query.count
1024
+ end
1025
+
1026
+ def test_find_by
1027
+ post = @query.find_by(id: 2)
1028
+ assert_equal 'Post 2', post[:title]
1029
+ end
1030
+
1031
+ def test_where_equality
1032
+ query = @query.where(featured: true)
1033
+ assert_equal [1, 3], query.map { |p| p[:id] }.sort
1034
+ end
1035
+
1036
+ def test_where_hash_conditions
1037
+ results = @query.where(title: { contains: 'Post' })
1038
+ assert_equal 3, results.size
1039
+
1040
+ results = @query.where(published_at: { greater_than: Date.new(2024, 1, 1) })
1041
+ assert_equal [2], results.map { |p| p[:id] }
1042
+
1043
+ results = @query.where(published_at: { less_than: Date.new(2024, 1, 1) })
1044
+ assert_equal [3], results.map { |p| p[:id] }
1045
+
1046
+ results = @query.where(published_at: { greater_than_or_equal: Date.new(2024, 1, 1) })
1047
+ assert_equal [1, 2], results.map { |p| p[:id] }.sort
1048
+
1049
+ results = @query.where(published_at: { less_than_or_equal: Date.new(2024, 1, 1) })
1050
+ assert_equal [1, 3], results.map { |p| p[:id] }.sort
1051
+
1052
+ results = @query.where(published_at: { from: Date.new(2024, 1, 1), to: Date.new(2024, 2, 1) })
1053
+ assert_equal [1, 2], results.map { |p| p[:id] }.sort
1054
+
1055
+ results = @query.where(published_at: { from: Date.new(2024, 1, 1) })
1056
+ assert_equal [1, 2], results.map { |p| p[:id] }.sort
1057
+
1058
+ results = @query.where(published_at: { to: Date.new(2024, 1, 1) })
1059
+ assert_equal [1, 3], results.map { |p| p[:id] }.sort
1060
+
1061
+ results = @query.where(id: { in: [1, 3] })
1062
+ assert_equal [1, 3], results.map { |p| p[:id] }.sort
1063
+
1064
+ results = @query.where(id: { not_in: [1, 3] })
1065
+ assert_equal [2, 4], results.map { |p| p[:id] }.sort
1066
+
1067
+ results = @query.where(featured: { not: true })
1068
+
1069
+ results = @query.where(tags: { empty: true })
1070
+ assert_equal [4], results.map { |p| p[:id] }
1071
+
1072
+ results = @query.where(tags: { empty: false })
1073
+ assert_equal [1, 2, 3], results.map { |p| p[:id] }.sort
1074
+
1075
+ results = @query.where(title: { exists: true })
1076
+ assert_equal 3, results.size
1077
+
1078
+ results = @query.where(title: { exists: false })
1079
+ assert_equal [4], results.map { |p| p[:id] }
1080
+
1081
+ results = @query.where(title: { starts_with: 'Post' })
1082
+ assert_equal 3, results.size
1083
+
1084
+ results = @query.where(title: { ends_with: '1' })
1085
+ assert_equal [1], results.map { |p| p[:id] }
1086
+ end
1087
+
1088
+ def test_where_array
1089
+ results = @query.where(id: [1, 3])
1090
+ assert_equal [1, 3], results.map { |p| p[:id] }.sort
1091
+ end
1092
+
1093
+ def test_where_range
1094
+ results = @query.where(published_at: Date.new(2024, 1, 1)..Date.new(2024, 12, 31))
1095
+ assert_equal [1, 2], results.map { |p| p[:id] }.sort
1096
+ end
1097
+
1098
+ def test_order_asc
1099
+ results = @query.order(:published_at)
1100
+ expected_order = [3, 1, 2, 4]
1101
+ assert_equal expected_order, results.map { |p| p[:id] }
1102
+ end
1103
+
1104
+ def test_order_desc
1105
+ results = @query.order(:published_at, desc: true)
1106
+ expected_order = [2, 1, 3, 4]
1107
+ assert_equal expected_order, results.map { |p| p[:id] }
1108
+ end
1109
+
1110
+ def test_limit_and_offset
1111
+ results = @query.order(:id).limit(2)
1112
+ assert_equal [1, 2], results.map { |p| p[:id] }
1113
+
1114
+ results = @query.order(:id).offset(2).limit(1)
1115
+ assert_equal [3], results.map { |p| p[:id] }
1116
+
1117
+ results = @query.order(:id).offset(1).limit(2)
1118
+ assert_equal [2, 3], results.map { |p| p[:id] }
1119
+
1120
+ results = @query.order(:id).offset(10)
1121
+ assert_equal [], results.map { |p| p[:id] }
1122
+ end
1123
+
1124
+ def test_chained_queries
1125
+ results = @query.where(featured: true).order(:published_at, desc: true).limit(1)
1126
+ assert_equal [1], results.map { |p| p[:id] }
1127
+
1128
+ results = @query.where(views: { greater_than: 120 }).where(featured: true)
1129
+ assert_equal [3], results.map { |p| p[:id] }
1130
+ end
1131
+
1132
+ def test_scopes
1133
+ featured = @query.featured.all
1134
+ assert_equal [1, 3], featured.map { |p| p[:id] }.sort
1135
+
1136
+ recent = @query.recent.all
1137
+ assert_equal [2, 1], recent.map { |p| p[:id] }
1138
+
1139
+ recent_3 = @query.recent(3)
1140
+ assert_equal [2, 1, 3], recent_3.map { |p| p[:id] }
1141
+
1142
+ tagged = @query.tagged('ruby')
1143
+ assert_equal [1, 3], tagged.map { |p| p[:id] }.sort
1144
+
1145
+ featured_ruby = @query.featured.tagged('ruby')
1146
+ assert_equal [1, 3], featured_ruby.map { |p| p[:id] }.sort
1147
+ end
1148
+ def test_scope_error_handling
1149
+ Hq.define :error_model do
1150
+ from_array([])
1151
+ scope({ bad: -> { raise 'boom' } })
1152
+ end
1153
+ query = Hq.query(:error_model)
1154
+ error = assert_raises(Hq::ScopeError) { query.bad }
1155
+ assert_match /Error in scope :bad for model :error_model/, error.message
1156
+ end
1157
+
1158
+ def test_enumerable
1159
+ assert_equal 4, @query.size
1160
+
1161
+ titles = @query.select { |p| p[:title] }.map { |p| p[:title] }
1162
+ assert_equal ['Post 1', 'Post 2', 'Post 3'], titles.sort
1163
+
1164
+ featured_ids = @query.select { |p| p[:featured] }.map { |p| p[:id] }
1165
+ assert_equal [1, 3], featured_ids.sort
1166
+
1167
+ assert @query.any? { |p| p[:featured] }
1168
+ assert @query.all? { |p| p[:id].is_a?(Integer) }
1169
+ end
1170
+
1171
+ def test_symbol_normalization_in_conditions
1172
+ results = @query.where(title: { contains: 'Post' })
1173
+ assert_equal 3, results.size
1174
+
1175
+ results = @query.where('id' => 1, :featured => true)
1176
+ assert_equal [1], results.map { |p| p[:id] }
1177
+ end
1178
+
1179
+ def test_complex_conditions
1180
+ results = @query.where(featured: false)
1181
+ assert_equal [2], results.map { |p| p[:id] }
1182
+
1183
+ results = @query.where(featured: nil)
1184
+ assert_equal [4], results.map { |p| p[:id] }
1185
+
1186
+ results = @query.where(tags: ['ruby', 'web'])
1187
+ assert_equal [1], results.map { |p| p[:id] }
1188
+
1189
+ results = @query.where(tags: ['tools'])
1190
+ assert_equal [2], results.map { |p| p[:id] }
1191
+ end
1192
+
1193
+ def test_or_where_hash
1194
+ results = @query.where(featured: true).or_where(views: { greater_than: 150 })
1195
+ assert_equal [1, 2, 3], results.map { |p| p.id }.sort
1196
+ end
1197
+
1198
+ def test_or_where_block
1199
+ results = @query.where { |q| q.where(id: 1).where(featured: false) }
1200
+ .or_where { |q| q.where(views: { greater_than_or_equal: 200 }) }
1201
+
1202
+ assert_equal [2], results.map { |p| p.id }
1203
+ end
1204
+
1205
+ def test_where_block_for_grouping
1206
+ results = @query.where(featured: true).where do |q|
1207
+ q.where(views: { less_than: 120 }).or_where(tags: { contains: 'ruby' })
1208
+ end
1209
+ assert_equal [1, 3], results.map { |p| p.id }.sort
1210
+ end
1211
+
1212
+ def test_negation_with_where_not_hash
1213
+ results = @query.where(featured: { not: true })
1214
+ assert_equal [2, 4], results.map { |p| p.id }.sort
1215
+ end
1216
+
1217
+ def test_negation_with_or_where_not_hash
1218
+ results = @query.where(featured: true).or_where(title: { not: 'Post 2' })
1219
+ assert_equal [1, 3, 4], results.map { |p| p.id }.sort
1220
+ end
1221
+
1222
+ def test_complex_and_or_not
1223
+ results = @query.where { |q| q.where(featured: true).where(views: { less_than_or_equal: 150 }) }
1224
+ .or_where { |q| q.where(tags: { contains: 'web' }).where(views: { less_than: 150 }) }
1225
+
1226
+ assert_equal [1, 3], results.map { |p| p.id }.sort
1227
+ end
1228
+ end
1229
+ end
1230
+
1231
+
1232
+ # File: ./relations_test.rb
1233
+
1234
+ require_relative 'test_helper'
1235
+
1236
+ class TestRelations < Minitest::Test
1237
+ def setup
1238
+ Hq.reload
1239
+
1240
+ Hq.define :authors do
1241
+ from_array([
1242
+ { id: 1, name: 'Alice' },
1243
+ { id: 2, name: 'Bob' },
1244
+ { id: 3, name: 'Charlie' }
1245
+ ])
1246
+ has_many :posts
1247
+ has_one :profile
1248
+ end
1249
+
1250
+ Hq.define :posts do
1251
+ from_array([
1252
+ { id: 101, title: 'Intro to Hq', author_id: 1, published: true, content: '...' },
1253
+ { id: 102, title: 'Advanced Ruby', author_id: 1, published: false, content: '...' },
1254
+ { id: 103, title: 'Web Development', author_id: 2, published: true, content: '...' },
1255
+ { id: 104, title: 'Orphaned Post', author_id: nil, published: true, content: '...' }
1256
+ ])
1257
+ belongs_to :author
1258
+ has_many :post_tags
1259
+ has_many :tags, through: :post_tags
1260
+ end
1261
+
1262
+ Hq.define :profiles do
1263
+ from_array([
1264
+ { id: 201, bio: 'Ruby Developer', author_id: 1 },
1265
+ { id: 202, bio: 'Web Enthusiast', author_id: 2 }
1266
+ ])
1267
+ belongs_to :author
1268
+ end
1269
+
1270
+ Hq.define :tags do
1271
+ from_array([
1272
+ { id: 301, name: 'ruby' },
1273
+ { id: 302, name: 'web' },
1274
+ { id: 303, name: 'performance' }
1275
+ ])
1276
+ has_many :post_tags
1277
+ has_many :posts, through: :post_tags
1278
+ end
1279
+
1280
+ Hq.define :post_tags do
1281
+ from_array([
1282
+ { id: 401, post_id: 101, tag_id: 301 },
1283
+ { id: 402, post_id: 101, tag_id: 302 },
1284
+ { id: 403, post_id: 102, tag_id: 301 },
1285
+ { id: 404, post_id: 103, tag_id: 302 }
1286
+ ])
1287
+ belongs_to :post
1288
+ belongs_to :tag
1289
+ end
1290
+
1291
+ Hq.define :users do
1292
+ from_array([ { user_pk: 501, username: 'admin' } ])
1293
+ has_many :articles, class_name: :articles, foreign_key: :creator_id, owner_key: :user_pk
1294
+ end
1295
+
1296
+ Hq.define :articles do
1297
+ from_array([
1298
+ { id: 601, title: 'User Article', creator_id: 501 }
1299
+ ])
1300
+ belongs_to :creator, class_name: :users, foreign_key: :creator_id, primary_key: :user_pk
1301
+ end
1302
+ end
1303
+
1304
+ def teardown
1305
+ Hq.reload
1306
+ end
1307
+
1308
+ def test_belongs_to_finds_parent
1309
+ post = hq(:posts).find_by(id: 101)
1310
+ author = post.author
1311
+
1312
+ assert_instance_of Hq::InstanceWrapper, author
1313
+ assert_equal 1, author.id
1314
+ assert_equal 'Alice', author.name
1315
+ assert post.respond_to?(:author)
1316
+ end
1317
+
1318
+ def test_belongs_to_returns_nil_if_fk_nil
1319
+ post = hq(:posts).find_by(id: 104)
1320
+ assert_nil post.author
1321
+ end
1322
+
1323
+ def test_belongs_to_returns_nil_if_parent_missing
1324
+ post = Hq::InstanceWrapper.new({ id: 105, title: 'Missing Author Post', author_id: 999 }, :posts)
1325
+ author = post.author
1326
+ assert_nil author
1327
+ end
1328
+
1329
+ def test_belongs_to_with_custom_keys
1330
+ article = hq(:articles).find_by(id: 601)
1331
+ creator = article.creator
1332
+
1333
+ assert_instance_of Hq::InstanceWrapper, creator
1334
+ assert_equal 'admin', creator.username
1335
+ assert article.respond_to?(:creator)
1336
+ end
1337
+
1338
+ def test_has_many_returns_query
1339
+ alice = hq(:authors).find_by(id: 1)
1340
+ posts_query = alice.posts
1341
+
1342
+ assert_instance_of Hq::Query, posts_query
1343
+ assert alice.respond_to?(:posts)
1344
+ end
1345
+
1346
+ def test_has_many_retrieves_children
1347
+ alice = hq(:authors).find_by(id: 1)
1348
+ posts = alice.posts.order(:id)
1349
+
1350
+ assert_equal 2, posts.size
1351
+ assert_equal [101, 102], posts.map { |p| p.id }
1352
+ assert_equal ['Intro to Hq', 'Advanced Ruby'], posts.map { |p| p.title }
1353
+ end
1354
+
1355
+ def test_has_many_allows_chaining
1356
+ alice = hq(:authors).find_by(id: 1)
1357
+ published_posts = alice.posts.where(published: true)
1358
+
1359
+ assert_equal 1, published_posts.size
1360
+ assert_equal 101, published_posts.first.id
1361
+ end
1362
+
1363
+ def test_has_many_returns_empty_query_if_no_children
1364
+ charlie = hq(:authors).find_by(id: 3)
1365
+ posts_query = charlie.posts
1366
+
1367
+ assert_instance_of Hq::Query, posts_query
1368
+ assert_equal 0, posts_query.size
1369
+ assert_equal [], posts_query.to_a
1370
+ end
1371
+
1372
+ def test_has_many_with_custom_keys
1373
+ user = hq(:users).first
1374
+ articles_query = user.articles
1375
+
1376
+ assert_instance_of Hq::Query, articles_query
1377
+ assert_equal 1, articles_query.size
1378
+ assert_equal 601, articles_query.first.id
1379
+ assert user.respond_to?(:articles)
1380
+ end
1381
+
1382
+ def test_has_one_finds_child
1383
+ alice = hq(:authors).find_by(id: 1)
1384
+ profile = alice.profile
1385
+
1386
+ assert_instance_of Hq::InstanceWrapper, profile
1387
+ assert_equal 201, profile.id
1388
+ assert_equal 'Ruby Developer', profile.bio
1389
+ assert alice.respond_to?(:profile)
1390
+ end
1391
+
1392
+ def test_has_one_returns_nil_if_no_child
1393
+ charlie = hq(:authors).find_by(id: 3)
1394
+ profile = charlie.profile
1395
+
1396
+ assert_nil profile
1397
+ end
1398
+
1399
+ def test_has_many_through_returns_query
1400
+ post = hq(:posts).find_by(id: 101)
1401
+ tags_query = post.tags
1402
+
1403
+ assert_instance_of Hq::Query, tags_query
1404
+ assert post.respond_to?(:tags)
1405
+ end
1406
+
1407
+ def test_has_many_through_retrieves_targets
1408
+ post = hq(:posts).find_by(id: 101)
1409
+ tags = post.tags.order(:id)
1410
+
1411
+ assert_equal 2, tags.size
1412
+ assert_equal [301, 302], tags.map { |t| t.id }
1413
+ assert_equal ['ruby', 'web'], tags.map { |t| t.name }
1414
+ end
1415
+
1416
+ def test_has_many_through_allows_chaining
1417
+ post = hq(:posts).find_by(id: 101)
1418
+ ruby_tag = post.tags.where(name: 'ruby')
1419
+
1420
+ assert_equal 1, ruby_tag.size
1421
+ assert_equal 301, ruby_tag.first.id
1422
+ end
1423
+
1424
+ def test_has_many_through_returns_empty_query_if_no_targets
1425
+ post = hq(:posts).find_by(id: 104)
1426
+ tags_query = post.tags
1427
+
1428
+ assert_instance_of Hq::Query, tags_query
1429
+ assert_equal 0, tags_query.size
1430
+ assert_equal [], tags_query.to_a
1431
+ end
1432
+
1433
+ def test_has_many_through_inverse
1434
+ tag = hq(:tags).find_by(id: 301)
1435
+ posts_query = tag.posts
1436
+
1437
+ assert_instance_of Hq::Query, posts_query
1438
+ assert tag.respond_to?(:posts)
1439
+
1440
+ posts = posts_query.order(:id)
1441
+ assert_equal 2, posts.size
1442
+ assert_equal [101, 102], posts.map { |p| p.id }
1443
+ end
1444
+ end
1445
+
1446
+
1447
+ # File: ./run_all.rb
1448
+
1449
+ require_relative "relations_test"
1450
+ require_relative "query_test"
1451
+ require_relative "context_test"
1452
+ require_relative "instance_test"
1453
+ require_relative "edge_test"
1454
+ require_relative "paginate_test"
1455
+ require_relative "data_test"
1456
+
1457
+
1458
+ # File: ./test_helper.rb
1459
+
1460
+ require 'minitest/autorun'
1461
+ require 'minitest/pride'
1462
+ require_relative '../lib/hq'
1463
+ require 'date'
1464
+ require 'tmpdir' # For temporary file tests
1465
+ require 'fileutils'
1466
+