From 2a970a0346314b509d1f675af442366b23386d42 Mon Sep 17 00:00:00 2001 From: Jonathan PHILIPPE Date: Fri, 3 Jul 2026 15:10:56 +0200 Subject: [PATCH 1/3] build(deps): add activestorage to the test harness - test-only dependency, like actionpack/railties: needed by the integration spec proving association targets traverse attachments --- Gemfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 1cc4059..936a87b 100644 --- a/Gemfile +++ b/Gemfile @@ -18,7 +18,9 @@ source 'https://rubygems.pkg.github.com/fluence-eu' do end # Test harness only (not runtime deps): in-memory DB, ActionController::Parameters -# for the duck-typed params path, and railties to exercise the Railtie auto-include. +# for the duck-typed params path, railties to exercise the Railtie auto-include, +# and activestorage for the has_one_attached association-target integration spec. gem 'actionpack', '>= 7.1' +gem 'activestorage', '>= 7.1' gem 'railties', '>= 7.1' gem 'sqlite3', '>= 2.1' From 17f428ef86c53c0b1c45afc7a994602480b19fd4 Mon Sep 17 00:00:00 2001 From: Jonathan PHILIPPE Date: Fri, 3 Jul 2026 15:11:14 +0200 Subject: [PATCH 2/3] test(activestorage): prove association targets through attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - boots the real engine in a minimal Rails::Application, in the integration spec file only — spec_helper and the rest of the suite stay engine-free - has_one_attached / has_many_attached generate concrete associations from the owner's side, so the ordinary declared paths apply: `{ document_blob: :content_type }`, `{ document_attachment: :created_at }`, DISTINCT through has_many_attached, declarations validator green against the generated associations - the boot re-establishes the database connection, so the business schema moves to spec/support/schema.rb and is rebuilt after boot; DATABASE_URL is pinned to sqlite3 in-memory so a developer's local environment cannot leak into the suite - rubocop: spec support code excluded from AbcSize/MethodLength — schema definitions are declarative, their length tracks the number of tables, not complexity; lib/ stays fully covered --- .rubocop.yml | 10 ++++ spec/active_storage_spec.rb | 116 ++++++++++++++++++++++++++++++++++++ spec/spec_helper.rb | 27 +-------- spec/support/schema.rb | 39 ++++++++++++ 4 files changed, 167 insertions(+), 25 deletions(-) create mode 100644 spec/active_storage_spec.rb create mode 100644 spec/support/schema.rb diff --git a/.rubocop.yml b/.rubocop.yml index 87ff240..d1e4668 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -23,3 +23,13 @@ Metrics/BlockLength: Exclude: - 'spec/**/*' - 'filterable.gemspec' + +# Spec support code is declarative (schema definitions, harness setup): its +# length tracks the number of tables and columns, not complexity. Production +# code under lib/ stays fully covered. +Metrics/AbcSize: + Exclude: + - 'spec/**/*' +Metrics/MethodLength: + Exclude: + - 'spec/**/*' diff --git a/spec/active_storage_spec.rb b/spec/active_storage_spec.rb new file mode 100644 index 0000000..954163f --- /dev/null +++ b/spec/active_storage_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +# Integration proof that association targets traverse the associations +# generated by has_one_attached / has_many_attached. ActiveStorage's generated +# associations are concrete from the owner's side (`document_attachment`, +# `document_blob` through it), so the ordinary declared paths apply — no +# polymorphic hop needed. The real engine is booted here, in this file only, +# so spec_helper and the rest of the suite stay engine-free. +require 'active_storage/engine' + +# Booting the application re-establishes the database connection from this +# URL, replacing the one spec_helper opened — hence the schema rebuild below. +ENV['DATABASE_URL'] = 'sqlite3::memory:' + +# A minimal application so the ActiveStorage engine wires its models and the +# has_one_attached/has_many_attached macros exactly as in a real app. The +# filterable railtie initializers run again on boot — harmless, the includes +# are idempotent. +class FilterableTestApp < Rails::Application + config.eager_load = false + config.active_storage.service_configurations = { 'test' => { 'service' => 'Disk', 'root' => Dir.mktmpdir } } + config.active_storage.service = :test + config.secret_key_base = 'filterable-test' + config.logger = Logger.new(File::NULL) + config.active_support.deprecation = :silence +end +Rails.application.initialize! + +FilterableSchema.define +ActiveRecord::Schema.define do + create_table :active_storage_blobs, force: true do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum + t.datetime :created_at, null: false + t.index [:key], unique: true + end + + create_table :active_storage_attachments, force: true do |t| + t.string :name, null: false + t.string :record_type, null: false + t.bigint :record_id, null: false + t.bigint :blob_id, null: false + t.datetime :created_at, null: false + end + + create_table :contracts, force: true do |t| + t.string :label + end +end + +class Contract < ActiveRecord::Base + has_one_attached :document + has_many_attached :annexes + + datable document_since: { document_attachment: :created_at } + equatable document_type: { document_blob: :content_type }, + annex_type: { annexes_blobs: :content_type } +end + +RSpec.describe 'ActiveStorage integration' do + def blob(content_type) + ActiveStorage::Blob.create!( + key: SecureRandom.base36(28), filename: 'file', content_type: content_type, + byte_size: 1, checksum: 'x', service_name: 'test' + ) + end + + def attach(record, name, content_type) + ActiveStorage::Attachment.create!(name: name, record: record, blob: blob(content_type)) + end + + let!(:pdf_contract) { Contract.create!(label: 'pdf') } + let!(:png_contract) { Contract.create!(label: 'png') } + let!(:bare_contract) { Contract.create!(label: 'bare') } + + before do + attach(pdf_contract, 'document', 'application/pdf') + attach(png_contract, 'document', 'image/png') + end + + it 'filters by equality on the blob content type through has_one_attached' do + result = Contract.filterable(filters: { document_type: 'application/pdf' }) + + expect(result).to contain_exactly(pdf_contract) + end + + it 'drops records without any attachment' do + result = Contract.filterable(filters: { document_type: %w[application/pdf image/png] }) + + expect(result).to contain_exactly(pdf_contract, png_contract) + end + + it 'applies date bounds on the attachment through has_one_attached' do + result = Contract.filterable(filters: { document_since: { since: Date.tomorrow.iso8601 } }) + + expect(result).to contain_exactly(pdf_contract, png_contract) + end + + it 'deduplicates records when filtering through has_many_attached' do + attach(pdf_contract, 'annexes', 'text/csv') + attach(pdf_contract, 'annexes', 'text/csv') + + result = Contract.filterable(filters: { annex_type: 'text/csv' }) + + expect(result.map(&:id)).to eq([pdf_contract.id]) + end + + it 'validates the declarations against the generated associations' do + expect(Contract.filterable_declarations).to be_valid + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index dc20545..f9baaa3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -38,31 +38,8 @@ # and the datable/sortable DSL with no explicit `include`. Filterable::Railtie.instance.run_initializers -ActiveRecord::Schema.verbose = false -ActiveRecord::Schema.define do - create_table :banks, force: true do |t| - t.string :name - end - - create_table :accounts, force: true do |t| - t.string :name - t.integer :balance_cents - t.date :opened_on - t.integer :bank_id - end - - create_table :movement_details, force: true do |t| - t.date :value_date - t.date :booking_date - t.integer :gross_amount_cents - t.string :reference - t.integer :account_id - t.string :attachable_type - t.integer :attachable_id - t.string :source_type - t.integer :source_id - end -end +require_relative 'support/schema' +FilterableSchema.define # Note the absence of any `include`: the Railtie auto-included the engine into # ActiveRecord::Base, so the model only declares its whitelisted columns. diff --git a/spec/support/schema.rb b/spec/support/schema.rb new file mode 100644 index 0000000..ed41504 --- /dev/null +++ b/spec/support/schema.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# The business tables the suite runs against. Extracted so the ActiveStorage +# integration spec can rebuild them after its Rails boot replaces the +# in-memory database connection. +module FilterableSchema + module_function + + # (Re)creates every business table on the current connection. + # + # @return [void] + def define + ActiveRecord::Schema.verbose = false + ActiveRecord::Schema.define do + create_table :banks, force: true do |t| + t.string :name + end + + create_table :accounts, force: true do |t| + t.string :name + t.integer :balance_cents + t.date :opened_on + t.integer :bank_id + end + + create_table :movement_details, force: true do |t| + t.date :value_date + t.date :booking_date + t.integer :gross_amount_cents + t.string :reference + t.integer :account_id + t.string :attachable_type + t.integer :attachable_id + t.string :source_type + t.integer :source_id + end + end + end +end From 40eae17a47768810be140172c059765d8b20b2ba Mon Sep 17 00:00:00 2001 From: Jonathan PHILIPPE Date: Fri, 3 Jul 2026 15:11:29 +0200 Subject: [PATCH 3/3] docs(readme): document ActiveStorage attachment paths --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 3b6d614..7ad0895 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,21 @@ an unknown type narrows nothing and is reported by the validator. The reverse, concrete direction (`Message` → `{ entry: :created_at }` through its `has_one`) is an ordinary path. +#### ActiveStorage + +The associations `has_one_attached` / `has_many_attached` generate are +concrete from the owner's side, so they are ordinary paths — no polymorphic +hop needed: + +```ruby +class Contract < ApplicationRecord + has_one_attached :document + + equatable document_type: { document_blob: :content_type } + datable document_since: { document_attachment: :created_at } +end +``` + ### Default filters A model can declare default filter params, applied whenever the request does