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
10 changes: 10 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**/*'
4 changes: 3 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 116 additions & 0 deletions spec/active_storage_spec.rb
Original file line number Diff line number Diff line change
@@ -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
27 changes: 2 additions & 25 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions spec/support/schema.rb
Original file line number Diff line number Diff line change
@@ -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
Loading