diff --git a/lib/sequel/core.rb b/lib/sequel/core.rb index 52455d5f06..457f0f3b80 100644 --- a/lib/sequel/core.rb +++ b/lib/sequel/core.rb @@ -59,28 +59,12 @@ # You can set the SEQUEL_NO_CORE_EXTENSIONS constant or environment variable to have # Sequel not extend the core classes. module Sequel - # The offset of the current time zone from UTC, in seconds. - LOCAL_DATETIME_OFFSET_SECS = Time.now.utc_offset - - # The offset of the current time zone from UTC, as a fraction of a day. - LOCAL_DATETIME_OFFSET = respond_to?(:Rational, true) ? Rational(LOCAL_DATETIME_OFFSET_SECS, 60*60*24) : LOCAL_DATETIME_OFFSET_SECS/60/60/24.0 - - @application_timezone = nil @convert_two_digit_years = true - @database_timezone = nil @datetime_class = Time - @typecast_timezone = nil @virtual_row_instance_eval = true class << self attr_accessor :convert_two_digit_years, :datetime_class, :virtual_row_instance_eval - attr_accessor :application_timezone, :database_timezone, :typecast_timezone - end - - # Convert the given Time/DateTime object into the database timezone, used when - # literalizing objects in an SQL string. - def self.application_to_database_timestamp(v) - convert_output_timestamp(v, Sequel.database_timezone) end # Returns true if the passed object could be a specifier of conditions, false otherwise. @@ -127,20 +111,6 @@ def self.convert_exception_class(exception, klass) e end - # Convert the given object into an object of Sequel.datetime_class in the - # application_timezone. Used when coverting datetime/timestamp columns - # returned by the database. - def self.database_to_application_timestamp(v) - convert_timestamp(v, Sequel.database_timezone) - end - - # Sets the database, application, and typecasting timezones to the given timezone. - def self.default_timezone=(tz) - self.database_timezone = tz - self.application_timezone = tz - self.typecast_timezone = tz - end - # Load all Sequel extensions given. Only loads extensions included in this # release of Sequel, doesn't load external extensions. # @@ -236,13 +206,6 @@ def self.string_to_time(s) raise convert_exception_class(e, InvalidValue) end end - - # Convert the given object into an object of Sequel.datetime_class in the - # application_timezone. Used when typecasting values when assigning them - # to model datetime attributes. - def self.typecast_to_application_timestamp(v) - convert_timestamp(v, Sequel.typecast_timezone) - end # If the supplied block takes a single argument, # yield a new SQL::VirtualRow instance to the block @@ -273,111 +236,6 @@ def self.adapter_method(adapter, *args, &block) # :nodoc: end connect(opts, &block) end - - # Convert the given DateTime to the given input_timezone, keeping the - # same time and just modifying the timezone. - def self.convert_input_datetime_no_offset(v, input_timezone) # :nodoc: - case input_timezone - when :utc - v# DateTime assumes UTC if no offset is given - when :local - v.new_offset(LOCAL_DATETIME_OFFSET) - LOCAL_DATETIME_OFFSET - else - convert_input_datetime_other(v, input_timezone) - end - end - - # Convert the given DateTime to the given input_timezone that is not supported - # by default (such as nil, :local, or :utc). Raises an error by default. - # Can be overridden in extensions. - def self.convert_input_datetime_other(v, input_timezone) # :nodoc: - raise InvalidValue, "Invalid input_timezone: #{input_timezone.inspect}" - end - - # Converts the object from a String, Array, Date, DateTime, or Time into an - # instance of Sequel.datetime_class. If given an array or a string that doesn't - # contain an offset, assume that the array/string is already in the given input_timezone. - def self.convert_input_timestamp(v, input_timezone) # :nodoc: - case v - when String - v2 = Sequel.string_to_datetime(v) - if !input_timezone || Date._parse(v).has_key?(:offset) - v2 - else - # Correct for potentially wrong offset if string doesn't include offset - if v2.is_a?(DateTime) - v2 = convert_input_datetime_no_offset(v2, input_timezone) - else - # Time assumes local time if no offset is given - v2 = v2.getutc + LOCAL_DATETIME_OFFSET_SECS if input_timezone == :utc - end - v2 - end - when Array - y, mo, d, h, mi, s = v - if datetime_class == DateTime - convert_input_datetime_no_offset(DateTime.civil(y, mo, d, h, mi, s, 0), input_timezone) - else - Time.send(input_timezone == :utc ? :utc : :local, y, mo, d, h, mi, s) - end - when Time - if datetime_class == DateTime - v.respond_to?(:to_datetime) ? v.to_datetime : string_to_datetime(v.iso8601) - else - v - end - when DateTime - if datetime_class == DateTime - v - else - v.respond_to?(:to_time) ? v.to_time : string_to_datetime(v.to_s) - end - when Date - convert_input_timestamp(v.to_s, input_timezone) - else - raise InvalidValue, "Invalid convert_input_timestamp type: #{v.inspect}" - end - end - - # Convert the given DateTime to the given output_timezone that is not supported - # by default (such as nil, :local, or :utc). Raises an error by default. - # Can be overridden in extensions. - def self.convert_output_datetime_other(v, output_timezone) # :nodoc: - raise InvalidValue, "Invalid output_timezone: #{output_timezone.inspect}" - end - - # Converts the object to the given output_timezone. - def self.convert_output_timestamp(v, output_timezone) # :nodoc: - if output_timezone - if v.is_a?(DateTime) - case output_timezone - when :utc - v.new_offset(0) - when :local - v.new_offset(LOCAL_DATETIME_OFFSET) - else - convert_output_datetime_other(v, output_timezone) - end - else - v.send(output_timezone == :utc ? :getutc : :getlocal) - end - else - v - end - end - - # Converts the given object from the given input timezone to the - # application timezone using convert_input_timestamp and - # convert_output_timestamp. - def self.convert_timestamp(v, input_timezone) # :nodoc: - begin - convert_output_timestamp(convert_input_timestamp(v, input_timezone), Sequel.application_timezone) - rescue InvalidValue - raise - rescue => e - raise convert_exception_class(e, InvalidValue) - end - end # Method that adds a database adapter class method to Sequel that calls # Sequel.adapter_method. @@ -387,12 +245,9 @@ def self.def_adapter_method(*adapters) # :nodoc: end end - private_class_method :adapter_method, :convert_input_datetime_no_offset, - :convert_input_datetime_other, :convert_input_timestamp, - :convert_output_datetime_other, :convert_output_timestamp, - :convert_timestamp, :def_adapter_method + private_class_method :adapter_method, :def_adapter_method - require(%w"metaprogramming sql connection_pool exceptions dataset database version") + require(%w"metaprogramming sql connection_pool exceptions dataset database timezones version") require(%w"schema_generator schema_methods schema_sql", 'database') require(%w"convenience graph prepared_statements sql", 'dataset') require('core_sql') if !defined?(::SEQUEL_NO_CORE_EXTENSIONS) && !ENV.has_key?('SEQUEL_NO_CORE_EXTENSIONS') diff --git a/lib/sequel/extensions/named_timezones.rb b/lib/sequel/extensions/named_timezones.rb index 729782d4bd..b63d629849 100644 --- a/lib/sequel/extensions/named_timezones.rb +++ b/lib/sequel/extensions/named_timezones.rb @@ -27,29 +27,35 @@ module Sequel self.datetime_class = DateTime - %w'application database typecast'.each do |t| - instance_eval("def #{t}_timezone=(tz); @#{t}_timezone = tz.is_a?(String) ? TZInfo::Timezone.get(tz) : tz; end", __FILE__, __LINE__) - end - - # Assume the given DateTime has a correct time but a wrong timezone. It is - # currently in UTC timezone, but it should be converted to the input_timzone. - # Keep the time the same but convert the timezone to the input_timezone. - # Expects the input_timezone to be a TZInfo::Timezone instance. - def self.convert_input_datetime_other(v, input_timezone) # :nodoc: - local_offset = input_timezone.period_for_local(v).utc_total_offset_rational - (v - local_offset).new_offset(local_offset) - end + module NamedTimezones + private + + # Assume the given DateTime has a correct time but a wrong timezone. It is + # currently in UTC timezone, but it should be converted to the input_timzone. + # Keep the time the same but convert the timezone to the input_timezone. + # Expects the input_timezone to be a TZInfo::Timezone instance. + def convert_input_datetime_other(v, input_timezone) + local_offset = input_timezone.period_for_local(v).utc_total_offset_rational + (v - local_offset).new_offset(local_offset) + end - # Convert the given DateTime to use the given output_timezone. - # Expects the output_timezone to be a TZInfo::Timezone instance. - def self.convert_output_datetime_other(v, output_timezone) # :nodoc: - # TZInfo converts times, but expects the given DateTime to have an offset - # of 0 and always leaves the timezone offset as 0 - v = output_timezone.utc_to_local(v.new_offset(0)) - local_offset = output_timezone.period_for_local(v).utc_total_offset_rational - # Convert timezone offset from UTC to the offset for the output_timezone - (v - local_offset).new_offset(local_offset) + # Convert the given DateTime to use the given output_timezone. + # Expects the output_timezone to be a TZInfo::Timezone instance. + def convert_output_datetime_other(v, output_timezone) + # TZInfo converts times, but expects the given DateTime to have an offset + # of 0 and always leaves the timezone offset as 0 + v = output_timezone.utc_to_local(v.new_offset(0)) + local_offset = output_timezone.period_for_local(v).utc_total_offset_rational + # Convert timezone offset from UTC to the offset for the output_timezone + (v - local_offset).new_offset(local_offset) + end + + # Convert the timezone setter argument. Returns argument given by default, + # exists for easier overriding in extensions. + def convert_timezone_setter_arg(tz) + tz.is_a?(String) ? TZInfo::Timezone.get(tz) : super + end end - private_class_method :convert_input_datetime_other, :convert_output_datetime_other + extend NamedTimezones end diff --git a/lib/sequel/extensions/thread_local_timezones.rb b/lib/sequel/extensions/thread_local_timezones.rb new file mode 100644 index 0000000000..6073ece5b9 --- /dev/null +++ b/lib/sequel/extensions/thread_local_timezones.rb @@ -0,0 +1,48 @@ +# The thread_local_timezones extension allows you to set a per-thread timezone that +# will override the default global timezone while the thread is executing. The +# main use case is for web applications that execute each request in its own thread, +# and want to set the timezones based on the request. The most common example +# is having the database always store time in UTC, but have the application deal +# with the timezone of the current user. That can be done with: +# +# Sequel.database_timezone = :utc +# # In each thread: +# Sequel.thread_application_timezone = current_user.timezone +# +# This extension is designed to work with the named_timezones extension. +# +# This extension adds the thread_application_timezone=, thread_database_timezone=, +# and thread_typecast_timezone= methods to the Sequel module. It overrides +# the application_timezone, database_timezone, and typecast_timezone +# methods to check the related thread local timezone first, and use it if present. +# If the related thread local timezone is not present, it falls back to the +# default global timezone. +# +# There is one special case of note. If you have a default global timezone +# and you want to have a nil thread local timezone, you have to set the thread +# local value to :nil instead of nil: +# +# Sequel.application_timezone = :utc +# Sequel.thread_application_timezone = nil +# Sequel.application_timezone # => :utc +# Sequel.thread_application_timezone = :nil +# Sequel.application_timezone # => nil + +module Sequel + module ThreadLocalTimezones + %w'application database typecast'.each do |t| + class_eval("def thread_#{t}_timezone=(tz); Thread.current[:#{t}_timezone] = convert_timezone_setter_arg(tz); end", __FILE__, __LINE__) + class_eval(< e + raise convert_exception_class(e, InvalidValue) + end + end + + # Convert the timezone setter argument. Returns argument given by default, + # exists for easier overriding in extensions. + def convert_timezone_setter_arg(tz) + tz + end + end + + extend Timezones +end diff --git a/spec/extensions/named_timezones_spec.rb b/spec/extensions/named_timezones_spec.rb index 6280d1c0c0..2712b45cb6 100644 --- a/spec/extensions/named_timezones_spec.rb +++ b/spec/extensions/named_timezones_spec.rb @@ -63,5 +63,10 @@ def ds.supports_timestamp_usecs?; false; end dt.should == @dt + Rational(1, 6) dt.offset.should == Rational(-7, 24) end + + it "should work with the thread_local_timezones extension" do + [Thread.new{Sequel.thread_application_timezone = 'America/New_York'; sleep 0.03; Sequel.application_timezone.should == @tz_out}, + Thread.new{sleep 0.01; Sequel.thread_application_timezone = 'America/Los_Angeles'; sleep 0.01; Sequel.application_timezone.should == @tz_in}].each{|x| x.join} + end end end diff --git a/spec/extensions/spec_helper.rb b/spec/extensions/spec_helper.rb index 4730a284f2..8a55154812 100644 --- a/spec/extensions/spec_helper.rb +++ b/spec/extensions/spec_helper.rb @@ -8,7 +8,7 @@ require 'sequel/model' end -Sequel.extension(*%w'string_date_time inflector pagination query pretty_table blank migration schema_dumper looser_typecasting sql_expr') +Sequel.extension(*%w'string_date_time inflector pagination query pretty_table blank migration schema_dumper looser_typecasting sql_expr thread_local_timezones') {:hook_class_methods=>[], :schema=>[], :validation_class_methods=>[]}.each{|p, opts| Sequel::Model.plugin(p, *opts)} class MockDataset < Sequel::Dataset diff --git a/spec/extensions/thread_local_timezones_spec.rb b/spec/extensions/thread_local_timezones_spec.rb new file mode 100644 index 0000000000..520e5e9564 --- /dev/null +++ b/spec/extensions/thread_local_timezones_spec.rb @@ -0,0 +1,45 @@ +require File.join(File.dirname(__FILE__), "spec_helper") + +describe "Sequel thread_local_timezones extension" do + after do + Sequel.default_timezone = nil + Sequel.thread_application_timezone = nil + Sequel.thread_database_timezone = nil + Sequel.thread_typecast_timezone = nil + end + + it "should allow specifying thread local timezones via thread_*_timezone=" do + proc{Sequel.thread_application_timezone = :local}.should_not raise_error + proc{Sequel.thread_database_timezone = :utc}.should_not raise_error + proc{Sequel.thread_typecast_timezone = nil}.should_not raise_error + end + + it "should use thread local timezone if available" do + Sequel.thread_application_timezone = :local + Sequel.application_timezone.should == :local + Sequel.thread_database_timezone = :utc + Sequel.database_timezone.should == :utc + Sequel.thread_typecast_timezone = nil + Sequel.typecast_timezone.should == nil + end + + it "should fallback to default timezone if no thread_local timezone" do + Sequel.default_timezone = :utc + Sequel.application_timezone.should == :utc + Sequel.database_timezone.should == :utc + Sequel.typecast_timezone.should == :utc + end + + it "should use a nil thread_local_timezone if set instead of falling back to the default timezone if thread_local_timezone is set to :nil" do + Sequel.typecast_timezone = :utc + Sequel.thread_typecast_timezone = nil + Sequel.typecast_timezone.should == :utc + Sequel.thread_typecast_timezone = :nil + Sequel.typecast_timezone.should == nil + end + + it "should be thread safe" do + [Thread.new{Sequel.thread_application_timezone = :utc; sleep 0.03; Sequel.application_timezone.should == :utc}, + Thread.new{sleep 0.01; Sequel.thread_application_timezone = :local; sleep 0.01; Sequel.application_timezone.should == :local}].each{|x| x.join} + end +end