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
9 changes: 4 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
.bundle/
log/*.log
pkg/
test/dummy/db/*.sqlite3
test/dummy/db/*.sqlite3-journal
test/dummy/log/*.log
/test/dummy/storage/
test/dummy/tmp/
Gemfile.lock
Gemfile.local
coverage/
Expand All @@ -14,3 +9,7 @@ coverage/
doc/
.yardoc/
.ruby-version
.idea/
passive_columns-*.gem
spec/dummy/log/*.log
spec/dummy/tmp/
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@ But you still has an ability to retrieve the passive column on demand
page.huge_article # => 'Some huge article...'
```

### Eager-loading passive columns in the same query

Use `with_passive_columns` when you want passive columns included in the main `SELECT` instead of loading them later.

```ruby
# All columns (including every passive column)
Page.with_passive_columns

# Default non-passive columns plus only the passive columns you list.
# Any names that are not passive columns are ignored.
Page.with_passive_columns(:huge_article)

# On an association (scope is on the associated model):
user.pages.with_passive_columns(:huge_article)

# Equivalent when you already have a relation object to merge:
user.pages.merge(Page.with_passive_columns(:huge_article))
```

`find` / `find_by` still use the default selection unless you chain `with_passive_columns` explicitly (for example `Page.with_passive_columns.find(id)`).

---


Expand Down Expand Up @@ -65,6 +86,9 @@ user.name

By the way, it uses the Rails' `.pick` method to get the value of the column under the hood

### Logging on-demand SQL loads

Whenever a passive attribute reader or `load_column` runs the extra `pick` query, the gem logs one **`debug`** line (prefix `[passive_columns]`, model, column, and primary-key context) **only if** `model.logger` is set — there is no fallback to `ActiveRecord::Base.logger`. Like other debug lines, it appears only when that logger’s level is **debug** (typical in `development`, usually not at `info` in production).

### Important

Expand Down
29 changes: 29 additions & 0 deletions lib/passive_columns.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,40 @@
#
# The next time you call the passive column it won't hit the database as it is already loaded.
# page.huge_article # => 'Some huge article...'
#
# To load passive columns in the same query, use +with_passive_columns+ with no arguments (all passive columns)
# or pass passive column names; any other names are ignored.
# Page.with_passive_columns.to_a
# Page.with_passive_columns(:huge_article).to_a
module PassiveColumns
extend ActiveSupport::Concern

included do
class_attribute :_passive_columns, default: []

# Eager-load passive columns in the main SELECT instead of omitting them.
# With no arguments, sets +select_values+ to all +column_names+ (including passive columns);
# Active Record turns that into the SELECT list.
# With arguments, only names listed in +passive_columns+ are merged into +select_values+ with
# the usual non-passive column list; other names are ignored. If none of the arguments are
# passive columns, the relation behaves like a normal query (passive columns still omitted).
#
# @param [Array<Symbol, String>] requested
# @return [ActiveRecord::Relation]
scope(:with_passive_columns, lambda do |*requested|
return self if klass._passive_columns.blank?

if requested.empty?
spawn.tap { |r| r.select_values = klass.column_names.dup }
else
passive_only = requested.map(&:to_s).uniq.select { |c| klass._passive_columns.include?(c) }
return self if passive_only.empty?

spawn.tap do |r|
r.select_values = klass.column_names - klass._passive_columns + passive_only
end
end
end)
end

class_methods do # rubocop:disable Metrics/BlockLength
Expand Down
15 changes: 14 additions & 1 deletion lib/passive_columns/loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,29 @@ def load(column, force: false)

model.send(column)
rescue ActiveModel::MissingAttributeError
materialize_missing_column(column, force)
end

private

def materialize_missing_column(column, force)
allowed_columns = (force ? [column] : passive_columns).map(&:to_s)
raise if allowed_columns.exclude?(column.to_s) || identity_constraints.value?(nil)

log_lazy_sql_load(column)
value = pick_value(column)
model[column] = value
model.send(:clear_attribute_change, column)
model[column]
end

private
def log_lazy_sql_load(column)
logger = model.logger
return unless logger&.debug?

ctx = identity_constraints.map { |k, v| "#{k}=#{v.inspect}" }.join(', ')
logger.debug("[passive_columns] On-demand SQL load of #{model.class.name}##{column} (#{ctx})")
end

def pick_value(column)
model.class.unscoped.where(identity_constraints).pick(column)
Expand Down
85 changes: 85 additions & 0 deletions spec/passive_columns/default_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,53 @@
end
end

context 'with_passive_columns' do
it 'loads every column including passive when called with no arguments' do
Project.create!(user_id: user.id, name: 'Project', description: 'a description', guidelines: 'g')
project = Project.with_passive_columns.take
expect(project.attributes.keys).to match_array(%w[id name user_id guidelines description settings])
end

it 'lists every table column in the select clause when called with no arguments' do
expect(Project.with_passive_columns.to_sql).to eq(
'SELECT "projects"."id", "projects"."user_id", "projects"."name", ' \
'"projects"."description", "projects"."guidelines", "projects"."settings" FROM "projects"'
)
end

it 'adds only listed passive columns to the default select' do
Project.create!(user_id: user.id, name: 'Project', description: 'a description', guidelines: 'g')
project = Project.with_passive_columns(:description).take
expect(project.attributes.keys).to match_array(%w[id name user_id description])
expect(project.description).to eq 'a description'
expect(project.guidelines).to eq 'g'
expect(project.attributes.keys).to match_array(%w[id name user_id description guidelines])
end

it 'ignores non-passive column names in the argument list' do
Project.create!(user_id: user.id, name: 'Project', description: 'a description', guidelines: 'g')
sql = Project.with_passive_columns(:name, :description).to_sql
expect(sql).to eq(Project.with_passive_columns(:description).to_sql)
end

it 'behaves like a normal relation when no passive names remain after filtering' do
expect(Project.with_passive_columns(:name, :user_id).to_sql).to eq(Project.all.to_sql)
end

it 'replaces a narrow select with the full column list when with_passive_columns has no arguments' do
expect(Project.select(:id).with_passive_columns.to_sql).to eq(
'SELECT "projects"."id", "projects"."user_id", "projects"."name", ' \
'"projects"."description", "projects"."guidelines", "projects"."settings" FROM "projects"'
)
end

it 'merges with_passive_columns into an association scope' do
Project.create!(user_id: user.id, name: 'Project', description: 'a description', guidelines: 'g')
project = user.projects.merge(Project.with_passive_columns(:description)).take
expect(project.attributes.keys).to match_array(%w[id name user_id description])
end
end

context 'finder methods that work via cached_find_by_statement mechanism' do
it 'retrieves columns except passive ones by find_by' do
Project.create!(id: 1, user_id: user.id, name: 'Project', description: 'a description', guidelines: 'g')
Expand Down Expand Up @@ -97,6 +144,44 @@
end
end

context 'lazy column debug logs' do
let(:log_io) { StringIO.new }

before do
@prev_project_logger = Project.logger
Project.logger = Logger.new(log_io).tap { |l| l.level = Logger::DEBUG }
end

after do
Project.logger = @prev_project_logger
end

it 'logs the same debug line for a passive reader' do
Project.create!(user_id: user.id, name: 'P', description: 'd', guidelines: 'g')
project = Project.select(:id).take!
project.description

expect(log_io.string).to include('[passive_columns] On-demand SQL load of Project#description')
end

it 'logs the same debug line for load_column' do
Project.create!(user_id: user.id, name: 'P', description: 'd', guidelines: 'g')
project = Project.select(:id).take!
project.load_column(:name)

expect(log_io.string).to include('[passive_columns] On-demand SQL load of Project#name')
end

it 'does not log when the logger level is above debug' do
Project.logger = Logger.new(log_io).tap { |l| l.level = Logger::INFO }
Project.create!(user_id: user.id, name: 'P', description: 'd', guidelines: 'g')
project = Project.select(:id).take!
project.description

expect(log_io.string).not_to include('[passive_columns]')
end
end

context 'to_sql' do
it 'does not break uninvolved models' do
expect(User.all.to_sql).to eq('SELECT "users".* FROM "users"')
Expand Down
Loading