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
end
puts FaPost.inspect
fa_user = FaUser.create!(username: "Alice", url_name: "alice")
fa_user_id = fa_user.id
raise if fa_user.id.nil?

View File

@@ -8,9 +8,9 @@ require "active_support"
require "active_support/concern"
require "active_model/attribute_set"
require_relative "has_aux_table/util"
require_relative "has_aux_table/relation_extensions"
require_relative "has_aux_table/aux_table_config"
require_relative "has_aux_table/query_extensions"
require_relative "has_aux_table/migration_extensions"
module HasAuxTable
@@ -22,7 +22,6 @@ module HasAuxTable
module ClassMethods
extend T::Sig
include QueryExtensions
include RelationExtensions
# Main DSL method for defining auxiliary tables
@@ -41,8 +40,9 @@ module HasAuxTable
@aux_table_configs[aux_table_name] = aux_config =
generate_aux_config(aux_table_name)
setup_attribute_types_hook!(aux_config)
setup_columns_hook!(aux_config)
setup_schema_loading_hook!(aux_config)
# setup_query_extensions!(self, aux_config, with_bind_attribute: false)
setup_relation_extensions!(aux_config)
aux_config
@@ -60,6 +60,42 @@ module HasAuxTable
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
sig { params(aux_config: AuxTableConfig).void }
def setup_schema_loading_hook!(aux_config)
@@ -72,13 +108,10 @@ module HasAuxTable
result = load_schema_method.call
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
aux_columns_hash =
aux_config.model_class.columns_hash.reject do |col|
%w[id created_at updated_at].include?(col) ||
col == aux_config.foreign_key.to_s
aux_config.model_class.columns_hash.select do |col|
aux_config.is_aux_column?(col)
end
main_column_names = main_columns_hash.keys
@@ -99,6 +132,7 @@ module HasAuxTable
end
.to_h
# set attributes that exist on the aux table to also exist on this table
aux_table_filtered_attributes.each do |name, attr|
@default_attributes[name] = attr
end
@@ -210,7 +244,7 @@ module HasAuxTable
# 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
foreign_key = "base_table_id".to_sym
foreign_key = :base_table_id
# Get the current class for the association
main_class = self
@@ -222,7 +256,7 @@ module HasAuxTable
Class.new(ActiveRecord::Base) do
# Set the table name
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
# Foreign key points to base STI table (e.g., vehicle_id)
@@ -246,15 +280,9 @@ module HasAuxTable
dependent: :destroy
)
# so the aux table is joined against the main table
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
Object.const_set(aux_class_name, aux_class)

View File

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

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 RelationExtensions
extend T::Sig
Util = HasAuxTable::Util
sig { params(aux_config: AuxTableConfig).void }
def setup_relation_extensions!(aux_config)
setup_main_class_extensions!(aux_config)
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 }
def setup_main_class_extensions!(aux_config)
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)
opts_remapped = aux_config.remap_conditions(args.first)
original.call(opts_remapped)
@@ -42,7 +24,7 @@ module HasAuxTable
end
end
hook_method(
Util.hook_method(
main_class,
:all,
false
@@ -52,20 +34,24 @@ module HasAuxTable
)
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(
aux_config.aux_association_name
)
end
hook_method(main_class, :find, false) do |original, arg|
Util.hook_method(main_class, :find, false) do |original, arg|
original.call(arg)
end
relation_class =
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)
opts_remapped = aux_config.remap_conditions(opts)
original.call(opts_remapped, *rest)
@@ -74,7 +60,7 @@ module HasAuxTable
end
end
hook_method(
Util.hook_method(
relation_class,
:bind_attribute,
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)
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
it "provides automatic attribute accessors for auxiliary table columns" do
vehicle = Car.create!(name: "Honda Civic")