Foxy fixtures. Adapter#disable_referential_integrity. Closes #9981.

git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@8036 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
This commit is contained in:
Jeremy Kemper 2007-10-26 05:56:46 +00:00
parent 742694e0eb
commit 49eafd8c36
16 changed files with 497 additions and 18 deletions

@ -1,5 +1,14 @@
*SVN*
* Foxy fixtures, from rathole (http://svn.geeksomnia.com/rathole/trunk/README)
- stable, autogenerated IDs
- specify associations (belongs_to, has_one, has_many) by label, not ID
- specify HABTM associations as inline lists
- autofill timestamp columns
- support YAML defaults
- fixture label interpolation
Enabled for fixtures that correspond to a model class and don't specify a primary key value. #9981 [jbarnette]
* Add docs explaining how to protect all attributes using attr_accessible with no arguments. Closes #9631 [boone, rmm5t]
* Update add_index documentation to use new options api. Closes #9787 [kamal]

@ -70,6 +70,13 @@ def quote_table_name(name)
name
end
# REFERENTIAL INTEGRITY ====================================
# Override to turn off referential integrity while executing +&block+
def disable_referential_integrity(&block)
yield
end
# CONNECTION MANAGEMENT ====================================
# Is this connection active and ready to perform queries?

@ -224,6 +224,18 @@ def quoted_false
"0"
end
# REFERENTIAL INTEGRITY ====================================
def disable_referential_integrity(&block) #:nodoc:
old = select_value("SELECT @@FOREIGN_KEY_CHECKS")
begin
update("SET FOREIGN_KEY_CHECKS = 0")
yield
ensure
update("SET FOREIGN_KEY_CHECKS = #{old}")
end
end
# CONNECTION MANAGEMENT ====================================

@ -364,6 +364,14 @@ def quoted_date(value) #:nodoc:
end
end
# REFERENTIAL INTEGRITY ====================================
def disable_referential_integrity(&block) #:nodoc:
execute(tables.collect { |name| "ALTER TABLE #{quote_table_name(name)} DISABLE TRIGGER ALL" }.join(";"))
yield
ensure
execute(tables.collect { |name| "ALTER TABLE #{quote_table_name(name)} ENABLE TRIGGER ALL" }.join(";"))
end
# DATABASE STATEMENTS ======================================

@ -215,6 +215,199 @@ class FixtureClassNotFound < ActiveRecord::ActiveRecordError #:nodoc:
# the results of your transaction until Active Record supports nested transactions or savepoints (in progress.)
# 2. Your database does not support transactions. Every Active Record database supports transactions except MySQL MyISAM.
# Use InnoDB, MaxDB, or NDB instead.
#
# = Advanced YAML Fixtures
#
# YAML fixtures that don't specify an ID get some extra features:
#
# * Stable, autogenerated ID's
# * Label references for associations (belongs_to, has_one, has_many)
# * HABTM associations as inline lists
# * Autofilled timestamp columns
# * Fixture label interpolation
# * Support for YAML defaults
#
# == Stable, autogenerated ID's
#
# Here, have a monkey fixture:
#
# george:
# id: 1
# name: George the Monkey
#
# reginald:
# id: 2
# name: Reginald the Pirate
#
# Each of these fixtures has two unique identifiers: one for the database
# and one for the humans. Why don't we generate the primary key instead?
# Hashing each fixture's label yields a consistent ID:
#
# george: # generated id: 503576764
# name: George the Monkey
#
# reginald: # generated id: 324201669
# name: Reginald the Pirate
#
# ActiveRecord looks at the fixture's model class, discovers the correct
# primary key, and generates it right before inserting the fixture
# into the database.
#
# The generated ID for a given label is constant, so we can discover
# any fixture's ID without loading anything, as long as we know the label.
#
# == Label references for associations (belongs_to, has_one, has_many)
#
# Specifying foreign keys in fixtures can be very fragile, not to
# mention difficult to read. Since ActiveRecord can figure out the ID of
# and fixture from its label, you can specify FK's by label instead of ID.
#
# === belongs_to
#
# Let's break out some more monkeys and pirates.
#
# ### in pirates.yml
#
# reginald:
# id: 1
# name: Reginald the Pirate
# monkey_id: 1
#
# ### in monkeys.yml
#
# george:
# id: 1
# name: George the Monkey
# pirate_id: 1
#
# Add a few more monkeys and pirates and break this into multiple files,
# and it gets pretty hard to keep track of what's going on. Let's
# use labels instead of ID's:
#
# ### in pirates.yml
#
# reginald:
# name: Reginald the Pirate
# monkey: george
#
# ### in monkeys.yml
#
# george:
# name: George the Monkey
# pirate: reginald
#
# Pow! All is made clear. ActiveRecord reflects on the fixture's model class,
# finds all the +belongs_to+ associations, and allows you to specify
# a target *label* for the *association* (monkey: george) rather than
# a target *id* for the *FK* (monkey_id: 1).
#
# === has_and_belongs_to_many
#
# Time to give our monkey some fruit.
#
# ### in monkeys.yml
#
# george:
# id: 1
# name: George the Monkey
# pirate_id: 1
#
# ### in fruits.yml
#
# apple:
# id: 1
# name: apple
#
# orange:
# id: 2
# name: orange
#
# grape:
# id: 3
# name: grape
#
# ### in fruits_monkeys.yml
#
# apple_george:
# fruit_id: 1
# monkey_id: 1
#
# orange_george:
# fruit_id: 2
# monkey_id: 1
#
# grape_george:
# fruit_id: 3
# monkey_id: 1
#
# Let's make the HABTM fixture go away.
#
# ### in monkeys.yml
#
# george:
# name: George the Monkey
# pirate: reginald
# fruits: apple, orange, grape
#
# ### in fruits.yml
#
# apple:
# name: apple
#
# orange:
# name: orange
#
# grape:
# name: grape
#
# Zap! No more fruits_monkeys.yml file. We've specified the list of fruits
# on George's fixture, but we could've just as easily specified a list
# of monkeys on each fruit. As with +belongs_to+, ActiveRecord reflects on
# the fixture's model class and discovers the +has_and_belongs_to_many+
# associations.
#
# == Autofilled timestamp columns
#
# If your table/model specifies any of ActiveRecord's
# standard timestamp columns (created_at, created_on, updated_at, updated_on),
# they will automatically be set to Time.now.
#
# If you've set specific values, they'll be left alone.
#
# == Fixture label interpolation
#
# The label of the current fixture is always available as a column value:
#
# geeksomnia:
# name: Geeksomnia's Account
# subdomain: $LABEL
#
# Also, sometimes (like when porting older join table fixtures) you'll need
# to be able to get ahold of the identifier for a given label. ERB
# to the rescue:
#
# george_reginald:
# monkey_id: <%= Fixtures.identify(:reginald) %>
# pirate_id: <%= Fixtures.identify(:george) %>
#
# == Support for YAML defaults
#
# You probably already know how to use YAML to set and reuse defaults in
# your +database.yml+ file,. You can use the same technique in your fixtures:
#
# DEFAULTS: &DEFAULTS
# created_on: <%= 3.weeks.ago.to_s(:db) %>
#
# first:
# name: Smurf
# <<: *DEFAULTS
#
# second:
# name: Fraggle
# <<: *DEFAULTS
#
# Any fixture labeled "DEFAULTS" is safely ignored.
class Fixtures < YAML::Omap
DEFAULT_FILTER_RE = /\.ya?ml$/
@ -279,32 +472,41 @@ def self.create_fixtures(fixtures_directory, table_names, class_names = {})
unless table_names_to_fetch.empty?
ActiveRecord::Base.silence do
fixtures_map = {}
connection.disable_referential_integrity do
fixtures_map = {}
fixtures = table_names_to_fetch.map do |table_name|
fixtures_map[table_name] = Fixtures.new(connection, File.split(table_name.to_s).last, class_names[table_name.to_sym], File.join(fixtures_directory, table_name.to_s))
end
fixtures = table_names_to_fetch.map do |table_name|
fixtures_map[table_name] = Fixtures.new(connection, File.split(table_name.to_s).last, class_names[table_name.to_sym], File.join(fixtures_directory, table_name.to_s))
end
all_loaded_fixtures.update(fixtures_map)
all_loaded_fixtures.update(fixtures_map)
connection.transaction(Thread.current['open_transactions'].to_i == 0) do
fixtures.reverse.each { |fixture| fixture.delete_existing_fixtures }
fixtures.each { |fixture| fixture.insert_fixtures }
connection.transaction(Thread.current['open_transactions'].to_i == 0) do
fixtures.reverse.each { |fixture| fixture.delete_existing_fixtures }
fixtures.each { |fixture| fixture.insert_fixtures }
# Cap primary key sequences to max(pk).
if connection.respond_to?(:reset_pk_sequence!)
table_names.each do |table_name|
connection.reset_pk_sequence!(table_name)
# Cap primary key sequences to max(pk).
if connection.respond_to?(:reset_pk_sequence!)
table_names.each do |table_name|
connection.reset_pk_sequence!(table_name)
end
end
end
end
cache_fixtures(connection, fixtures)
cache_fixtures(connection, fixtures)
end
end
end
cached_fixtures(connection, table_names)
end
# Returns a consistent identifier for +label+. This will always
# be a positive integer, and will always be the same for a given
# label, assuming the same OS, platform, and version of Ruby.
def self.identify(label)
label.to_s.hash.abs
end
attr_reader :table_name
def initialize(connection, table_name, class_name, fixture_path, file_filter = DEFAULT_FILTER_RE)
@ -322,12 +524,90 @@ def delete_existing_fixtures
end
def insert_fixtures
values.each do |fixture|
now = ActiveRecord::Base.default_timezone == :utc ? Time.now.utc : Time.now
now = now.to_s(:db)
# allow a standard key to be used for doing defaults in YAML
delete(assoc("DEFAULTS"))
# track any join tables we need to insert later
habtm_fixtures = Hash.new do |h, habtm|
h[habtm] = HabtmFixtures.new(@connection, habtm.options[:join_table], nil, nil)
end
each do |label, fixture|
row = fixture.to_hash
if model_class && model_class < ActiveRecord::Base && !row[primary_key_name]
# fill in timestamp columns if they aren't specified
timestamp_column_names.each do |name|
row[name] = now unless row.key?(name)
end
# interpolate the fixture label
row.each do |key, value|
row[key] = label if value == "$LABEL"
end
# generate a primary key
row[primary_key_name] = Fixtures.identify(label)
model_class.reflect_on_all_associations.each do |association|
case association.macro
when :belongs_to
if value = row.delete(association.name.to_s)
fk_name = (association.options[:foreign_key] || "#{association.name}_id").to_s
row[fk_name] = Fixtures.identify(value)
end
when :has_and_belongs_to_many
if (targets = row.delete(association.name.to_s))
targets = targets.is_a?(Array) ? targets : targets.split(/\s*,\s*/)
join_fixtures = habtm_fixtures[association]
targets.each do |target|
join_fixtures["#{label}_#{target}"] = Fixture.new(
{ association.primary_key_name => Fixtures.identify(label),
association.association_foreign_key => Fixtures.identify(target) }, nil)
end
end
end
end
end
@connection.insert_fixture(fixture, @table_name)
end
# insert any HABTM join tables we discovered
habtm_fixtures.values.each do |fixture|
fixture.delete_existing_fixtures
fixture.insert_fixtures
end
end
private
class HabtmFixtures < ::Fixtures #:nodoc:
def read_fixture_files; end
end
def model_class
@model_class ||= @class_name.is_a?(Class) ?
@class_name : @class_name.constantize rescue nil
end
def primary_key_name
@primary_key_name ||= model_class && model_class.primary_key
end
def timestamp_column_names
@timestamp_column_names ||= %w(created_at created_on updated_at updated_on).select do |name|
column_names.include?(name)
end
end
def column_names
@column_names ||= @connection.columns(@table_name).collect(&:name)
end
def read_fixture_files
if File.file?(yaml_file_path)
read_yaml_fixture_files

@ -252,9 +252,9 @@ def test_eager_with_has_many_and_limit_and_scoped_and_explicit_conditions_on_the
end
def test_eager_with_scoped_order_using_association_limiting_without_explicit_scope
posts_with_explicit_order = Post.find(:all, :conditions => 'comments.id', :include => :comments, :order => 'posts.id DESC', :limit => 2)
posts_with_explicit_order = Post.find(:all, :conditions => 'comments.id is not null', :include => :comments, :order => 'posts.id DESC', :limit => 2)
posts_with_scoped_order = Post.with_scope(:find => {:order => 'posts.id DESC'}) do
Post.find(:all, :conditions => 'comments.id', :include => :comments, :limit => 2)
Post.find(:all, :conditions => 'comments.id is not null', :include => :comments, :limit => 2)
end
assert_equal posts_with_explicit_order, posts_with_scoped_order
end

@ -548,7 +548,7 @@ def test_find_string_ids_when_using_finder_sql
client_ary = firm.clients_using_finder_sql.find("2", "3")
assert_kind_of Array, client_ary
assert_equal 2, client_ary.size
assert_equal client, client_ary.first
assert client_ary.include?(client)
end
def test_find_all

@ -295,4 +295,33 @@ def create_table(*args, &block)
t.column :city, :string, :null => false
t.column :type, :string
end
create_table :parrots, :force => true do |t|
t.column :name, :string
t.column :created_at, :datetime
t.column :created_on, :datetime
t.column :updated_at, :datetime
t.column :updated_on, :datetime
end
create_table :pirates, :force => true do |t|
t.column :catchphrase, :string
t.column :parrot_id, :integer
t.column :created_on, :datetime
t.column :updated_on, :datetime
end
create_table :parrots_pirates, :id => false, :force => true do |t|
t.column :parrot_id, :integer
t.column :pirate_id, :integer
end
create_table :treasures, :force => true do |t|
t.column :name, :string
end
create_table :parrots_treasures, :id => false, :force => true do |t|
t.column :parrot_id, :integer
t.column :treasure_id, :integer
end
end

4
activerecord/test/fixtures/parrot.rb vendored Normal file

@ -0,0 +1,4 @@
class Parrot < ActiveRecord::Base
has_and_belongs_to_many :pirates
has_and_belongs_to_many :treasures
end

16
activerecord/test/fixtures/parrots.yml vendored Normal file

@ -0,0 +1,16 @@
george:
name: "Curious George"
treasures: diamond, sapphire
louis:
name: "King Louis"
treasures: [diamond, sapphire]
frederick:
name: $LABEL
DEFAULTS: &DEFAULTS
treasures: sapphire, ruby
davey:
<<: *DEFAULTS

@ -0,0 +1,7 @@
george_blackbeard:
parrot_id: <%= Fixtures.identify(:george) %>
pirate_id: <%= Fixtures.identify(:blackbeard) %>
louis_blackbeard:
parrot_id: <%= Fixtures.identify(:louis) %>
pirate_id: <%= Fixtures.identify(:blackbeard) %>

4
activerecord/test/fixtures/pirate.rb vendored Normal file

@ -0,0 +1,4 @@
class Pirate < ActiveRecord::Base
belongs_to :parrot
has_and_belongs_to_many :parrots
end

@ -0,0 +1,9 @@
blackbeard:
catchphrase: "Yar."
parrot: george
redbeard:
catchphrase: "Avast!"
parrot: louis
created_on: <%= 2.weeks.ago.to_s(:db) %>
updated_on: <%= 2.weeks.ago.to_s(:db) %>

@ -0,0 +1,3 @@
class Treasure < ActiveRecord::Base
has_and_belongs_to_many :parrots
end

@ -0,0 +1,8 @@
diamond:
name: $LABEL
sapphire:
name: $LABEL
ruby:
name: $LABEL

@ -7,6 +7,9 @@
require 'fixtures/joke'
require 'fixtures/course'
require 'fixtures/category'
require 'fixtures/parrot'
require 'fixtures/pirate'
require 'fixtures/treasure'
class FixturesTest < Test::Unit::TestCase
self.use_instantiated_fixtures = true
@ -446,3 +449,83 @@ def test_cache
assert_equal 'Welcome to the weblog', posts(:welcome).title
end
end
class FoxyFixturesTest < Test::Unit::TestCase
fixtures :parrots, :parrots_pirates, :pirates, :treasures
def test_identifies_strings
assert_equal(Fixtures.identify("foo"), Fixtures.identify("foo"))
assert_not_equal(Fixtures.identify("foo"), Fixtures.identify("FOO"))
end
def test_identifies_symbols
assert_equal(Fixtures.identify(:foo), Fixtures.identify(:foo))
end
TIMESTAMP_COLUMNS = %w(created_at created_on updated_at updated_on)
def test_populates_timestamp_columns
TIMESTAMP_COLUMNS.each do |property|
assert_not_nil(parrots(:george).send(property), "should set #{property}")
end
end
def test_populates_all_columns_with_the_same_time
last = nil
TIMESTAMP_COLUMNS.each do |property|
current = parrots(:george).send(property)
last ||= current
assert_equal(last, current)
last = current
end
end
def test_only_populates_columns_that_exist
assert_not_nil(pirates(:blackbeard).created_on)
assert_not_nil(pirates(:blackbeard).updated_on)
end
def test_preserves_existing_fixture_data
assert_equal(2.weeks.ago.to_date, pirates(:redbeard).created_on.to_date)
assert_equal(2.weeks.ago.to_date, pirates(:redbeard).updated_on.to_date)
end
def test_generates_unique_ids
assert_not_nil(parrots(:george).id)
assert_not_equal(parrots(:george).id, parrots(:louis).id)
end
def test_resolves_belongs_to_symbols
assert_equal(parrots(:george), pirates(:blackbeard).parrot)
end
def test_supports_join_tables
assert(pirates(:blackbeard).parrots.include?(parrots(:george)))
assert(pirates(:blackbeard).parrots.include?(parrots(:louis)))
assert(parrots(:george).pirates.include?(pirates(:blackbeard)))
end
def test_supports_inline_habtm
assert(parrots(:george).treasures.include?(treasures(:diamond)))
assert(parrots(:george).treasures.include?(treasures(:sapphire)))
assert(!parrots(:george).treasures.include?(treasures(:ruby)))
end
def test_supports_yaml_arrays
assert(parrots(:louis).treasures.include?(treasures(:diamond)))
assert(parrots(:louis).treasures.include?(treasures(:sapphire)))
end
def test_strips_DEFAULTS_key
assert_raise(StandardError) { parrots(:DEFAULTS) }
# this lets us do YAML defaults and not have an extra fixture entry
%w(sapphire ruby).each { |t| assert(parrots(:davey).treasures.include?(treasures(t))) }
end
def test_supports_label_interpolation
assert_equal("frederick", parrots(:frederick).name)
end
end