From 053e03f7ef148339ddc2395b8223735381ff975a Mon Sep 17 00:00:00 2001 From: Dmitrii Golovin Date: Mon, 11 May 2026 11:01:52 -0300 Subject: [PATCH 1/3] fix ignore rules --- .gitignore | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 7d30e1f..28703a8 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ @@ -14,3 +9,7 @@ coverage/ doc/ .yardoc/ .ruby-version +.idea/ +passive_columns-*.gem +spec/dummy/log/*.log +spec/dummy/tmp/ From 1c9026f2c6505a2b9259fa4bf0b43d6d1af6374b Mon Sep 17 00:00:00 2001 From: Dmitrii Golovin Date: Mon, 11 May 2026 19:30:48 -0300 Subject: [PATCH 2/3] Add with_passive_columns scope to eager-load passive columns in SELECT --- README.md | 21 +++++++++++++ lib/passive_columns.rb | 29 +++++++++++++++++ spec/passive_columns/default_spec.rb | 47 ++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/README.md b/README.md index 26549e6..52969fc 100644 --- a/README.md +++ b/README.md @@ -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)`). + --- diff --git a/lib/passive_columns.rb b/lib/passive_columns.rb index 02be481..03ed531 100644 --- a/lib/passive_columns.rb +++ b/lib/passive_columns.rb @@ -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] 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 diff --git a/spec/passive_columns/default_spec.rb b/spec/passive_columns/default_spec.rb index 28930ae..39d3769 100644 --- a/spec/passive_columns/default_spec.rb +++ b/spec/passive_columns/default_spec.rb @@ -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') From 62e548ce54b988444c2eb5faa01481089491f797 Mon Sep 17 00:00:00 2001 From: Dmitrii Golovin Date: Mon, 11 May 2026 20:28:35 -0300 Subject: [PATCH 3/3] Add debug logs for lazy column loads --- README.md | 3 +++ lib/passive_columns/loader.rb | 15 ++++++++++- spec/passive_columns/default_spec.rb | 38 ++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 52969fc..a183566 100644 --- a/README.md +++ b/README.md @@ -86,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 diff --git a/lib/passive_columns/loader.rb b/lib/passive_columns/loader.rb index ef76f02..1157d09 100644 --- a/lib/passive_columns/loader.rb +++ b/lib/passive_columns/loader.rb @@ -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) diff --git a/spec/passive_columns/default_spec.rb b/spec/passive_columns/default_spec.rb index 39d3769..dba2e40 100644 --- a/spec/passive_columns/default_spec.rb +++ b/spec/passive_columns/default_spec.rb @@ -144,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"')