Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/continuous_integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,12 @@ jobs:
bundle config set gemfile "gemfiles/${{ matrix.appraisal }}.gemfile"
- name: Install gems
run: |
bundle update
bundle install
- name: Run Tests
run: bundle exec rake
- name: standardrb
if: matrix.standardrb == true
run: bundle exec rake standard
run: bundle exec standardrb
- name: yard
if: matrix.yard == true
run: bundle exec yard --fail-on-warning
run: bundle exec yard doc --fail-on-warning
74 changes: 74 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Copilot Instructions for support_table_cache

## Project Overview

This is a Ruby gem that adds transparent caching to ActiveRecord support/lookup tables. It intercepts `find_by` queries and `belongs_to` associations to cache small, rarely-changing reference tables (statuses, types, categories) without code changes.

**Core principle**: Cache entries keyed by unique attribute combinations, auto-invalidated on record changes via `after_commit` callbacks.

## Architecture

- **lib/support_table_cache.rb**: Main module with `cache_by` DSL, cache configuration, and invalidation logic
- **lib/support_table_cache/find_by_override.rb**: Prepends to model class to intercept `find_by` calls
- **lib/support_table_cache/relation_override.rb**: Prepends to `ActiveRecord::Relation` to handle scoped queries (e.g., `where(group: 'x').find_by(name: 'y')`)
- **lib/support_table_cache/associations.rb**: Extends `belongs_to` with `cache_belongs_to` to cache foreign key lookups
- **lib/support_table_cache/memory_cache.rb**: In-process cache implementation (use `support_table_cache = :memory`)

See [ARCHITECTURE.md](../ARCHITECTURE.md) for detailed flow diagrams showing cache key generation, invalidation, and association caching sequences.

## Key Patterns

### Model Configuration
Models use `cache_by` to declare unique keys that can be cached. Support composite keys and case-insensitivity:
```ruby
cache_by :name, case_sensitive: false
cache_by [:group, :code]
cache_by :name, where: {deleted_at: nil} # For default scopes
```

### Cache Key Structure
Cache keys are `[ClassName, {attr1: val1, attr2: val2}]` arrays with sorted attribute names. Case-insensitive values are downcased before keying.

### Module Prepending Pattern
Uses `prepend` to wrap ActiveRecord methods (`find_by`) rather than monkey-patching. This allows `super` to call original behavior on cache misses or when caching disabled.

## Testing

- **Multi-version testing**: Uses Appraisal gem to test against ActiveRecord 5.0-8.0 (see [Appraisals](../Appraisals))
- **Run tests**: `bundle exec rspec` (default rake task) or `bundle exec appraisal rspec` for all versions
- **Test setup**: In-memory SQLite database created in [spec/spec_helper.rb](../spec/spec_helper.rb) with test tables
- **Test isolation**: Tests wrapped with `SupportTableCache.testing!` in RSpec `config.before` to prevent cache pollution

### Code Style
Use **standardrb** for linting. Run `standardrb --fix` before committing. CI enforces this on ActiveRecord 8.0 matrix entry.

## Common Operations

### Adding Cache Support to Models
1. Include `SupportTableCache` in model class
2. Call `cache_by` with unique key attributes
3. Optionally set `self.support_table_cache_ttl = 5.minutes`
4. For associations: include `SupportTableCache::Associations` in parent model, then `cache_belongs_to :association_name`

### Cache Invalidation
Automatic via `after_commit` callback that clears all cache key variations (both old and new attribute values on updates). No manual invalidation needed unless using in-memory cache across processes.

### Debugging Cache Behavior
- Use `fetch_by` instead of `find_by` to raise error if query won't hit cache
- Disable caching in block: `Model.disable_cache { ... }` or globally `SupportTableCache.disable { ... }`
- Check if caching enabled: inspect `support_table_cache_by_attributes` class attribute

## Development Workflow

1. **Running specs locally**: `bundle exec rspec` (uses Ruby 3.3+ and ActiveRecord 8.0 from Gemfile)
2. **Testing specific AR version**: `bundle exec appraisal activerecord_7 rspec`
3. **Generating all gemfiles**: `bundle exec appraisal generate`
4. **Lint before commit**: `standardrb --fix`
5. **Release**: Only from `main` branch (enforced by `Rakefile` pre-release check)

## Important Constraints

- **Target models**: Only for small tables (few hundred rows max)
- **Unique keys only**: `cache_by` attributes must define unique constraints
- **No runtime scopes**: Cannot use `cache_belongs_to` with scoped associations (checked at configuration time)
- **In-memory cache caveat**: Per-process, not invalidated across processes—only use for truly static data or with TTL
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 1.1.5

### Fixed

- Replaced thread local variables with fiber local variables to prevent the possibility of behavior from leaking across fibers when disabling the cache in a block.
- Allow setting the cache to an in-memory cache by setting `support_table_cache` to `true`.

## 1.1.4

### Fixed
Expand All @@ -13,21 +20,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## 1.1.3

### Fixed

- Avoid calling methods that require a database connection when setting up belongs to caching.

## 1.1.2

### Fixed

- Do not cache records where only some of the columns have been loaded with a call to `select`.

## 1.1.1

### Fixed

- Fixed disabled and disable_cache methods to yield a block to match the documentation.

## 1.1.0

### Added

- Added fetch_by and fetch_by! methods that can verify the result will be cacheable.
- Allow configuring cache storage on a per class basis.
- Allow disabling caching on per class basis.
Expand All @@ -36,15 +47,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added test mode to intialize new caches within a test block.

### Changed

- Changed fiber local variables used for disabling the cache to thread local variables.
- Using find_by! on a relation will now use the cache.

## 1.0.1

### Added

- Preserve scope on relations terminated with a `find_by`.

## 1.0.0

### Added

- Add SupportTableCache concern to enable automatic caching on models when calling `find_by` with unique key parameters.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ gemspec

gem "rspec", "~> 3.0"
gem "rake"
gem "irb"
gem "sqlite3"
gem "appraisal"
gem "standard", "~>1.0"
Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ end

With this gem, you can avoid the database query associated with the `find_by` call. You don't need to alter your code in any way other than to include `SupportTableCache` in your model and telling it the attributes that comprise a unique key, which can be used for caching.

## Table of Contents

- [Usage](#usage)
- [Setting the Cache](#setting-the-cache)
- [Disabling Caching](#disabling-caching)
- [Caching Belongs to Associations](#caching-belongs-to-associations)
- [Testing](#testing)
- [Companion Gems](#companion-gems)
- [Installation](#installation)
- [Contributing](#contributing)
- [License](#license)

## Usage

To use the gem, you need to include it in you models and then specify which attributes can be used for caching with the `cache_by` method. A caching attribute must be a unique key on the model. For a composite unique key, you can specify an array of attributes. If any of the attributes are case-insensitive strings, you need to specify that as well.
Expand Down Expand Up @@ -136,10 +148,13 @@ class MiniTest::Spec

```

### Maintaining Data
### Companion Gems

You can use the companion [support_table_data gem](https://github.com/bdurand/support_table_data) to provide functionality for loading static data into your support tables as well as adding helper functions to make looking up specific rows much easier.

> [!TIP]
> The [support_table](https://github.com/bdurand/support_table) gem combines both gems in a drop in solution for Rails applications.

## Installation

Add this line to your application's Gemfile:
Expand Down
17 changes: 0 additions & 17 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@ rescue LoadError
puts "You must `gem install bundler` and `bundle install` to run rake tasks"
end

require "yard"
YARD::Rake::YardocTask.new(:yard)

require "bundler/gem_tasks"

task :verify_release_branch do
Expand All @@ -23,17 +20,3 @@ require "rspec/core/rake_task"
RSpec::Core::RakeTask.new(:spec)

task default: :spec

desc "run the specs using appraisal"
task :appraisals do
exec "bundle exec appraisal rake spec"
end

namespace :appraisals do
desc "install all the appraisal gemspecs"
task :install do
exec "bundle exec appraisal install"
end
end

require "standard/rake"
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.1.4
1.1.5
60 changes: 30 additions & 30 deletions lib/support_table_cache.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative "support_table_cache/associations"
require_relative "support_table_cache/fiber_locals"
require_relative "support_table_cache/find_by_override"
require_relative "support_table_cache/relation_override"
require_relative "support_table_cache/memory_cache"
Expand All @@ -10,6 +11,13 @@
module SupportTableCache
extend ActiveSupport::Concern

NOT_SET = Object.new.freeze
private_constant :NOT_SET

@fiber_locals = FiberLocals.new
@cache = NOT_SET
@disabled = false

included do
# @api private Used to store the list of attribute names used for caching.
class_attribute :support_table_cache_by_attributes, instance_accessor: false
Expand Down Expand Up @@ -42,14 +50,7 @@ module ClassMethods
# @yield Executes the provided block with caching disabled or enabled.
# @return [Object] The return value of the block.
def disable_cache(disabled = true, &block)
varname = "support_table_cache_disabled:#{name}"
save_val = Thread.current.thread_variable_get(varname)
begin
Thread.current.thread_variable_set(varname, !!disabled)
yield
ensure
Thread.current.thread_variable_set(varname, save_val)
end
SupportTableCache.with_fiber_local("support_table_cache_disabled:#{name}", !!disabled, &block)
end

# Enable the caching behavior for this class within the block. The enabled setting
Expand Down Expand Up @@ -81,10 +82,10 @@ def load_cache
# Set a class-specific cache to use in lieu of the global cache.
#
# @param cache [ActiveSupport::Cache::Store, Symbol] The cache instance to use. You can also
# specify the value :memory to use an optimized in-memory cache.
# specify the value :memory or true to use an optimized in-memory cache.
# @return [void]
def support_table_cache=(cache)
cache = MemoryCache.new if cache == :memory
cache = MemoryCache.new if cache == :memory || cache == true
self.support_table_cache_impl = cache
end

Expand Down Expand Up @@ -127,7 +128,7 @@ def cache_by(attributes, case_sensitive: true, where: nil)
private

def support_table_cache_disabled?
current_block_value = Thread.current.thread_variable_get("support_table_cache_disabled:#{name}")
current_block_value = SupportTableCache.fiber_local_value("support_table_cache_disabled:#{name}")
if current_block_value.nil?
SupportTableCache.disabled?
else
Expand All @@ -150,13 +151,7 @@ class << self
# @return [Object, nil] The return value of the block if a block is given, nil otherwise.
def disable(disabled = true, &block)
if block
save_val = Thread.current.thread_variable_get(:support_table_cache_disabled)
begin
Thread.current.thread_variable_set(:support_table_cache_disabled, !!disabled)
yield
ensure
Thread.current.thread_variable_set(:support_table_cache_disabled, save_val)
end
SupportTableCache.with_fiber_local("support_table_cache_disabled", !!disabled, &block)
else
@disabled = !!disabled
end
Expand All @@ -174,9 +169,9 @@ def enable(&block)
# Return true if caching has been disabled.
# @return [Boolean]
def disabled?
block_value = Thread.current.thread_variable_get(:support_table_cache_disabled)
block_value = SupportTableCache.fiber_local_value("support_table_cache_disabled")
if block_value.nil?
!!(defined?(@disabled) && @disabled)
!!@disabled
else
block_value
end
Expand All @@ -197,7 +192,7 @@ def cache=(value)
def cache
if testing_cache
testing_cache
elsif defined?(@cache)
elsif @cache != NOT_SET
@cache
elsif defined?(Rails.cache)
Rails.cache
Expand All @@ -211,14 +206,11 @@ def cache
# @yield Executes the provided block in test mode.
# @return [Object] The return value of the block.
def testing!(&block)
save_val = Thread.current.thread_variable_get(:support_table_cache_test_cache)
save_val = SupportTableCache.fiber_local_value("support_table_cache_test_cache")
if save_val.nil?
Thread.current.thread_variable_set(:support_table_cache_test_cache, MemoryCache.new)
end
begin
SupportTableCache.with_fiber_local("support_table_cache_test_cache", MemoryCache.new, &block)
else
yield
ensure
Thread.current.thread_variable_set(:support_table_cache_test_cache, save_val)
end
end

Expand All @@ -227,9 +219,9 @@ def testing!(&block)
# @return [SupportTableCache::MemoryCache, nil] The test cache or nil if not in test mode.
# @api private
def testing_cache
unless defined?(@cache) && @cache.nil?
Thread.current.thread_variable_get(:support_table_cache_test_cache)
end
return nil if @cache.nil?

SupportTableCache.fiber_local_value("support_table_cache_test_cache")
end

# Generate a consistent cache key for a set of attributes. It will return nil if the attributes
Expand Down Expand Up @@ -258,6 +250,14 @@ def cache_key(klass, attributes, key_attribute_names, case_sensitive)

[klass.name, sorted_attributes]
end

def fiber_local_value(varname)
@fiber_locals[varname]
end

def with_fiber_local(varname, value, &block)
@fiber_locals.with(varname, value, &block)
end
end

# Remove the cache entry for this record.
Expand Down
Loading