type_balancer_rails 0.2.6 → 0.2.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: 3a0c6351fbf82429fbfeed86c19f07b576be7225d9e885256dcfab1022fb4868
4
- data.tar.gz: 32f1b7feb2695fd2b390fa2cd36dd4e5bcbef29c9f9f9f7af4a609a441b2e7bc
3
+ metadata.gz: 9866aa8259f829d620cf73c8646ca94d0df346fb919f2b9c162541e0206e39d7
4
+ data.tar.gz: 1869524e7b9660adae5e6a86e8e6807306d61ad6927ad2192005b12236f2ef09
5
5
  SHA512:
6
- metadata.gz: 3e7cfc07660ba22fe7f53db4a77be6d2e0216264644a9f796144efb0f49d88fe047d4ade15020c7f572308b8d561b8785c4c5c9ded470d26b294ae5b76316527
7
- data.tar.gz: 6c43394a7cc216682ae10f98148b7df53e7d4a0acde867c82cc436bdb192bce214aa351e81bf09cd1c0a84faac27936f1ff66520e3ad89bab384afdc6a8676a6
6
+ metadata.gz: 98609db2225b4e762f7445bcc2fcf675e7995c49b1c41699ae5e5c2c706f34e0dbe88945b6bcf72f0651c2f92e2648c0515bda048df0631dd322e0abf3caaa5e
7
+ data.tar.gz: 2f8371a767e3301c57ffb3cf9bc16ae9e9e5e6ffe381ef97230632837eef7e9157f2968e40735fc00c8a2662e1f5dc56f5fb12644ed298b36a6162e0c5fe1d74
data/.rubocop.yml CHANGED
@@ -41,6 +41,8 @@ Metrics/MethodLength:
41
41
 
42
42
  Metrics/AbcSize:
43
43
  Max: 20
44
+ Exclude:
45
+ - 'spec/**/*'
44
46
 
45
47
  Metrics/ClassLength:
46
48
  Max: 200
data/CHANGELOG.md CHANGED
@@ -1,4 +1,38 @@
1
- ## [Unreleased]
1
+ ## [0.2.8] - 2025-05-10
2
+
3
+ - **Rails-style configuration block:**
4
+ You can now configure TypeBalancer Rails in an initializer using:
5
+ ```ruby
6
+ TypeBalancer::Rails.configure do |config|
7
+ config.cache_adapter = TypeBalancer::Rails::CacheAdapter.new
8
+ config.cache_expiry_seconds = 600
9
+ end
10
+ ```
11
+ Direct assignment is still supported for backward compatibility.
12
+
13
+ - **Per-request cache control:**
14
+ `balance_by_type` now accepts:
15
+ - `expires_in:` (override cache expiry for a single call)
16
+ - `cache_reset:` (force cache refresh for a single call)
17
+
18
+ - **Global cache expiry configuration:**
19
+ Set the default cache expiry for all balanced queries via `TypeBalancer::Rails.cache_expiry_seconds`.
20
+
21
+ - **Cache clearing:**
22
+ Use `TypeBalancer::Rails.clear_cache!` to clear all cached balanced results (e.g., from a console or admin task).
23
+
24
+ ### Changed
25
+ - **Caching and pagination are always enabled** for performance and reliability.
26
+ - **Cache keys are now isolated** per model and type field, preventing cross-contamination.
27
+
28
+ ## [0.2.7] - 2025-05-04
29
+
30
+ - Always-on pagination: Results from `balance_by_type` are now always paginated for performance (default: 20 per page, page 1). There is no option to disable pagination.
31
+ - Added support for `per_page` and `page` options to control result size and page.
32
+ - Added support for `expires_in` option to override the default cache expiration (default: 10 minutes) per call.
33
+ - Cache adapter is now a first-class, configurable component (`TypeBalancer::Rails.cache_adapter`).
34
+ - Improved documentation and architecture overview to reflect new pagination and caching behavior.
35
+ - RuboCop and test stability improvements.
2
36
 
3
37
  ## [0.2.6] - 2025-05-01
4
38
 
data/README.md CHANGED
@@ -26,6 +26,38 @@ Or install it yourself as:
26
26
  $ gem install type_balancer_rails
27
27
  ```
28
28
 
29
+ ## Caching and Performance
30
+
31
+ Type balancing can be computationally expensive, especially on large datasets. To ensure efficient performance, **TypeBalancer Rails automatically caches the balanced ID list for each query**. This means:
32
+ - The balancing algorithm only runs when needed (on cache miss or reset).
33
+ - Subsequent requests for the same query use the cached result, reducing database and CPU load.
34
+ - Caching is essential for production use. Disabling or misconfiguring cache may result in slow queries.
35
+
36
+ **Adjust cache settings thoughtfully:**
37
+ - Shorter expiries mean fresher data but more frequent recalculation.
38
+ - Longer expiries improve performance but may serve stale results if your data changes often.
39
+
40
+ ## Configuration
41
+
42
+ To customize caching and performance, use the Rails-style configuration block in an initializer (e.g., `config/initializers/type_balancer.rb`):
43
+
44
+ ```ruby
45
+ TypeBalancer::Rails.configure do |config|
46
+ # Use the default cache adapter (backed by Rails.cache)
47
+ config.cache_adapter = TypeBalancer::Rails::CacheAdapter.new
48
+
49
+ # Set the global cache expiry (in seconds)
50
+ # Default is 600 (10 minutes). Adjust as needed for your app's freshness/performance needs.
51
+ config.cache_expiry_seconds = 600
52
+ end
53
+ ```
54
+
55
+ > **Note:** You can also set these options directly
56
+ > ```ruby
57
+ > TypeBalancer::Rails.cache_adapter = TypeBalancer::Rails::CacheAdapter.new
58
+ > TypeBalancer::Rails.cache_expiry_seconds = 600
59
+ > ```
60
+
29
61
  ## Usage
30
62
 
31
63
  To balance records by a given type field, use the following syntax:
@@ -82,6 +114,82 @@ The `balance_by_type` method preserves the ActiveRecord query interface:
82
114
  .per(20)
83
115
  ```
84
116
 
117
+ ### Pagination and Caching (Always Enabled)
118
+
119
+ Results from `balance_by_type` are **always paginated** for performance reasons. By default, only the first 20 balanced records are returned. You can control the page size and which page is returned using the `per_page` and `page` options:
120
+
121
+ ```ruby
122
+ # Get the first 20 balanced records (default)
123
+ @posts = Post.all.balance_by_type
124
+
125
+ # Get the second page of 10 balanced records
126
+ @posts = Post.all.balance_by_type(type_field: :category, per_page: 10, page: 2)
127
+ ```
128
+
129
+ - **Default page size:** 20
130
+ - **Default page:** 1
131
+ - **Pagination is required:** There is no option to disable pagination. This is necessary for performance, especially on large datasets.
132
+
133
+ #### Cache Expiration
134
+
135
+ Balanced results are cached by default for 10 minutes to improve performance and reduce redundant calculations. You can override the cache expiration for a specific call by passing the `expires_in` option:
136
+
137
+ ```ruby
138
+ # Cache the balanced results for 1 hour instead of 10 minutes
139
+ @posts = Post.all.balance_by_type(type_field: :category, expires_in: 1.hour)
140
+ ```
141
+
142
+ - **Default cache expiration:** 10 minutes
143
+ - **Custom cache expiration:** Pass `expires_in: ...` (e.g., `expires_in: 1.hour`)
144
+
145
+ > **Note:** If you need to retrieve all balanced records, you must manually iterate through all pages.
146
+
147
+ ### Per-request Cache Control
148
+
149
+ You can override cache behavior for a single call to `balance_by_type`:
150
+
151
+ - **Custom Expiry:**
152
+ ```ruby
153
+ # Cache the balanced results for 1 hour for this request only
154
+ @posts = Post.all.balance_by_type(type_field: :category, expires_in: 1.hour)
155
+ ```
156
+ - **Force Cache Reset:**
157
+ ```ruby
158
+ # Force recalculation and cache update for this request
159
+ @posts = Post.all.balance_by_type(type_field: :category, cache_reset: true)
160
+ ```
161
+
162
+ #### Controller Example
163
+
164
+ You can pass these options from controller params:
165
+
166
+ ```ruby
167
+ class PostsController < ApplicationController
168
+ def index
169
+ @posts = Post.all.balance_by_type(
170
+ type_field: params[:type_field],
171
+ expires_in: params[:expires_in],
172
+ cache_reset: params[:cache_reset].present?
173
+ )
174
+ end
175
+ end
176
+ ```
177
+
178
+ ## Cache Management and Isolation
179
+
180
+ - **Cache keys are unique per model and type field.**
181
+ - There is no cross-contamination between different models or type fields.
182
+ - If you use multiple type fields or models, each will have its own cache entry.
183
+ - To avoid stale data, clear the cache after bulk updates or schema changes using `TypeBalancer::Rails.clear_cache!`.
184
+
185
+ ## Upgrade Notes
186
+
187
+ - `balance_by_type` now supports per-request `expires_in` and `cache_reset` options.
188
+ - Global cache expiry is configurable via `TypeBalancer::Rails.cache_expiry_seconds`.
189
+ - You can clear all cached results with `TypeBalancer::Rails.clear_cache!`.
190
+ - Caching is always enabled and required for performance.
191
+ - Pagination is always enabled; you must page through results if you want all records.
192
+
85
193
  ## Planned Enhancements
86
194
 
87
195
  - Support for passing a symbol directly to `balance_by_type`, e.g., `balance_by_type(:media_type)`, for more ergonomic usage. This is planned for a future version.
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env ruby
2
+ # /Users/carl/gems/type_balancer-rails/benchmarks/collection_methods_benchmark.rb
3
+
4
+ require 'bundler/setup'
5
+ require 'type_balancer'
6
+ require 'active_record'
7
+ require 'benchmark'
8
+ require_relative '../lib/type_balancer/rails'
9
+
10
+ # Setup in-memory SQLite DB and model
11
+ db_path = ':memory:'
12
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: db_path)
13
+
14
+ # Define a simple model for benchmarking
15
+ class Item < ActiveRecord::Base; end
16
+
17
+ # Create table and seed data
18
+ ActiveRecord::Schema.define do
19
+ create_table :items, force: true do |t|
20
+ t.string :media_type
21
+ end
22
+ end
23
+
24
+ # Seed with N records, distributed across types
25
+ N = 20_000
26
+ types = ['video', 'article', 'podcast']
27
+ puts "Seeding #{N} records..."
28
+ N.times do |i|
29
+ Item.create!(media_type: types[i % types.size])
30
+ end
31
+ puts 'Seeding complete.'
32
+
33
+ # Extend the relation with CollectionMethods
34
+ relation = Item.all.extending(TypeBalancer::Rails::CollectionMethods)
35
+
36
+ # Benchmark balance_by_type
37
+ puts "\nBenchmarking balance_by_type on #{N} records..."
38
+ Benchmark.bm(20) do |x|
39
+ x.report('balance_by_type:') do
40
+ result = relation.balance_by_type(type_field: :media_type)
41
+ result.to_a
42
+ end
43
+ end
@@ -0,0 +1,148 @@
1
+ # TypeBalancer Rails Gem: Architecture Overview
2
+
3
+ ## 1. Project Summary
4
+
5
+ TypeBalancer Rails is a Ruby gem designed to provide advanced balancing and ordering capabilities for ActiveRecord collections in Rails applications. Its primary feature is the ability to balance records by a specified type, ensuring even distribution and flexible pagination of heterogeneous data sets. The gem is intended for use in Rails projects that require sophisticated content or resource balancing, such as feeds, dashboards, or content aggregators.
6
+
7
+ ---
8
+
9
+ ## 2. Architecture & Dependencies
10
+
11
+ ### High-Level Architecture
12
+ - **Core Module:** The gem's core logic is implemented under `lib/type_balancer/rails/`, with extensions for ActiveRecord and integration with Rails via a Railtie.
13
+ - **ActiveRecord Integration:** The gem extends ActiveRecord models and relations to provide the `balance_by_type` method, which can be called on any model or relation.
14
+ - **Rails Integration:** A Railtie ensures the gem is loaded and configured automatically in Rails environments.
15
+ - **Generators:** The gem provides a Rails generator for easy installation and configuration.
16
+
17
+ ### Key Dependencies
18
+ - **active_support**: Used for concerns, core extensions, and Rails integration.
19
+ - **active_record**: The gem extends ActiveRecord models and relations.
20
+ - **type_balancer**: The core balancing logic is delegated to the `type_balancer` gem (see below).
21
+ - **Rails (optional)**: For automatic integration via Railtie and generator support.
22
+
23
+ ### About the TypeBalancer Gem
24
+
25
+ TypeBalancer is a Ruby gem that provides advanced algorithms for distributing items of different types evenly across a sequence. Its primary use case is to ensure that, in collections where certain types (e.g., articles, images, videos) are overrepresented, the output is balanced so that all types are fairly and optimally spaced. This is especially useful for content feeds, e-commerce listings, news aggregators, and recommendation systems.
26
+
27
+ **Key Features:**
28
+ - Balances collections by type, ensuring optimal spacing and respecting type ratios.
29
+ - Uses a sophisticated sliding window strategy by default, with support for custom window sizes and type orderings.
30
+ - Extensible strategy system for future balancing algorithms.
31
+ - Thread-safe, memory-efficient, and suitable for real-time processing of collections up to 10,000 items.
32
+ - No external dependencies and high performance across Ruby versions.
33
+
34
+ **Core API:**
35
+ - `TypeBalancer.balance(items, type_field: :type, strategy: :sliding_window, window_size: 10, type_order: [...])`
36
+ Balances an array of items by the specified type field, using the chosen strategy and options.
37
+ - `TypeBalancer.calculate_positions(total_count:, ratio:, available_items: [...])`
38
+ Calculates optimal positions for a given type or subset within a sequence.
39
+
40
+ **Integration with Rails:**
41
+ - The TypeBalancer Rails gem acts as a façade and adapter, exposing TypeBalancer's balancing logic as an easy-to-use method (`balance_by_type`) on ActiveRecord relations and models.
42
+ - This allows Rails developers to leverage advanced balancing in queries and collections with minimal setup.
43
+
44
+ ---
45
+
46
+ ## 3. Class & Module Documentation
47
+
48
+ ### `TypeBalancer::Rails::CollectionMethods`
49
+ - **Location:** `lib/type_balancer/rails/collection_methods.rb`
50
+ - **Purpose:**
51
+ - Provides the `balance_by_type` method for ActiveRecord::Relation, enabling balanced selection and ordering of records by a type field.
52
+ - **Always paginates results** for performance. Default: 20 per page, page 1. There is no option to disable pagination.
53
+ - Supports `per_page` and `page` options to control result size and page.
54
+ - **Results are cached** for 10 minutes by default, but you can override this per call with the `expires_in` option (e.g., `expires_in: 1.hour`).
55
+ - Handles result ordering and cache key generation.
56
+ - **Dependencies:**
57
+ - Depends on `TypeBalancer.balance` for core balancing logic.
58
+ - Expects to be included in ActiveRecord::Relation.
59
+ - **Patterns:**
60
+ - Adapter/Extension pattern for ActiveRecord::Relation.
61
+
62
+ ### `TypeBalancer::Rails::ActiveRecordExtension`
63
+ - **Location:** `/Users/carl/gems/type_balancer-rails/lib/type_balancer/rails/active_record_extension.rb`
64
+ - **Purpose:**
65
+ - Provides a concern to extend ActiveRecord models with type balancing configuration and class-level `balance_by_type` method.
66
+ - Ensures `CollectionMethods` is included in ActiveRecord::Relation.
67
+ - **Dependencies:**
68
+ - `ActiveSupport::Concern`, `ActiveRecord::Relation`, `TypeBalancer::Rails::CollectionMethods`.
69
+ - **Patterns:**
70
+ - Rails Concern, Extension.
71
+
72
+ ### `TypeBalancer::Rails::Railtie`
73
+ - **Location:** `/Users/carl/gems/type_balancer-rails/lib/type_balancer/rails/railtie.rb`
74
+ - **Purpose:**
75
+ - Integrates the gem with Rails, ensuring the ActiveRecord extension is loaded automatically.
76
+ - **Dependencies:**
77
+ - `Rails::Railtie`, `ActiveSupport.on_load(:active_record)`.
78
+ - **Patterns:**
79
+ - Railtie for Rails integration.
80
+
81
+ ### `TypeBalancer::Rails::VERSION`
82
+ - **Location:** `/Users/carl/gems/type_balancer-rails/lib/type_balancer/rails/version.rb`
83
+ - **Purpose:**
84
+ - Defines the gem's version constant.
85
+ - **Dependencies:** None.
86
+
87
+ ### `TypeBalancer::Rails` (Module)
88
+ - **Location:** `/Users/carl/gems/type_balancer-rails/lib/type_balancer/rails.rb`
89
+ - **Purpose:**
90
+ - Loads and wires up all Rails-specific extensions and dependencies for the gem.
91
+ - **Exposes a configurable cache adapter** (`TypeBalancer::Rails.cache_adapter`) used for caching balanced results.
92
+ - **Dependencies:**
93
+ - `active_support`, `active_record`, `TypeBalancer::Rails::ActiveRecordExtension`, `TypeBalancer::Rails::CollectionMethods`.
94
+
95
+ ### Main Entry File
96
+ - **Location:** `/Users/carl/gems/type_balancer-rails/lib/type_balancer_rails.rb`
97
+ - **Purpose:**
98
+ - Loads all required dependencies and sets up the gem for use in a Rails environment.
99
+ - Loads the Railtie if Rails is present.
100
+
101
+ ### Generator: `TypeBalancer::Generators::InstallGenerator`
102
+ - **Location:** `/Users/carl/gems/type_balancer-rails/lib/generators/type_balancer/install/install_generator.rb`
103
+ - **Purpose:**
104
+ - Provides a Rails generator for installing TypeBalancer configuration into a Rails app.
105
+ - **Dependencies:**
106
+ - Rails generator framework.
107
+ - **Patterns:**
108
+ - Generator pattern for Rails setup.
109
+
110
+ ---
111
+
112
+ ## 4. Testing Strategy
113
+
114
+ - **Unit Tests:**
115
+ - Located in `/Users/carl/gems/type_balancer-rails/spec/`.
116
+ - Organized by feature/module (e.g., `spec/type_balancer/rails/collection_methods_spec.rb`).
117
+ - Do **not** use a database; all database and dependency interactions are strictly mocked or stubbed.
118
+ - Shared contexts and helpers are provided in `spec/support/` (e.g., `test_helpers.rb`, `test_fixtures.rb`).
119
+ - Test models for mocking are in `spec/support/models/`.
120
+
121
+ - **Integration Tests:**
122
+ - Located in the example Rails app under `/Users/carl/gems/type_balancer-rails/example/`.
123
+ - Example app contains its own `spec/` directory with feature, controller, and model specs.
124
+ - Integration tests use a real database and Rails stack to verify end-to-end behavior.
125
+ - Example app includes its own Gemfile and configuration for isolated testing.
126
+
127
+ - **Testing Practices:**
128
+ - Unit tests focus on class responsibilities and use mocking for all external dependencies.
129
+ - Integration tests are only created in the example app and are not run as part of the main gem's unit test suite.
130
+ - No direct tests of private methods; only public interfaces are tested.
131
+ - RSpec is used as the test framework throughout.
132
+ - RuboCop is used for code style enforcement (`.rubocop.yml` present in both root and example app).
133
+ - CI and test runner setup is inferred from the presence of `.rspec`, `.rspec_status`, and Rakefile.
134
+
135
+ - **Interesting/Unique Practices:**
136
+ - Strict separation of unit and integration tests, with clear boundaries and no database usage in unit tests.
137
+ - Use of shared contexts and helpers to DRY up test setup.
138
+ - Example app serves as a living integration testbed for real-world Rails usage.
139
+
140
+ ---
141
+
142
+ ## 5. Additional Notes
143
+ - All file paths in this documentation are absolute, per project standards.
144
+ - The gem follows SOLID principles and Rails best practices, with minimal monkey-patching and clear extension points.
145
+ - **Pagination and caching are always enabled for performance reasons.**
146
+ - Default: 20 per page, page 1, 10 minute cache expiration.
147
+ - Use `per_page`, `page`, and `expires_in` options to customize.
148
+ - For more details, see the README and inline code comments.
data/example/Gemfile.lock CHANGED
@@ -1,10 +1,10 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- type_balancer_rails (0.2.5)
4
+ type_balancer_rails (0.2.8)
5
5
  activerecord (>= 7.0, < 9.0)
6
6
  activesupport (>= 7.0, < 9.0)
7
- type_balancer (~> 0.2.0)
7
+ type_balancer (>= 0.2.1)
8
8
 
9
9
  GEM
10
10
  remote: https://rubygems.org/
@@ -381,7 +381,7 @@ GEM
381
381
  turbo-rails (2.0.13)
382
382
  actionpack (>= 7.1.0)
383
383
  railties (>= 7.1.0)
384
- type_balancer (0.2.0)
384
+ type_balancer (0.2.1)
385
385
  tzinfo (2.0.6)
386
386
  concurrent-ruby (~> 1.0)
387
387
  unicode-display_width (3.1.4)
@@ -20,7 +20,7 @@ Rails.application.configure do
20
20
 
21
21
  # Show full error reports.
22
22
  config.consider_all_requests_local = true
23
- config.cache_store = :null_store
23
+ config.cache_store = :memory_store
24
24
 
25
25
  # Render exception templates for rescuable exceptions and raise for other exceptions.
26
26
  config.action_dispatch.show_exceptions = :rescuable
@@ -0,0 +1,65 @@
1
+ require 'rails_helper'
2
+
3
+ RSpec.describe PostsController, type: :controller do
4
+ fixtures :posts
5
+
6
+ before do
7
+ Rails.cache.clear
8
+ allow(TypeBalancer).to receive(:balance).and_call_original
9
+ end
10
+
11
+ describe 'GET #index' do
12
+ it 'caches the balanced ID list and reuses it' do
13
+ # First call: should invoke the balancer
14
+ get :index
15
+ expect(TypeBalancer).to have_received(:balance).once
16
+ first_result = assigns(:posts).map(&:id)
17
+
18
+ # Second call: should use the cache, not call balancer again
19
+ get :index
20
+ expect(TypeBalancer).to have_received(:balance).once
21
+ second_result = assigns(:posts).map(&:id)
22
+ expect(second_result).to eq(first_result)
23
+ end
24
+
25
+ it 'resets the cache when requested via controller param' do
26
+ # Prime the cache
27
+ get :index
28
+ expect(TypeBalancer).to have_received(:balance).once
29
+ first_result = assigns(:posts).map(&:id)
30
+
31
+ # Simulate cache reset by passing param (assuming controller supports it)
32
+ allow(TypeBalancer).to receive(:balance).and_call_original # reset spy
33
+ get :index, params: { cache_reset: true }
34
+ expect(TypeBalancer).to have_received(:balance).once
35
+ second_result = assigns(:posts).map(&:id)
36
+ expect(second_result).to eq(first_result) # Should still be balanced, but balancer called again
37
+ end
38
+
39
+ it 'isolates cache by type field' do
40
+ get :index, params: { type_field: 'media_type' }
41
+ expect(TypeBalancer).to have_received(:balance).once
42
+ allow(TypeBalancer).to receive(:balance).and_call_original # reset spy
43
+ get :index, params: { type_field: 'other_type' }
44
+ expect(TypeBalancer).to have_received(:balance).once
45
+ end
46
+
47
+ it 'respects per-request expires_in option' do
48
+ get :index, params: { expires_in: 1 }
49
+ expect(TypeBalancer).to have_received(:balance).once
50
+ sleep 2
51
+ allow(TypeBalancer).to receive(:balance).and_call_original # reset spy
52
+ get :index, params: { expires_in: 1 }
53
+ expect(TypeBalancer).to have_received(:balance).once
54
+ end
55
+
56
+ it 'expires cache and calls balancer again after expiry' do
57
+ get :index, params: { expires_in: 1 }
58
+ expect(TypeBalancer).to have_received(:balance).once
59
+ sleep 2
60
+ allow(TypeBalancer).to receive(:balance).and_call_original # reset spy
61
+ get :index, params: { expires_in: 1 }
62
+ expect(TypeBalancer).to have_received(:balance).once
63
+ end
64
+ end
65
+ end
@@ -9,7 +9,7 @@ RSpec.feature 'Contents balancing', type: :feature do
9
9
  # Collect the categories from the second column of the table rows
10
10
  categories = page.all('table tbody tr').map { |row| row.all('td')[1]&.text }.compact
11
11
 
12
- expect(categories.count).to eq(Content.count)
12
+ expect(categories.count).to eq(20)
13
13
  # There should be a mix of categories, not just a long run of one category
14
14
  expect(categories.uniq.sort).to eq(%w[blog news tutorial])
15
15
  # Check that the first 10 are not all the same (skewed fixture would be all news)
@@ -19,6 +19,7 @@ RSpec.describe Content, type: :model do
19
19
 
20
20
  describe 'type balancing with real records' do
21
21
  before do
22
+ TypeBalancer::Rails.clear_cache!
22
23
  described_class.delete_all
23
24
  @content1 = described_class.create!(title: 'Content 1', content_type: 'blog')
24
25
  @content2 = described_class.create!(title: 'Content 2', content_type: 'news')
@@ -16,6 +16,7 @@ RSpec.describe Post, type: :model do
16
16
  let(:another_video_post) { described_class.create!(title: 'Post 3', media_type: 'video') }
17
17
 
18
18
  before do
19
+ TypeBalancer::Rails.clear_cache!
19
20
  described_class.delete_all
20
21
  video_post; article_post; another_video_post # Create the records
21
22
  end
Binary file
@@ -0,0 +1,38 @@
1
+ module TypeBalancer
2
+ module Rails
3
+ class CacheAdapter
4
+ def initialize
5
+ @memory_cache = {}
6
+ end
7
+
8
+ def fetch(key, options = {}, &block)
9
+ if defined?(::Rails) && ::Rails.respond_to?(:cache) && ::Rails.cache
10
+ ::Rails.cache.fetch(key, options, &block)
11
+ else
12
+ @memory_cache[key] ||= block.call
13
+ end
14
+ end
15
+
16
+ def write(key, value, options = {})
17
+ if defined?(::Rails) && ::Rails.respond_to?(:cache) && ::Rails.cache
18
+ ::Rails.cache.write(key, value, options)
19
+ else
20
+ @memory_cache[key] = value
21
+ end
22
+ end
23
+
24
+ def delete(key)
25
+ if defined?(::Rails) && ::Rails.respond_to?(:cache) && ::Rails.cache
26
+ ::Rails.cache.delete(key)
27
+ else
28
+ @memory_cache.delete(key)
29
+ end
30
+ end
31
+
32
+ def clear_cache!
33
+ ::Rails.cache.clear if defined?(::Rails) && ::Rails.respond_to?(:cache) && ::Rails.cache
34
+ @memory_cache.clear
35
+ end
36
+ end
37
+ end
38
+ end
@@ -6,52 +6,60 @@ module TypeBalancer
6
6
  # Provides collection methods for balancing by type
7
7
  # These methods are extended onto ActiveRecord::Relation
8
8
  module CollectionMethods
9
- def balance_by_type(options = {})
10
- records = to_a
11
- return empty_relation if records.empty?
12
-
13
- type_field = fetch_type_field(options).to_sym
14
- type_counts = records.group_by { |r| r.send(type_field).to_s }.transform_values(&:count)
15
- type_order = compute_type_order(type_counts)
16
- items = build_items(records, type_field)
9
+ require 'digest/md5'
17
10
 
18
- balanced = TypeBalancer.balance(
19
- items,
20
- type_field: type_field,
21
- type_order: type_order
22
- )
23
-
24
- return empty_relation if balanced.nil?
25
-
26
- paged = apply_pagination(balanced, options)
11
+ def balance_by_type(options = {})
12
+ type_field, offset, per_page = pagination_params(options)
13
+ cache_key = build_cache_key(type_field)
14
+ expires_in = options[:expires_in] || ::TypeBalancer::Rails.cache_expiry_seconds
15
+ cache_reset = options[:cache_reset]
16
+ if cache_reset
17
+ ids = compute_ids(type_field)
18
+ TypeBalancer::Rails.cache_adapter.write(cache_key, ids, expires_in: expires_in)
19
+ else
20
+ ids = TypeBalancer::Rails.cache_adapter.fetch(cache_key, expires_in: expires_in) do
21
+ compute_ids(type_field)
22
+ end
23
+ end
24
+ page_ids = ids[offset, per_page] || []
25
+ return empty_relation if page_ids.empty?
27
26
 
28
- build_result(paged)
27
+ order_by_ids(page_ids)
29
28
  end
30
29
 
31
30
  private
32
31
 
33
- def apply_pagination(records, options)
34
- return records unless options[:page] || options[:per_page]
32
+ def pagination_params(options)
33
+ type_field = fetch_type_field(options).to_sym
34
+ page = (options[:page] || 1).to_i
35
+ per_page = (options[:per_page] || 20).to_i
36
+ offset = (page - 1) * per_page
37
+ [type_field, offset, per_page]
38
+ end
35
39
 
36
- page = (options[:page] || 1).to_i
37
- per_page = (options[:per_page] || 20).to_i
38
- offset = (page - 1) * per_page
39
- records[offset, per_page] || []
40
+ def build_cache_key(type_field)
41
+ [
42
+ 'type_balancer',
43
+ klass.name,
44
+ type_field,
45
+ Digest::MD5.hexdigest(to_sql)
46
+ ].join(':')
40
47
  end
41
48
 
42
- def build_result(balanced)
43
- flattened = balanced.flatten(1)
44
- ids = flattened.map { |h| h[:id] }
45
- unless klass.respond_to?(:where)
46
- raise TypeError, 'balance_by_type can only be called on an ActiveRecord::Relation or compatible object'
47
- end
49
+ def compute_ids(type_field)
50
+ records = select(:id, type_field)
51
+ items = records.map { |r| { id: r.id, type_field => r.public_send(type_field) } }
52
+ type_counts = items.group_by { |h| h[type_field] }.transform_values(&:size)
53
+ order = compute_type_order(type_counts)
54
+ balanced = TypeBalancer.balance(items, type_field: type_field, type_order: order)
55
+ balanced ? balanced.flatten(1).map { |h| h[:id] } : []
56
+ rescue TypeBalancer::EmptyCollectionError
57
+ []
58
+ end
48
59
 
49
- relation = klass.where(id: ids)
50
- if ids.any?
51
- case_sql = "CASE id #{ids.each_with_index.map { |id, idx| "WHEN #{id} THEN #{idx}" }.join(' ')} END"
52
- relation = relation.order(Arel.sql(case_sql))
53
- end
54
- relation
60
+ def order_by_ids(ids)
61
+ case_sql = "CASE id #{ids.each_with_index.map { |id, idx| "WHEN #{id} THEN #{idx}" }.join(' ')} END"
62
+ klass.where(id: ids).order(Arel.sql(case_sql))
55
63
  end
56
64
 
57
65
  def empty_relation
@@ -70,28 +78,6 @@ module TypeBalancer
70
78
  def compute_type_order(type_counts)
71
79
  type_counts.sort_by { |_, count| count }.map(&:first)
72
80
  end
73
-
74
- def build_items(records, type_field)
75
- records.map do |record|
76
- { id: record.id, type_field => record.send(type_field).to_s }
77
- end
78
- end
79
-
80
- def logger?
81
- defined?(::Rails) && ::Rails.logger
82
- end
83
-
84
- def balance_results_lines(balanced, _type_field)
85
- if balanced.nil?
86
- ['Balanced result is nil!']
87
- else
88
- [
89
- "First 10 balanced types: \#{balanced.first(10).map { |h| h[type_field] }.inspect}",
90
- "Unique types in first 10: \#{balanced.first(10).map { |h| h[type_field] }.uniq.inspect}",
91
- "Total balanced items: \#{balanced.size}"
92
- ]
93
- end
94
- end
95
81
  end
96
82
  end
97
83
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module TypeBalancer
4
4
  module Rails
5
- VERSION = '0.2.6'
5
+ VERSION = '0.2.8'
6
6
  end
7
7
  end
@@ -6,8 +6,23 @@ require 'active_record'
6
6
 
7
7
  require_relative 'rails/active_record_extension'
8
8
  require_relative 'rails/collection_methods'
9
+ require_relative 'rails/cache_adapter'
9
10
 
10
11
  module TypeBalancer
11
12
  module Rails
13
+ class << self
14
+ attr_accessor :cache_adapter, :cache_expiry_seconds
15
+
16
+ def clear_cache!
17
+ cache_adapter&.clear_cache!
18
+ end
19
+
20
+ # Rails-style configuration block
21
+ def configure
22
+ yield self if block_given?
23
+ end
24
+ end
25
+ self.cache_adapter ||= TypeBalancer::Rails::CacheAdapter.new
26
+ self.cache_expiry_seconds ||= 600 # 10 minutes default
12
27
  end
13
28
  end
@@ -32,7 +32,7 @@ Gem::Specification.new do |spec|
32
32
  # Runtime dependencies
33
33
  spec.add_dependency 'activerecord', '>= 7.0', '< 9.0'
34
34
  spec.add_dependency 'activesupport', '>= 7.0', '< 9.0'
35
- spec.add_dependency 'type_balancer', '~> 0.2.1'
35
+ spec.add_dependency 'type_balancer', '>= 0.2.1'
36
36
 
37
37
  # For more information and examples about making a new gem, check out our
38
38
  # guide at: https://bundler.io/guides/creating_gem.html
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: type_balancer_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.6
4
+ version: 0.2.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl Smith
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-05-01 00:00:00.000000000 Z
11
+ date: 2025-05-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -54,14 +54,14 @@ dependencies:
54
54
  name: type_balancer
55
55
  requirement: !ruby/object:Gem::Requirement
56
56
  requirements:
57
- - - "~>"
57
+ - - ">="
58
58
  - !ruby/object:Gem::Version
59
59
  version: 0.2.1
60
60
  type: :runtime
61
61
  prerelease: false
62
62
  version_requirements: !ruby/object:Gem::Requirement
63
63
  requirements:
64
- - - "~>"
64
+ - - ">="
65
65
  - !ruby/object:Gem::Version
66
66
  version: 0.2.1
67
67
  description: Provides Rails integration for the type_balancer gem
@@ -78,6 +78,8 @@ files:
78
78
  - LICENSE.txt
79
79
  - README.md
80
80
  - Rakefile
81
+ - benchmarks/collection_methods_benchmark.rb
82
+ - docs/architecture_overview.md
81
83
  - example/.dockerignore
82
84
  - example/.rspec
83
85
  - example/.rubocop.yml
@@ -145,6 +147,7 @@ files:
145
147
  - example/lib/tasks/dev_fixtures.rake
146
148
  - example/script/.keep
147
149
  - example/spec/controllers/contents_controller_spec.rb
150
+ - example/spec/controllers/posts_controller_caching_spec.rb
148
151
  - example/spec/controllers/posts_controller_spec.rb
149
152
  - example/spec/features/contents_balancing_spec.rb
150
153
  - example/spec/features/posts_balancing_spec.rb
@@ -164,6 +167,7 @@ files:
164
167
  - lib/generators/type_balancer/install/templates/type_balancer.rb.erb
165
168
  - lib/type_balancer/rails.rb
166
169
  - lib/type_balancer/rails/active_record_extension.rb
170
+ - lib/type_balancer/rails/cache_adapter.rb
167
171
  - lib/type_balancer/rails/collection_methods.rb
168
172
  - lib/type_balancer/rails/railtie.rb
169
173
  - lib/type_balancer/rails/version.rb