more rspec

This commit is contained in:
Dylan Knutson
2025-07-15 07:22:38 +00:00
parent 239afcbadb
commit fda95fb33f
7 changed files with 136 additions and 140 deletions

View File

@@ -107,6 +107,8 @@ class E621Post < Post
belongs_to :creator, class_name: "E621User", inverse_of: :created_posts belongs_to :creator, class_name: "E621User", inverse_of: :created_posts
end end
puts FaPost.inspect
fa_user = FaUser.create!(username: "Alice", url_name: "alice") fa_user = FaUser.create!(username: "Alice", url_name: "alice")
fa_user_id = fa_user.id fa_user_id = fa_user.id
raise if fa_user.id.nil? raise if fa_user.id.nil?

View File

@@ -8,9 +8,9 @@ require "active_support"
require "active_support/concern" require "active_support/concern"
require "active_model/attribute_set" require "active_model/attribute_set"
require_relative "has_aux_table/util"
require_relative "has_aux_table/relation_extensions" require_relative "has_aux_table/relation_extensions"
require_relative "has_aux_table/aux_table_config" require_relative "has_aux_table/aux_table_config"
require_relative "has_aux_table/query_extensions"
require_relative "has_aux_table/migration_extensions" require_relative "has_aux_table/migration_extensions"
module HasAuxTable module HasAuxTable
@@ -22,7 +22,6 @@ module HasAuxTable
module ClassMethods module ClassMethods
extend T::Sig extend T::Sig
include QueryExtensions
include RelationExtensions include RelationExtensions
# Main DSL method for defining auxiliary tables # Main DSL method for defining auxiliary tables
@@ -41,8 +40,9 @@ module HasAuxTable
@aux_table_configs[aux_table_name] = aux_config = @aux_table_configs[aux_table_name] = aux_config =
generate_aux_config(aux_table_name) generate_aux_config(aux_table_name)
setup_attribute_types_hook!(aux_config)
setup_columns_hook!(aux_config)
setup_schema_loading_hook!(aux_config) setup_schema_loading_hook!(aux_config)
# setup_query_extensions!(self, aux_config, with_bind_attribute: false)
setup_relation_extensions!(aux_config) setup_relation_extensions!(aux_config)
aux_config aux_config
@@ -60,6 +60,42 @@ module HasAuxTable
private private
sig { params(aux_config: AuxTableConfig).void }
def setup_attribute_types_hook!(aux_config)
original_method = aux_config.main_class.method(:attribute_types)
aux_config
.main_class
.define_singleton_method(:attribute_types) do
@aux_config_attribute_types_cache ||= {}
@aux_config_attribute_types_cache[aux_config.table_name] ||= begin
original_types = original_method.call.dup
aux_types =
aux_config.model_class.attribute_types.filter do |k, _|
aux_config.is_aux_column?(k)
end
original_types.merge!(aux_types)
original_types
end
end
end
sig { params(aux_config: AuxTableConfig).void }
def setup_columns_hook!(aux_config)
# original_method = aux_config.main_class.method(:columns)
# aux_config
# .main_class
# .define_singleton_method(:columns) do
# original_columns = original_method.call
# aux_columns =
# aux_config.model_class.columns.filter do |col|
# aux_config.is_aux_column?(col.name)
# end
# original_columns + aux_columns
# end
end
# Hook into schema loading to generate attribute accessors when schema is loaded # Hook into schema loading to generate attribute accessors when schema is loaded
sig { params(aux_config: AuxTableConfig).void } sig { params(aux_config: AuxTableConfig).void }
def setup_schema_loading_hook!(aux_config) def setup_schema_loading_hook!(aux_config)
@@ -72,13 +108,10 @@ module HasAuxTable
result = load_schema_method.call result = load_schema_method.call
aux_config.load_aux_schema aux_config.load_aux_schema
# `columns_hash` is populated by `load_schema!` so we can use it to
# validate no column overlaps between main table and auxiliary table
main_columns_hash = self.columns_hash main_columns_hash = self.columns_hash
aux_columns_hash = aux_columns_hash =
aux_config.model_class.columns_hash.reject do |col| aux_config.model_class.columns_hash.select do |col|
%w[id created_at updated_at].include?(col) || aux_config.is_aux_column?(col)
col == aux_config.foreign_key.to_s
end end
main_column_names = main_columns_hash.keys main_column_names = main_columns_hash.keys
@@ -99,6 +132,7 @@ module HasAuxTable
end end
.to_h .to_h
# set attributes that exist on the aux table to also exist on this table
aux_table_filtered_attributes.each do |name, attr| aux_table_filtered_attributes.each do |name, attr|
@default_attributes[name] = attr @default_attributes[name] = attr
end end
@@ -210,7 +244,7 @@ module HasAuxTable
# Get the base class name for the foreign key (e.g., Vehicle -> vehicle_id) # Get the base class name for the foreign key (e.g., Vehicle -> vehicle_id)
# In STI, all subclasses share the same table, so we need the base class # In STI, all subclasses share the same table, so we need the base class
foreign_key = "base_table_id".to_sym foreign_key = :base_table_id
# Get the current class for the association # Get the current class for the association
main_class = self main_class = self
@@ -222,7 +256,7 @@ module HasAuxTable
Class.new(ActiveRecord::Base) do Class.new(ActiveRecord::Base) do
# Set the table name # Set the table name
self.table_name = table_name.to_s self.table_name = table_name.to_s
self.primary_key = "base_table_id" self.primary_key = foreign_key
# Define the association back to the specific STI subclass # Define the association back to the specific STI subclass
# Foreign key points to base STI table (e.g., vehicle_id) # Foreign key points to base STI table (e.g., vehicle_id)
@@ -246,15 +280,9 @@ module HasAuxTable
dependent: :destroy dependent: :destroy
) )
# so the aux table is joined against the main table
self.default_scope { eager_load(aux_association_name) } self.default_scope { eager_load(aux_association_name) }
after_create do
aux_model = association(aux_association_name).target
aux_model.base_table_id = self.id
aux_model.save!
true
end
# Set the constant to make the class accessible # Set the constant to make the class accessible
Object.const_set(aux_class_name, aux_class) Object.const_set(aux_class_name, aux_class)

View File

@@ -2,7 +2,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module HasAuxTable module HasAuxTable
# AuxTable class to store auxiliary table definition
class AuxTableConfig < T::Struct class AuxTableConfig < T::Struct
extend T::Sig extend T::Sig
@@ -61,7 +60,8 @@ module HasAuxTable
).returns(ActiveRecord::Relation) ).returns(ActiveRecord::Relation)
end end
def apply_split_conditions!(relation, conditions) def apply_split_conditions!(relation, conditions)
main_conditions, aux_conditions = self.split_conditions(conditions) main_conditions, aux_conditions =
self.partition_by_aux_columns(conditions)
relation = relation.where(main_conditions) if main_conditions.any? relation = relation.where(main_conditions) if main_conditions.any?
if aux_conditions.any? if aux_conditions.any?
relation = relation.where(aux_association_name => aux_conditions) relation = relation.where(aux_association_name => aux_conditions)
@@ -69,22 +69,13 @@ module HasAuxTable
relation relation
end end
sig do
params(conditions: T::Hash[String, T.untyped]).returns(
[T::Hash[String, T.untyped], T::Hash[String, T.untyped]]
)
end
def split_conditions(conditions)
conditions.partition { |k, _| !self.is_aux_column?(k) }.map(&:to_h)
end
sig do sig do
params(conditions: T::Hash[String, T.untyped]).returns( params(conditions: T::Hash[String, T.untyped]).returns(
T::Hash[String, T.untyped] T::Hash[String, T.untyped]
) )
end end
def remap_conditions(conditions) def remap_conditions(conditions)
main, aux = split_conditions(conditions) main, aux = partition_by_aux_columns(conditions)
main.merge!(aux_association_name => aux) if aux.any? main.merge!(aux_association_name => aux) if aux.any?
main main
end end
@@ -100,25 +91,38 @@ module HasAuxTable
aux_model.assign_attributes(aux_args) aux_model.assign_attributes(aux_args)
end end
sig { returns(T::Array[Symbol]) } sig { returns(T::Array[String]) }
def aux_column_names def aux_column_names
@aux_column_names ||= @aux_column_names ||=
model_class begin
.column_names rejected_columns = [
.reject do |col| self.foreign_key,
[ self.primary_key,
self.foreign_key, "created_at",
:base_table_id, "updated_at"
:created_at, ].flatten.map(&:to_s)
:updated_at
].flatten.include?(col.to_sym) model_class
end .column_names
.map(&:to_sym) .reject { |col| rejected_columns.include?(col.to_s) }
.map(&:to_s)
end
end end
sig { params(name: T.any(Symbol, String)).returns(T::Boolean) } sig { params(name: T.any(Symbol, String)).returns(T::Boolean) }
def is_aux_column?(name) def is_aux_column?(name)
aux_column_names.include?(name.to_sym) aux_column_names.include?(name.to_s)
end
private
sig do
params(hash: T::Hash[String, T.untyped]).returns(
[T::Hash[String, T.untyped], T::Hash[String, T.untyped]]
)
end
def partition_by_aux_columns(hash)
hash.partition { |k, _| !self.is_aux_column?(k) }.map(&:to_h)
end end
end end
end end

View File

@@ -1,73 +0,0 @@
# typed: false
# frozen_string_literal: true
module HasAuxTable
module QueryExtensions
extend T::Sig
sig do
params(
on: T.any(ActiveRecord::Relation, T.class_of(ActiveRecord::Base)),
aux_config: AuxTableConfig,
with_bind_attribute: T::Boolean
).void
end
def setup_query_extensions!(on, aux_config, with_bind_attribute: true)
# association_name = aux_config.aux_association_name
where_method = on.method(:where)
on.define_singleton_method(:where) do |args|
if args.is_a?(Hash)
main_conditions, aux_conditions = aux_config.split_conditions(args)
combined_conditions =
main_conditions.merge(
aux_config.aux_association_name => aux_conditions
)
where_method.call(combined_conditions)
else
super(args)
end
end
all_method = on.method(:all)
on.define_singleton_method(:all) do
all_method.call.eager_load(aux_config.aux_association_name)
end
unscoped_method = on.method(:unscoped)
on.define_singleton_method(:unscoped) do
relation = unscoped_method.call
self.setup_query_extensions!(relation, aux_config)
relation
end
if with_bind_attribute
bind_attribute_method = on.method(:bind_attribute)
on.define_singleton_method(:bind_attribute) do |name, value, &block|
if aux_config.is_aux_column?(name)
aux_config.aux_bind_attribute(name, value, &block)
else
bind_attribute_method.call(name, value, &block)
end
end
end
find_by_method = on.method(:find_by)
on.define_singleton_method(:find_by) do |args|
main_conditions, aux_conditions = aux_config.split_conditions(args)
combined_conditions = main_conditions
if aux_conditions.any?
combined_conditions.merge!(
aux_config.aux_association_name => aux_conditions
)
end
find_by_method.call(combined_conditions)
end
on.define_singleton_method(:exists?) do |args|
relation = self.select("1").joins(aux_config.aux_association_name)
relation = aux_config.apply_split_conditions!(relation, args)
relation.first.present?
end
end
end
end

View File

@@ -4,36 +4,18 @@
module HasAuxTable module HasAuxTable
module RelationExtensions module RelationExtensions
extend T::Sig extend T::Sig
Util = HasAuxTable::Util
sig { params(aux_config: AuxTableConfig).void } sig { params(aux_config: AuxTableConfig).void }
def setup_relation_extensions!(aux_config) def setup_relation_extensions!(aux_config)
setup_main_class_extensions!(aux_config) setup_main_class_extensions!(aux_config)
end end
def hook_method(target, method_name, is_instance_method, &hook_block)
define_method =
is_instance_method ? :define_method : :define_singleton_method
target_method =
(
if is_instance_method
target.instance_method(method_name)
else
target.method(method_name)
end
)
target.send(define_method, method_name) do |*args, **kwargs, &block|
method = is_instance_method ? target_method.bind(self) : target_method
hook_block.call(method, *args, **kwargs, &block)
end
end
sig { params(aux_config: AuxTableConfig).void } sig { params(aux_config: AuxTableConfig).void }
def setup_main_class_extensions!(aux_config) def setup_main_class_extensions!(aux_config)
main_class = aux_config.main_class main_class = aux_config.main_class
hook_method(main_class, :where, false) do |original, *args| Util.hook_method(main_class, :where, false) do |original, *args|
if args.length == 1 && args.first.is_a?(Hash) if args.length == 1 && args.first.is_a?(Hash)
opts_remapped = aux_config.remap_conditions(args.first) opts_remapped = aux_config.remap_conditions(args.first)
original.call(opts_remapped) original.call(opts_remapped)
@@ -42,7 +24,7 @@ module HasAuxTable
end end
end end
hook_method( Util.hook_method(
main_class, main_class,
:all, :all,
false false
@@ -52,20 +34,24 @@ module HasAuxTable
) )
end end
hook_method(main_class, :unscoped, false) do |original, *args, **kwargs| Util.hook_method(
main_class,
:unscoped,
false
) do |original, *args, **kwargs|
original.call(*args, **kwargs).eager_load( original.call(*args, **kwargs).eager_load(
aux_config.aux_association_name aux_config.aux_association_name
) )
end end
hook_method(main_class, :find, false) do |original, arg| Util.hook_method(main_class, :find, false) do |original, arg|
original.call(arg) original.call(arg)
end end
relation_class = relation_class =
main_class.relation_delegate_class(ActiveRecord::Relation) main_class.relation_delegate_class(ActiveRecord::Relation)
hook_method(relation_class, :where!, true) do |original, opts, *rest| Util.hook_method(relation_class, :where!, true) do |original, opts, *rest|
if opts.is_a?(Hash) if opts.is_a?(Hash)
opts_remapped = aux_config.remap_conditions(opts) opts_remapped = aux_config.remap_conditions(opts)
original.call(opts_remapped, *rest) original.call(opts_remapped, *rest)
@@ -74,7 +60,7 @@ module HasAuxTable
end end
end end
hook_method( Util.hook_method(
relation_class, relation_class,
:bind_attribute, :bind_attribute,
true true

39
lib/has_aux_table/util.rb Normal file
View File

@@ -0,0 +1,39 @@
# typed: true
# frozen_string_literal: true
module HasAuxTable
module Util
extend T::Sig
sig do
params(
target:
T.any(
T.class_of(ActiveRecord::Base),
T.class_of(ActiveRecord::Relation)
),
method_name: Symbol,
is_instance_method: T::Boolean,
hook_block: T.proc.void
).void
end
def self.hook_method(target, method_name, is_instance_method, &hook_block)
define_method =
is_instance_method ? :define_method : :define_singleton_method
target_method =
(
if is_instance_method
target.instance_method(method_name)
else
target.method(method_name)
end
)
target.send(define_method, method_name) do |*args, **kwargs, &block|
method = is_instance_method ? target_method.bind(self) : target_method
T.unsafe(hook_block).call(method, *args, **kwargs, &block)
end
end
end
end

View File

@@ -129,6 +129,16 @@ RSpec.describe HasAuxTable do
expect(Vehicle.count).to eq(0) expect(Vehicle.count).to eq(0)
end end
describe "column reporting" do
it "reports the correct columns on the string repr of the class" do
expect(Car.inspect).to include("fuel_type")
end
it "does not include the aux table foreign key" do
expect(Car.inspect).not_to include("base_table_id")
end
end
describe "database integration" do describe "database integration" do
it "provides automatic attribute accessors for auxiliary table columns" do it "provides automatic attribute accessors for auxiliary table columns" do
vehicle = Car.create!(name: "Honda Civic") vehicle = Car.create!(name: "Honda Civic")