aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--actionpack/test/controller/integration_upload_test.rb65
-rw-r--r--actionpack/test/controller/request/multipart_params_parsing_test.rb167
-rw-r--r--actionpack/test/controller/request/query_string_parsing_test.rb (renamed from actionpack/test/controller/query_string_parsing_test.rb)0
-rw-r--r--actionpack/test/controller/request/url_encoded_params_parsing_test.rb171
-rw-r--r--actionpack/test/controller/request_test.rb305
-rw-r--r--activerecord/CHANGELOG2
-rw-r--r--activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb104
-rwxr-xr-xactiverecord/lib/active_record/connection_adapters/abstract_adapter.rb23
-rw-r--r--activerecord/lib/active_record/connection_adapters/mysql_adapter.rb15
-rw-r--r--activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb55
-rw-r--r--activerecord/lib/active_record/fixtures.rb3
-rw-r--r--activerecord/lib/active_record/transactions.rb66
-rw-r--r--activerecord/test/cases/defaults_test.rb63
-rw-r--r--activerecord/test/cases/helper.rb2
-rw-r--r--activerecord/test/cases/transactions_test.rb167
-rw-r--r--activesupport/CHANGELOG2
-rw-r--r--activesupport/lib/active_support/time_with_zone.rb8
-rw-r--r--activesupport/test/core_ext/time_with_zone_test.rb9
18 files changed, 774 insertions, 453 deletions
diff --git a/actionpack/test/controller/integration_upload_test.rb b/actionpack/test/controller/integration_upload_test.rb
deleted file mode 100644
index d579980c19..0000000000
--- a/actionpack/test/controller/integration_upload_test.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-require 'abstract_unit'
-
-unless defined? ApplicationController
- class ApplicationController < ActionController::Base
- end
-end
-
-class UploadTestController < ActionController::Base
- def update
- SessionUploadTest.last_request_type = ActionController::Base.param_parsers[request.content_type]
- render :text => "got here"
- end
-
- def read
- render :text => "File: #{params[:uploaded_data].read}"
- end
-end
-
-class SessionUploadTest < ActionController::IntegrationTest
- FILES_DIR = File.dirname(__FILE__) + '/../fixtures/multipart'
-
- class << self
- attr_accessor :last_request_type
- end
-
- def test_upload_and_read_file
- with_test_routing do
- post '/read', :uploaded_data => fixture_file_upload(FILES_DIR + "/hello.txt", "text/plain")
- assert_equal "File: Hello", response.body
- end
- end
-
- # The lint wrapper is used in integration tests
- # instead of a normal StringIO class
- InputWrapper = Rack::Lint::InputWrapper
-
- def test_post_with_upload_with_unrewindable_input
- InputWrapper.any_instance.expects(:rewind).raises(Errno::ESPIPE)
-
- with_test_routing do
- post '/read', :uploaded_data => fixture_file_upload(FILES_DIR + "/hello.txt", "text/plain")
- assert_equal "File: Hello", response.body
- end
- end
-
- def test_post_with_upload_with_params_parsing
- with_test_routing do
- params = { :uploaded_data => fixture_file_upload(FILES_DIR + "/mona_lisa.jpg", "image/jpg") }
- post '/update', params, :location => 'blah'
- assert_equal(:multipart_form, SessionUploadTest.last_request_type)
- end
- end
-
- private
- def with_test_routing
- with_routing do |set|
- set.draw do |map|
- map.update 'update', :controller => "upload_test", :action => "update", :method => :post
- map.read 'read', :controller => "upload_test", :action => "read", :method => :post
- end
-
- yield
- end
- end
-end
diff --git a/actionpack/test/controller/request/multipart_params_parsing_test.rb b/actionpack/test/controller/request/multipart_params_parsing_test.rb
new file mode 100644
index 0000000000..03ab164972
--- /dev/null
+++ b/actionpack/test/controller/request/multipart_params_parsing_test.rb
@@ -0,0 +1,167 @@
+require 'abstract_unit'
+
+class MultipartParamsParsingTest < ActionController::IntegrationTest
+ class TestController < ActionController::Base
+ class << self
+ attr_accessor :last_request_parameters, :last_request_type
+ end
+
+ def parse
+ self.class.last_request_type = ActionController::Base.param_parsers[request.content_type]
+ self.class.last_request_parameters = request.request_parameters
+ head :ok
+ end
+
+ def read
+ render :text => "File: #{params[:uploaded_data].read}"
+ end
+ end
+
+ FIXTURE_PATH = File.dirname(__FILE__) + '/../../fixtures/multipart'
+
+ def teardown
+ TestController.last_request_parameters = nil
+ TestController.last_request_type = nil
+ end
+
+ test "parses single parameter" do
+ assert_equal({ 'foo' => 'bar' }, parse_multipart('single_parameter'))
+ end
+
+ test "parses bracketed parameters" do
+ assert_equal({ 'foo' => { 'baz' => 'bar'}}, parse_multipart('bracketed_param'))
+ end
+
+ test "parses text file" do
+ params = parse_multipart('text_file')
+ assert_equal %w(file foo), params.keys.sort
+ assert_equal 'bar', params['foo']
+
+ file = params['file']
+ assert_kind_of StringIO, file
+ assert_equal 'file.txt', file.original_filename
+ assert_equal "text/plain", file.content_type
+ assert_equal 'contents', file.read
+ end
+
+ test "parses boundary problem file" do
+ params = parse_multipart('boundary_problem_file')
+ assert_equal %w(file foo), params.keys.sort
+
+ file = params['file']
+ foo = params['foo']
+
+ assert_kind_of Tempfile, file
+
+ assert_equal 'file.txt', file.original_filename
+ assert_equal "text/plain", file.content_type
+
+ assert_equal 'bar', foo
+ end
+
+ test "parses large text file" do
+ params = parse_multipart('large_text_file')
+ assert_equal %w(file foo), params.keys.sort
+ assert_equal 'bar', params['foo']
+
+ file = params['file']
+
+ assert_kind_of Tempfile, file
+
+ assert_equal 'file.txt', file.original_filename
+ assert_equal "text/plain", file.content_type
+ assert ('a' * 20480) == file.read
+ end
+
+ test "parses binary file" do
+ params = parse_multipart('binary_file')
+ assert_equal %w(file flowers foo), params.keys.sort
+ assert_equal 'bar', params['foo']
+
+ file = params['file']
+ assert_kind_of StringIO, file
+ assert_equal 'file.csv', file.original_filename
+ assert_nil file.content_type
+ assert_equal 'contents', file.read
+
+ file = params['flowers']
+ assert_kind_of StringIO, file
+ assert_equal 'flowers.jpg', file.original_filename
+ assert_equal "image/jpeg", file.content_type
+ assert_equal 19512, file.size
+ end
+
+ test "parses mixed files" do
+ params = parse_multipart('mixed_files')
+ assert_equal %w(files foo), params.keys.sort
+ assert_equal 'bar', params['foo']
+
+ # Ruby CGI doesn't handle multipart/mixed for us.
+ files = params['files']
+ assert_kind_of String, files
+ files.force_encoding('ASCII-8BIT') if files.respond_to?(:force_encoding)
+ assert_equal 19756, files.size
+ end
+
+ test "uploads and parses parameters" do
+ with_test_routing do
+ params = { :uploaded_data => fixture_file_upload(FIXTURE_PATH + "/mona_lisa.jpg", "image/jpg") }
+ post '/parse', params, :location => 'blah'
+ assert_equal(:multipart_form, TestController.last_request_type)
+ end
+ end
+
+ test "uploads and reads file" do
+ with_test_routing do
+ post '/read', :uploaded_data => fixture_file_upload(FIXTURE_PATH + "/hello.txt", "text/plain")
+ assert_equal "File: Hello", response.body
+ end
+ end
+
+ # The lint wrapper is used in integration tests
+ # instead of a normal StringIO class
+ InputWrapper = Rack::Lint::InputWrapper
+
+ test "parses unwindable stream" do
+ InputWrapper.any_instance.expects(:rewind).raises(Errno::ESPIPE)
+ params = parse_multipart('large_text_file')
+ assert_equal %w(file foo), params.keys.sort
+ assert_equal 'bar', params['foo']
+ end
+
+ test "uploads and reads file with unwindable input" do
+ InputWrapper.any_instance.expects(:rewind).raises(Errno::ESPIPE)
+
+ with_test_routing do
+ post '/read', :uploaded_data => fixture_file_upload(FIXTURE_PATH + "/hello.txt", "text/plain")
+ assert_equal "File: Hello", response.body
+ end
+ end
+
+ private
+ def fixture(name)
+ File.open(File.join(FIXTURE_PATH, name), 'rb') do |file|
+ { "rack.input" => file.read,
+ "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x",
+ "CONTENT_LENGTH" => file.stat.size.to_s }
+ end
+ end
+
+ def parse_multipart(name)
+ with_test_routing do
+ headers = fixture(name)
+ post "/parse", headers.delete("rack.input"), headers
+ assert_response :ok
+ TestController.last_request_parameters
+ end
+ end
+
+ def with_test_routing
+ with_routing do |set|
+ set.draw do |map|
+ map.connect ':action', :controller => "multipart_params_parsing_test/test"
+ end
+ yield
+ end
+ end
+end
diff --git a/actionpack/test/controller/query_string_parsing_test.rb b/actionpack/test/controller/request/query_string_parsing_test.rb
index a31e326ddf..a31e326ddf 100644
--- a/actionpack/test/controller/query_string_parsing_test.rb
+++ b/actionpack/test/controller/request/query_string_parsing_test.rb
diff --git a/actionpack/test/controller/request/url_encoded_params_parsing_test.rb b/actionpack/test/controller/request/url_encoded_params_parsing_test.rb
new file mode 100644
index 0000000000..26a4538ca6
--- /dev/null
+++ b/actionpack/test/controller/request/url_encoded_params_parsing_test.rb
@@ -0,0 +1,171 @@
+require 'abstract_unit'
+
+class UrlEncodedParamsParsingTest < ActionController::IntegrationTest
+ class TestController < ActionController::Base
+ class << self
+ attr_accessor :last_request_parameters, :last_request_type
+ end
+
+ def parse
+ self.class.last_request_parameters = request.request_parameters
+ head :ok
+ end
+ end
+
+ def teardown
+ TestController.last_request_parameters = nil
+ end
+
+ test "parses unbalanced query string with array" do
+ assert_parses(
+ {'location' => ["1", "2"], 'age_group' => ["2"]},
+ "location[]=1&location[]=2&age_group[]=2"
+ )
+ end
+
+ test "parses nested hash" do
+ query = [
+ "note[viewers][viewer][][type]=User",
+ "note[viewers][viewer][][id]=1",
+ "note[viewers][viewer][][type]=Group",
+ "note[viewers][viewer][][id]=2"
+ ].join("&")
+
+ expected = { "note" => { "viewers"=>{"viewer"=>[{ "id"=>"1", "type"=>"User"}, {"type"=>"Group", "id"=>"2"} ]} } }
+ assert_parses(expected, query)
+ end
+
+ test "parses more complex nesting" do
+ query = [
+ "customers[boston][first][name]=David",
+ "customers[boston][first][url]=http://David",
+ "customers[boston][second][name]=Allan",
+ "customers[boston][second][url]=http://Allan",
+ "something_else=blah",
+ "something_nil=",
+ "something_empty=",
+ "products[first]=Apple Computer",
+ "products[second]=Pc",
+ "=Save"
+ ].join("&")
+
+ expected = {
+ "customers" => {
+ "boston" => {
+ "first" => {
+ "name" => "David",
+ "url" => "http://David"
+ },
+ "second" => {
+ "name" => "Allan",
+ "url" => "http://Allan"
+ }
+ }
+ },
+ "something_else" => "blah",
+ "something_empty" => "",
+ "something_nil" => "",
+ "products" => {
+ "first" => "Apple Computer",
+ "second" => "Pc"
+ }
+ }
+
+ assert_parses expected, query
+ end
+
+ test "parses params with array" do
+ query = "selected[]=1&selected[]=2&selected[]=3"
+ expected = { "selected" => [ "1", "2", "3" ] }
+ assert_parses expected, query
+ end
+
+ test "parses params with non alphanumeric name" do
+ query = "a/b[c]=d"
+ expected = { "a/b" => { "c" => "d" }}
+ assert_parses expected, query
+ end
+
+ test "parses params with single brackets in the middle" do
+ query = "a/b[c]d=e"
+ expected = { "a/b" => {} }
+ assert_parses expected, query
+ end
+
+ test "parses params with separated brackets" do
+ query = "a/b@[c]d[e]=f"
+ expected = { "a/b@" => { }}
+ assert_parses expected, query
+ end
+
+ test "parses params with separated brackets and array" do
+ query = "a/b@[c]d[e][]=f"
+ expected = { "a/b@" => { }}
+ assert_parses expected, query
+ end
+
+ test "parses params with unmatched brackets and array" do
+ query = "a/b@[c][d[e][]=f"
+ expected = { "a/b@" => { "c" => { }}}
+ assert_parses expected, query
+ end
+
+ test "parses params with nil key" do
+ query = "=&test2=value1"
+ expected = { "test2" => "value1" }
+ assert_parses expected, query
+ end
+
+ test "parses params with array prefix and hashes" do
+ query = "a[][b][c]=d"
+ expected = {"a" => [{"b" => {"c" => "d"}}]}
+ assert_parses expected, query
+ end
+
+ test "parses params with complex nesting" do
+ query = "a[][b][c][][d][]=e"
+ expected = {"a" => [{"b" => {"c" => [{"d" => ["e"]}]}}]}
+ assert_parses expected, query
+ end
+
+ test "parses params with file path" do
+ query = [
+ "customers[boston][first][name]=David",
+ "something_else=blah",
+ "logo=#{File.expand_path(__FILE__)}"
+ ].join("&")
+
+ expected = {
+ "customers" => {
+ "boston" => {
+ "first" => {
+ "name" => "David"
+ }
+ }
+ },
+ "something_else" => "blah",
+ "logo" => File.expand_path(__FILE__),
+ }
+
+ assert_parses expected, query
+ end
+
+
+ private
+ def with_test_routing
+ with_routing do |set|
+ set.draw do |map|
+ map.connect ':action', :controller => "url_encoded_params_parsing_test/test"
+ end
+ yield
+ end
+ end
+
+ def assert_parses(expected, actual)
+ with_test_routing do
+ post "/parse", actual
+ assert_response :ok
+ assert_equal(expected, TestController.last_request_parameters)
+ end
+ end
+end
diff --git a/actionpack/test/controller/request_test.rb b/actionpack/test/controller/request_test.rb
index c53f1bc2d9..7097d08076 100644
--- a/actionpack/test/controller/request_test.rb
+++ b/actionpack/test/controller/request_test.rb
@@ -405,308 +405,3 @@ class RequestTest < ActiveSupport::TestCase
@request.request_method(true)
end
end
-
-class UrlEncodedRequestParameterParsingTest < ActiveSupport::TestCase
- def test_unbalanced_query_string_with_array
- assert_equal(
- {'location' => ["1", "2"], 'age_group' => ["2"]},
- ActionController::RequestParser.parse_request_parameters({'location[]' => ["1", "2"], 'age_group[]' => ["2"]})
- )
- end
-
- def test_request_hash_parsing
- query = {
- "note[viewers][viewer][][type]" => ["User", "Group"],
- "note[viewers][viewer][][id]" => ["1", "2"]
- }
-
- expected = { "note" => { "viewers"=>{"viewer"=>[{ "id"=>"1", "type"=>"User"}, {"type"=>"Group", "id"=>"2"} ]} } }
-
- assert_equal(expected, ActionController::RequestParser.parse_request_parameters(query))
- end
-
- def test_parse_params
- input = {
- "customers[boston][first][name]" => [ "David" ],
- "customers[boston][first][url]" => [ "http://David" ],
- "customers[boston][second][name]" => [ "Allan" ],
- "customers[boston][second][url]" => [ "http://Allan" ],
- "something_else" => [ "blah" ],
- "something_nil" => [ nil ],
- "something_empty" => [ "" ],
- "products[first]" => [ "Apple Computer" ],
- "products[second]" => [ "Pc" ],
- "" => [ 'Save' ]
- }
-
- expected_output = {
- "customers" => {
- "boston" => {
- "first" => {
- "name" => "David",
- "url" => "http://David"
- },
- "second" => {
- "name" => "Allan",
- "url" => "http://Allan"
- }
- }
- },
- "something_else" => "blah",
- "something_empty" => "",
- "something_nil" => "",
- "products" => {
- "first" => "Apple Computer",
- "second" => "Pc"
- }
- }
-
- assert_equal expected_output, ActionController::RequestParser.parse_request_parameters(input)
- end
-
- UploadedStringIO = ActionController::UploadedStringIO
- class MockUpload < UploadedStringIO
- def initialize(content_type, original_path, *args)
- self.content_type = content_type
- self.original_path = original_path
- super *args
- end
- end
-
- def test_parse_params_from_multipart_upload
- file = MockUpload.new('img/jpeg', 'foo.jpg')
- ie_file = MockUpload.new('img/jpeg', 'c:\\Documents and Settings\\foo\\Desktop\\bar.jpg')
- non_file_text_part = MockUpload.new('text/plain', '', 'abc')
-
- input = {
- "something" => [ UploadedStringIO.new("") ],
- "array_of_stringios" => [[ UploadedStringIO.new("One"), UploadedStringIO.new("Two") ]],
- "mixed_types_array" => [[ UploadedStringIO.new("Three"), "NotStringIO" ]],
- "mixed_types_as_checkboxes[strings][nested]" => [[ file, "String", UploadedStringIO.new("StringIO")]],
- "ie_mixed_types_as_checkboxes[strings][nested]" => [[ ie_file, "String", UploadedStringIO.new("StringIO")]],
- "products[string]" => [ UploadedStringIO.new("Apple Computer") ],
- "products[file]" => [ file ],
- "ie_products[string]" => [ UploadedStringIO.new("Microsoft") ],
- "ie_products[file]" => [ ie_file ],
- "text_part" => [non_file_text_part]
- }
-
- expected_output = {
- "something" => "",
- "array_of_stringios" => ["One", "Two"],
- "mixed_types_array" => [ "Three", "NotStringIO" ],
- "mixed_types_as_checkboxes" => {
- "strings" => {
- "nested" => [ file, "String", "StringIO" ]
- },
- },
- "ie_mixed_types_as_checkboxes" => {
- "strings" => {
- "nested" => [ ie_file, "String", "StringIO" ]
- },
- },
- "products" => {
- "string" => "Apple Computer",
- "file" => file
- },
- "ie_products" => {
- "string" => "Microsoft",
- "file" => ie_file
- },
- "text_part" => "abc"
- }
-
- params = ActionController::RequestParser.parse_request_parameters(input)
- assert_equal expected_output, params
-
- # Lone filenames are preserved.
- assert_equal 'foo.jpg', params['mixed_types_as_checkboxes']['strings']['nested'].first.original_filename
- assert_equal 'foo.jpg', params['products']['file'].original_filename
-
- # But full Windows paths are reduced to their basename.
- assert_equal 'bar.jpg', params['ie_mixed_types_as_checkboxes']['strings']['nested'].first.original_filename
- assert_equal 'bar.jpg', params['ie_products']['file'].original_filename
- end
-
- def test_parse_params_with_file
- input = {
- "customers[boston][first][name]" => [ "David" ],
- "something_else" => [ "blah" ],
- "logo" => [ File.new(File.dirname(__FILE__) + "/rack_test.rb").path ]
- }
-
- expected_output = {
- "customers" => {
- "boston" => {
- "first" => {
- "name" => "David"
- }
- }
- },
- "something_else" => "blah",
- "logo" => File.new(File.dirname(__FILE__) + "/rack_test.rb").path,
- }
-
- assert_equal expected_output, ActionController::RequestParser.parse_request_parameters(input)
- end
-
- def test_parse_params_with_array
- input = { "selected[]" => [ "1", "2", "3" ] }
-
- expected_output = { "selected" => [ "1", "2", "3" ] }
-
- assert_equal expected_output, ActionController::RequestParser.parse_request_parameters(input)
- end
-
- def test_parse_params_with_non_alphanumeric_name
- input = { "a/b[c]" => %w(d) }
- expected = { "a/b" => { "c" => "d" }}
- assert_equal expected, ActionController::RequestParser.parse_request_parameters(input)
- end
-
- def test_parse_params_with_single_brackets_in_middle
- input = { "a/b[c]d" => %w(e) }
- expected = { "a/b" => {} }
- assert_equal expected, ActionController::RequestParser.parse_request_parameters(input)
- end
-
- def test_parse_params_with_separated_brackets
- input = { "a/b@[c]d[e]" => %w(f) }
- expected = { "a/b@" => { }}
- assert_equal expected, ActionController::RequestParser.parse_request_parameters(input)
- end
-
- def test_parse_params_with_separated_brackets_and_array
- input = { "a/b@[c]d[e][]" => %w(f) }
- expected = { "a/b@" => { }}
- assert_equal expected , ActionController::RequestParser.parse_request_parameters(input)
- end
-
- def test_parse_params_with_unmatched_brackets_and_array
- input = { "a/b@[c][d[e][]" => %w(f) }
- expected = { "a/b@" => { "c" => { }}}
- assert_equal expected, ActionController::RequestParser.parse_request_parameters(input)
- end
-
- def test_parse_params_with_nil_key
- input = { nil => nil, "test2" => %w(value1) }
- expected = { "test2" => "value1" }
- assert_equal expected, ActionController::RequestParser.parse_request_parameters(input)
- end
-
- def test_parse_params_with_array_prefix_and_hashes
- input = { "a[][b][c]" => %w(d) }
- expected = {"a" => [{"b" => {"c" => "d"}}]}
- assert_equal expected, ActionController::RequestParser.parse_request_parameters(input)
- end
-
- def test_parse_params_with_complex_nesting
- input = { "a[][b][c][][d][]" => %w(e) }
- expected = {"a" => [{"b" => {"c" => [{"d" => ["e"]}]}}]}
- assert_equal expected, ActionController::RequestParser.parse_request_parameters(input)
- end
-end
-
-class MultipartRequestParameterParsingTest < ActiveSupport::TestCase
- FIXTURE_PATH = File.dirname(__FILE__) + '/../fixtures/multipart'
-
- def test_single_parameter
- params = parse_multipart('single_parameter')
- assert_equal({ 'foo' => 'bar' }, params)
- end
-
- def test_bracketed_param
- assert_equal({ 'foo' => { 'baz' => 'bar'}}, parse_multipart('bracketed_param'))
- end
-
- def test_text_file
- params = parse_multipart('text_file')
- assert_equal %w(file foo), params.keys.sort
- assert_equal 'bar', params['foo']
-
- file = params['file']
- assert_kind_of StringIO, file
- assert_equal 'file.txt', file.original_filename
- assert_equal "text/plain", file.content_type
- assert_equal 'contents', file.read
- end
-
- def test_boundary_problem_file
- params = parse_multipart('boundary_problem_file')
- assert_equal %w(file foo), params.keys.sort
-
- file = params['file']
- foo = params['foo']
-
- assert_kind_of Tempfile, file
-
- assert_equal 'file.txt', file.original_filename
- assert_equal "text/plain", file.content_type
-
- assert_equal 'bar', foo
- end
-
- def test_large_text_file
- params = parse_multipart('large_text_file')
- assert_equal %w(file foo), params.keys.sort
- assert_equal 'bar', params['foo']
-
- file = params['file']
-
- assert_kind_of Tempfile, file
-
- assert_equal 'file.txt', file.original_filename
- assert_equal "text/plain", file.content_type
- assert ('a' * 20480) == file.read
- end
-
- uses_mocha "test_no_rewind_stream" do
- def test_no_rewind_stream
- # Ensures that parse_multipart_form_parameters works with streams that cannot be rewound
- file = File.open(File.join(FIXTURE_PATH, 'large_text_file'), 'rb')
- file.expects(:rewind).raises(Errno::ESPIPE)
- params = ActionController::RequestParser.parse_multipart_form_parameters(file, 'AaB03x', file.stat.size, {})
- assert_not_equal 0, file.pos # file was not rewound after reading
- end
- end
-
- def test_binary_file
- params = parse_multipart('binary_file')
- assert_equal %w(file flowers foo), params.keys.sort
- assert_equal 'bar', params['foo']
-
- file = params['file']
- assert_kind_of StringIO, file
- assert_equal 'file.csv', file.original_filename
- assert_nil file.content_type
- assert_equal 'contents', file.read
-
- file = params['flowers']
- assert_kind_of StringIO, file
- assert_equal 'flowers.jpg', file.original_filename
- assert_equal "image/jpeg", file.content_type
- assert_equal 19512, file.size
- #assert_equal File.read(File.dirname(__FILE__) + '/../../../activerecord/test/fixtures/flowers.jpg'), file.read
- end
-
- def test_mixed_files
- params = parse_multipart('mixed_files')
- assert_equal %w(files foo), params.keys.sort
- assert_equal 'bar', params['foo']
-
- # Ruby CGI doesn't handle multipart/mixed for us.
- files = params['files']
- assert_kind_of String, files
- files.force_encoding('ASCII-8BIT') if files.respond_to?(:force_encoding)
- assert_equal 19756, files.size
- end
-
- private
- def parse_multipart(name)
- File.open(File.join(FIXTURE_PATH, name), 'rb') do |file|
- params = ActionController::RequestParser.parse_multipart_form_parameters(file, 'AaB03x', file.stat.size, {})
- assert_equal 0, file.pos # file was rewound after reading
- params
- end
- end
-end
diff --git a/activerecord/CHANGELOG b/activerecord/CHANGELOG
index c750f486f9..35172ae110 100644
--- a/activerecord/CHANGELOG
+++ b/activerecord/CHANGELOG
@@ -1,5 +1,7 @@
*2.3.0/3.0*
+* Support nested transactions using database savepoints. #383 [Jonathan Viney, Hongli Lai]
+
* Added dynamic scopes ala dynamic finders #1648 [Yaroslav Markin]
* Fixed that ActiveRecord::Base#new_record? should return false (not nil) for existing records #1219 [Yaroslav Markin]
diff --git a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb
index 189c6c7b5a..39118583bd 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb
@@ -53,36 +53,120 @@ module ActiveRecord
def delete(sql, name = nil)
delete_sql(sql, name)
end
+
+ # Checks whether there is currently no transaction active. This is done
+ # by querying the database driver, and does not use the transaction
+ # house-keeping information recorded by #increment_open_transactions and
+ # friends.
+ #
+ # Returns true if there is no transaction active, false if there is a
+ # transaction active, and nil if this information is unknown.
+ #
+ # Not all adapters supports transaction state introspection. Currently,
+ # only the PostgreSQL adapter supports this.
+ def outside_transaction?
+ nil
+ end
+
+ # Runs the given block in a database transaction, and returns the result
+ # of the block.
+ #
+ # == Nested transactions support
+ #
+ # Most databases don't support true nested transactions. At the time of
+ # writing, the only database that supports true nested transactions that
+ # we're aware of, is MS-SQL.
+ #
+ # In order to get around this problem, #transaction will emulate the effect
+ # of nested transactions, by using savepoints:
+ # http://dev.mysql.com/doc/refman/5.0/en/savepoints.html
+ # Savepoints are supported by MySQL and PostgreSQL, but not SQLite3.
+ #
+ # It is safe to call this method if a database transaction is already open,
+ # i.e. if #transaction is called within another #transaction block. In case
+ # of a nested call, #transaction will behave as follows:
+ #
+ # - The block will be run without doing anything. All database statements
+ # that happen within the block are effectively appended to the already
+ # open database transaction.
+ # - However, if +requires_new+ is set, the block will be wrapped in a
+ # database savepoint acting as a sub-transaction.
+ #
+ # === Caveats
+ #
+ # MySQL doesn't support DDL transactions. If you perform a DDL operation,
+ # then any created savepoints will be automatically released. For example,
+ # if you've created a savepoint, then you execute a CREATE TABLE statement,
+ # then the savepoint that was created will be automatically released.
+ #
+ # This means that, on MySQL, you shouldn't execute DDL operations inside
+ # a #transaction call that you know might create a savepoint. Otherwise,
+ # #transaction will raise exceptions when it tries to release the
+ # already-automatically-released savepoints:
+ #
+ # Model.connection.transaction do # BEGIN
+ # Model.connection.transaction(:requires_new => true) do # CREATE SAVEPOINT active_record_1
+ # Model.connection.create_table(...)
+ # # active_record_1 now automatically released
+ # end # RELEASE SAVEPOINT active_record_1 <--- BOOM! database error!
+ # end
+ def transaction(options = {})
+ options.assert_valid_keys :requires_new, :joinable
+
+ last_transaction_joinable, @transaction_joinable =
+ @transaction_joinable, options[:joinable] || true
+ requires_new = options[:requires_new] || !last_transaction_joinable
- # Wrap a block in a transaction. Returns result of block.
- def transaction(start_db_transaction = true)
transaction_open = false
begin
if block_given?
- if start_db_transaction
- begin_db_transaction
+ if requires_new || open_transactions == 0
+ if open_transactions == 0
+ begin_db_transaction
+ elsif requires_new
+ create_savepoint
+ end
+ increment_open_transactions
transaction_open = true
end
yield
end
rescue Exception => database_transaction_rollback
- if transaction_open
+ if transaction_open && !outside_transaction?
transaction_open = false
- rollback_db_transaction
+ decrement_open_transactions
+ if open_transactions == 0
+ rollback_db_transaction
+ else
+ rollback_to_savepoint
+ end
end
raise unless database_transaction_rollback.is_a? ActiveRecord::Rollback
end
ensure
- if transaction_open
+ @transaction_joinable = last_transaction_joinable
+
+ if outside_transaction?
+ @open_transactions = 0
+ elsif transaction_open
+ decrement_open_transactions
begin
- commit_db_transaction
+ if open_transactions == 0
+ commit_db_transaction
+ else
+ release_savepoint
+ end
rescue Exception => database_transaction_rollback
- rollback_db_transaction
+ if open_transactions == 0
+ rollback_db_transaction
+ else
+ rollback_to_savepoint
+ end
raise
end
end
end
-
+
# Begins the transaction (and turns off auto-committing).
def begin_db_transaction() end
diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
index bfafcfb3ab..a8cd9f033b 100755
--- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
@@ -66,6 +66,12 @@ module ActiveRecord
def supports_ddl_transactions?
false
end
+
+ # Does this adapter support savepoints? PostgreSQL and MySQL do, SQLite
+ # does not.
+ def supports_savepoints?
+ false
+ end
# Should primary key values be selected from their corresponding
# sequence before the insert statement? If true, next_sequence_value
@@ -160,6 +166,23 @@ module ActiveRecord
@open_transactions -= 1
end
+ def transaction_joinable=(joinable)
+ @transaction_joinable = joinable
+ end
+
+ def create_savepoint
+ end
+
+ def rollback_to_savepoint
+ end
+
+ def release_savepoint
+ end
+
+ def current_savepoint_name
+ "active_record_#{open_transactions}"
+ end
+
def log_info(sql, name, ms)
if @logger && @logger.debug?
name = '%s (%.1fms)' % [name || 'SQL', ms]
diff --git a/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb
index 60729c63db..b2345fd571 100644
--- a/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb
@@ -210,6 +210,10 @@ module ActiveRecord
def supports_migrations? #:nodoc:
true
end
+
+ def supports_savepoints? #:nodoc:
+ true
+ end
def native_database_types #:nodoc:
NATIVE_DATABASE_TYPES
@@ -349,6 +353,17 @@ module ActiveRecord
# Transactions aren't supported
end
+ def create_savepoint
+ execute("SAVEPOINT #{current_savepoint_name}")
+ end
+
+ def rollback_to_savepoint
+ execute("ROLLBACK TO SAVEPOINT #{current_savepoint_name}")
+ end
+
+ def release_savepoint
+ execute("RELEASE SAVEPOINT #{current_savepoint_name}")
+ end
def add_limit_offset!(sql, options) #:nodoc:
if limit = options[:limit]
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
index 6685cb8663..5a8d99924d 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
@@ -272,6 +272,10 @@ module ActiveRecord
def supports_ddl_transactions?
true
end
+
+ def supports_savepoints?
+ true
+ end
# Returns the configured supported identifier length supported by PostgreSQL,
# or report the default of 63 on PostgreSQL 7.x.
@@ -528,45 +532,28 @@ module ActiveRecord
def rollback_db_transaction
execute "ROLLBACK"
end
+
+ if PGconn.public_method_defined?(:transaction_status)
+ # ruby-pg defines Ruby constants for transaction status,
+ # ruby-postgres does not.
+ PQTRANS_IDLE = defined?(PGconn::PQTRANS_IDLE) ? PGconn::PQTRANS_IDLE : 0
+
+ def outside_transaction?
+ @connection.transaction_status == PQTRANS_IDLE
+ end
+ end
- # ruby-pg defines Ruby constants for transaction status,
- # ruby-postgres does not.
- PQTRANS_IDLE = defined?(PGconn::PQTRANS_IDLE) ? PGconn::PQTRANS_IDLE : 0
-
- # Check whether a transaction is active.
- def transaction_active?
- @connection.transaction_status != PQTRANS_IDLE
+ def create_savepoint
+ execute("SAVEPOINT #{current_savepoint_name}")
end
- # Wrap a block in a transaction. Returns result of block.
- def transaction(start_db_transaction = true)
- transaction_open = false
- begin
- if block_given?
- if start_db_transaction
- begin_db_transaction
- transaction_open = true
- end
- yield
- end
- rescue Exception => database_transaction_rollback
- if transaction_open && transaction_active?
- transaction_open = false
- rollback_db_transaction
- end
- raise unless database_transaction_rollback.is_a? ActiveRecord::Rollback
- end
- ensure
- if transaction_open && transaction_active?
- begin
- commit_db_transaction
- rescue Exception => database_transaction_rollback
- rollback_db_transaction
- raise
- end
- end
+ def rollback_to_savepoint
+ execute("ROLLBACK TO SAVEPOINT #{current_savepoint_name}")
end
+ def release_savepoint
+ execute("RELEASE SAVEPOINT #{current_savepoint_name}")
+ end
# SCHEMA STATEMENTS ========================================
diff --git a/activerecord/lib/active_record/fixtures.rb b/activerecord/lib/active_record/fixtures.rb
index 129306d335..0131d9fac5 100644
--- a/activerecord/lib/active_record/fixtures.rb
+++ b/activerecord/lib/active_record/fixtures.rb
@@ -516,7 +516,7 @@ class Fixtures < (RUBY_VERSION < '1.9' ? YAML::Omap : Hash)
all_loaded_fixtures.update(fixtures_map)
- connection.transaction(connection.open_transactions.zero?) do
+ connection.transaction(:requires_new => true) do
fixtures.reverse.each { |fixture| fixture.delete_existing_fixtures }
fixtures.each { |fixture| fixture.insert_fixtures }
@@ -937,6 +937,7 @@ module ActiveRecord
@@already_loaded_fixtures[self.class] = @loaded_fixtures
end
ActiveRecord::Base.connection.increment_open_transactions
+ ActiveRecord::Base.connection.transaction_joinable = false
ActiveRecord::Base.connection.begin_db_transaction
# Load fixtures for every test.
else
diff --git a/activerecord/lib/active_record/transactions.rb b/activerecord/lib/active_record/transactions.rb
index 0a27ea980e..0b6e52c79b 100644
--- a/activerecord/lib/active_record/transactions.rb
+++ b/activerecord/lib/active_record/transactions.rb
@@ -120,16 +120,66 @@ module ActiveRecord
# end
#
# One should restart the entire transaction if a StatementError occurred.
+ #
+ # == Nested transactions
+ #
+ # #transaction calls can be nested. By default, this makes all database
+ # statements in the nested transaction block become part of the parent
+ # transaction. For example:
+ #
+ # User.transaction do
+ # User.create(:username => 'Kotori')
+ # User.transaction do
+ # User.create(:username => 'Nemu')
+ # raise ActiveRecord::Rollback
+ # end
+ # end
+ #
+ # User.find(:all) # => empty
+ #
+ # It is also possible to requires a sub-transaction by passing
+ # <tt>:requires_new => true</tt>. If anything goes wrong, the
+ # database rolls back to the beginning of the sub-transaction
+ # without rolling back the parent transaction. For example:
+ #
+ # User.transaction do
+ # User.create(:username => 'Kotori')
+ # User.transaction(:requires_new => true) do
+ # User.create(:username => 'Nemu')
+ # raise ActiveRecord::Rollback
+ # end
+ # end
+ #
+ # User.find(:all) # => Returns only Kotori
+ #
+ # Most databases don't support true nested transactions. At the time of
+ # writing, the only database that we're aware of that supports true nested
+ # transactions, is MS-SQL. Because of this, Active Record emulates nested
+ # transactions by using savepoints. See
+ # http://dev.mysql.com/doc/refman/5.0/en/savepoints.html
+ # for more information about savepoints.
+ #
+ # === Caveats
+ #
+ # If you're on MySQL, then do not use DDL operations in nested transactions
+ # blocks that are emulated with savepoints. That is, do not execute statements
+ # like 'CREATE TABLE' inside such blocks. This is because MySQL automatically
+ # releases all savepoints upon executing a DDL operation. When #transaction
+ # is finished and tries to release the savepoint it created earlier, a
+ # database error will occur because the savepoint has already been
+ # automatically released. The following example demonstrates the problem:
+ #
+ # Model.connection.transaction do # BEGIN
+ # Model.connection.transaction(:requires_new => true) do # CREATE SAVEPOINT active_record_1
+ # Model.connection.create_table(...) # active_record_1 now automatically released
+ # end # RELEASE savepoint active_record_1
+ # # ^^^^ BOOM! database error!
+ # end
module ClassMethods
# See ActiveRecord::Transactions::ClassMethods for detailed documentation.
- def transaction(&block)
- connection.increment_open_transactions
-
- begin
- connection.transaction(connection.open_transactions == 1, &block)
- ensure
- connection.decrement_open_transactions
- end
+ def transaction(options = {}, &block)
+ # See the ConnectionAdapters::DatabaseStatements#transaction API docs.
+ connection.transaction(options, &block)
end
end
diff --git a/activerecord/test/cases/defaults_test.rb b/activerecord/test/cases/defaults_test.rb
index 38e4853dc0..b4032c23e6 100644
--- a/activerecord/test/cases/defaults_test.rb
+++ b/activerecord/test/cases/defaults_test.rb
@@ -18,11 +18,43 @@ class DefaultTest < ActiveRecord::TestCase
end
end
- if current_adapter?(:MysqlAdapter)
+ if current_adapter?(:PostgreSQLAdapter, :FirebirdAdapter, :OpenBaseAdapter, :OracleAdapter)
+ def test_default_integers
+ default = Default.new
+ assert_instance_of Fixnum, default.positive_integer
+ assert_equal 1, default.positive_integer
+ assert_instance_of Fixnum, default.negative_integer
+ assert_equal -1, default.negative_integer
+ assert_instance_of BigDecimal, default.decimal_number
+ assert_equal BigDecimal.new("2.78"), default.decimal_number
+ end
+ end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ def test_multiline_default_text
+ # older postgres versions represent the default with escapes ("\\012" for a newline)
+ assert ( "--- []\n\n" == Default.columns_hash['multiline_default'].default ||
+ "--- []\\012\\012" == Default.columns_hash['multiline_default'].default)
+ end
+ end
+end
- #MySQL 5 and higher is quirky with not null text/blob columns.
- #With MySQL Text/blob columns cannot have defaults. If the column is not null MySQL will report that the column has a null default
- #but it behaves as though the column had a default of ''
+if current_adapter?(:MysqlAdapter)
+ class DefaultsTestWithoutTransactionalFixtures < ActiveRecord::TestCase
+ # ActiveRecord::Base#create! (and #save and other related methods) will
+ # open a new transaction. When in transactional fixtures mode, this will
+ # cause ActiveRecord to create a new savepoint. However, since MySQL doesn't
+ # support DDL transactions, creating a table will result in any created
+ # savepoints to be automatically released. This in turn causes the savepoint
+ # release code in AbstractAdapter#transaction to fail.
+ #
+ # We don't want that to happen, so we disable transactional fixtures here.
+ self.use_transactional_fixtures = false
+
+ # MySQL 5 and higher is quirky with not null text/blob columns.
+ # With MySQL Text/blob columns cannot have defaults. If the column is not
+ # null MySQL will report that the column has a null default
+ # but it behaves as though the column had a default of ''
def test_mysql_text_not_null_defaults
klass = Class.new(ActiveRecord::Base)
klass.table_name = 'test_mysql_text_not_null_defaults'
@@ -48,8 +80,7 @@ class DefaultTest < ActiveRecord::TestCase
ensure
klass.connection.drop_table(klass.table_name) rescue nil
end
-
-
+
# MySQL uses an implicit default 0 rather than NULL unless in strict mode.
# We use an implicit NULL so schema.rb is compatible with other databases.
def test_mysql_integer_not_null_defaults
@@ -77,24 +108,4 @@ class DefaultTest < ActiveRecord::TestCase
klass.connection.drop_table(klass.table_name) rescue nil
end
end
-
- if current_adapter?(:PostgreSQLAdapter, :FirebirdAdapter, :OpenBaseAdapter, :OracleAdapter)
- def test_default_integers
- default = Default.new
- assert_instance_of Fixnum, default.positive_integer
- assert_equal 1, default.positive_integer
- assert_instance_of Fixnum, default.negative_integer
- assert_equal -1, default.negative_integer
- assert_instance_of BigDecimal, default.decimal_number
- assert_equal BigDecimal.new("2.78"), default.decimal_number
- end
- end
-
- if current_adapter?(:PostgreSQLAdapter)
- def test_multiline_default_text
- # older postgres versions represent the default with escapes ("\\012" for a newline)
- assert ( "--- []\n\n" == Default.columns_hash['multiline_default'].default ||
- "--- []\\012\\012" == Default.columns_hash['multiline_default'].default)
- end
- end
end
diff --git a/activerecord/test/cases/helper.rb b/activerecord/test/cases/helper.rb
index 2043138ca3..ccd04b67f6 100644
--- a/activerecord/test/cases/helper.rb
+++ b/activerecord/test/cases/helper.rb
@@ -34,7 +34,7 @@ rescue LoadError
end
ActiveRecord::Base.connection.class.class_eval do
- IGNORED_SQL = [/^PRAGMA/, /^SELECT currval/, /^SELECT CAST/, /^SELECT @@IDENTITY/, /^SELECT @@ROWCOUNT/]
+ IGNORED_SQL = [/^PRAGMA/, /^SELECT currval/, /^SELECT CAST/, /^SELECT @@IDENTITY/, /^SELECT @@ROWCOUNT/, /^SAVEPOINT/, /^ROLLBACK TO SAVEPOINT/, /^RELEASE SAVEPOINT/]
def execute_with_query_record(sql, name = nil, &block)
$queries_executed ||= []
diff --git a/activerecord/test/cases/transactions_test.rb b/activerecord/test/cases/transactions_test.rb
index b12ec36455..f07fad1828 100644
--- a/activerecord/test/cases/transactions_test.rb
+++ b/activerecord/test/cases/transactions_test.rb
@@ -213,11 +213,104 @@ class TransactionTest < ActiveRecord::TestCase
assert Topic.find(2).approved?, "Second should still be approved"
end
+ def test_invalid_keys_for_transaction
+ assert_raises ArgumentError do
+ Topic.transaction :nested => true do
+ end
+ end
+ end
+
+ def test_force_savepoint_in_nested_transaction
+ Topic.transaction do
+ @first.approved = true
+ @second.approved = false
+ @first.save!
+ @second.save!
+
+ begin
+ Topic.transaction :requires_new => true do
+ @first.happy = false
+ @first.save!
+ raise
+ end
+ rescue
+ end
+ end
+
+ assert @first.reload.approved?
+ assert !@second.reload.approved?
+ end if Topic.connection.supports_savepoints?
+
+ def test_no_savepoint_in_nested_transaction_without_force
+ Topic.transaction do
+ @first.approved = true
+ @second.approved = false
+ @first.save!
+ @second.save!
+
+ begin
+ Topic.transaction do
+ @first.approved = false
+ @first.save!
+ raise
+ end
+ rescue
+ end
+ end
+
+ assert !@first.reload.approved?
+ assert !@second.reload.approved?
+ end if Topic.connection.supports_savepoints?
+
+ def test_many_savepoints
+ Topic.transaction do
+ @first.content = "One"
+ @first.save!
+
+ begin
+ Topic.transaction :requires_new => true do
+ @first.content = "Two"
+ @first.save!
+
+ begin
+ Topic.transaction :requires_new => true do
+ @first.content = "Three"
+ @first.save!
+
+ begin
+ Topic.transaction :requires_new => true do
+ @first.content = "Four"
+ @first.save!
+ raise
+ end
+ rescue
+ end
+
+ @three = @first.reload.content
+ raise
+ end
+ rescue
+ end
+
+ @two = @first.reload.content
+ raise
+ end
+ rescue
+ end
+
+ @one = @first.reload.content
+ end
+
+ assert_equal "One", @one
+ assert_equal "Two", @two
+ assert_equal "Three", @three
+ end if Topic.connection.supports_savepoints?
+
uses_mocha 'mocking connection.commit_db_transaction' do
def test_rollback_when_commit_raises
Topic.connection.expects(:begin_db_transaction)
- Topic.connection.expects(:transaction_active?).returns(true) if current_adapter?(:PostgreSQLAdapter)
Topic.connection.expects(:commit_db_transaction).raises('OH NOES')
+ Topic.connection.expects(:outside_transaction?).returns(false)
Topic.connection.expects(:rollback_db_transaction)
assert_raise RuntimeError do
@@ -227,6 +320,39 @@ class TransactionTest < ActiveRecord::TestCase
end
end
end
+
+ if current_adapter?(:PostgreSQLAdapter) && PGconn.public_method_defined?(:transaction_status)
+ def test_outside_transaction_works
+ Topic.logger.info("-------------")
+ assert Topic.connection.outside_transaction?
+ Topic.connection.begin_db_transaction
+ assert !Topic.connection.outside_transaction?
+ Topic.connection.rollback_db_transaction
+ assert Topic.connection.outside_transaction?
+ end
+
+ uses_mocha 'mocking connection.rollback_db_transaction' do
+ def test_rollback_wont_be_executed_if_no_transaction_active
+ assert_raise RuntimeError do
+ Topic.transaction do
+ Topic.connection.rollback_db_transaction
+ Topic.connection.expects(:rollback_db_transaction).never
+ raise "Rails doesn't scale!"
+ end
+ end
+ end
+ end
+
+ def test_open_transactions_count_is_reset_to_zero_if_no_transaction_active
+ Topic.transaction do
+ Topic.transaction do
+ Topic.connection.rollback_db_transaction
+ end
+ assert_equal 0, Topic.connection.open_transactions
+ end
+ assert_equal 0, Topic.connection.open_transactions
+ end
+ end
def test_sqlite_add_column_in_transaction_raises_statement_invalid
return true unless current_adapter?(:SQLite3Adapter, :SQLiteAdapter)
@@ -282,6 +408,45 @@ class TransactionTest < ActiveRecord::TestCase
end
end
+class TransactionsWithTransactionalFixturesTest < ActiveRecord::TestCase
+ self.use_transactional_fixtures = true
+ fixtures :topics
+
+ def test_automatic_savepoint_in_outer_transaction
+ @first = Topic.find(1)
+
+ begin
+ Topic.transaction do
+ @first.approved = true
+ @first.save!
+ raise
+ end
+ rescue
+ assert !@first.reload.approved?
+ end
+ end
+
+ def test_no_automatic_savepoint_for_inner_transaction
+ @first = Topic.find(1)
+
+ Topic.transaction do
+ @first.approved = true
+ @first.save!
+
+ begin
+ Topic.transaction do
+ @first.approved = false
+ @first.save!
+ raise
+ end
+ rescue
+ end
+ end
+
+ assert !@first.reload.approved?
+ end
+end if Topic.connection.supports_savepoints?
+
if current_adapter?(:PostgreSQLAdapter)
class ConcurrentTransactionTest < TransactionTest
use_concurrent_connections
diff --git a/activesupport/CHANGELOG b/activesupport/CHANGELOG
index 757cb1da04..fed977775e 100644
--- a/activesupport/CHANGELOG
+++ b/activesupport/CHANGELOG
@@ -1,5 +1,7 @@
*2.3.0 [Edge]*
+* TimeWithZone#xmlschema accepts optional fraction_digits argument [#1725 state:resolved] [Nicholas Dainty]
+
* Object#tap shim for Ruby < 1.8.7. Similar to Object#returning, tap yields self then returns self. [Jeremy Kemper]
array.select { ... }.tap(&:inspect).map { ... }
diff --git a/activesupport/lib/active_support/time_with_zone.rb b/activesupport/lib/active_support/time_with_zone.rb
index 51939e1d01..82b47b6c47 100644
--- a/activesupport/lib/active_support/time_with_zone.rb
+++ b/activesupport/lib/active_support/time_with_zone.rb
@@ -99,8 +99,12 @@ module ActiveSupport
"#{time.strftime('%a, %d %b %Y %H:%M:%S')} #{zone} #{formatted_offset}"
end
- def xmlschema
- "#{time.strftime("%Y-%m-%dT%H:%M:%S")}#{formatted_offset(true, 'Z')}"
+ def xmlschema(fraction_digits = 0)
+ fraction = if fraction_digits > 0
+ ".%i" % time.usec.to_s[0, fraction_digits]
+ end
+
+ "#{time.strftime("%Y-%m-%dT%H:%M:%S")}#{fraction}#{formatted_offset(true, 'Z')}"
end
alias_method :iso8601, :xmlschema
diff --git a/activesupport/test/core_ext/time_with_zone_test.rb b/activesupport/test/core_ext/time_with_zone_test.rb
index 7c8fb7dd94..4dc1fec559 100644
--- a/activesupport/test/core_ext/time_with_zone_test.rb
+++ b/activesupport/test/core_ext/time_with_zone_test.rb
@@ -105,6 +105,15 @@ class TimeWithZoneTest < Test::Unit::TestCase
end
end
+ def test_xmlschema_with_fractional_seconds
+ silence_warnings do # silence warnings raised by tzinfo gem
+ @twz += 0.123456 # advance the time by a fraction of a second
+ assert_equal "1999-12-31T19:00:00.123-05:00", @twz.xmlschema(3)
+ assert_equal "1999-12-31T19:00:00.123456-05:00", @twz.xmlschema(6)
+ assert_equal "1999-12-31T19:00:00.123456-05:00", @twz.xmlschema(12)
+ end
+ end
+
def test_to_yaml
silence_warnings do # silence warnings raised by tzinfo gem
assert_equal "--- 1999-12-31 19:00:00 -05:00\n", @twz.to_yaml