Skip to content

Commit

Permalink
Add thread_local_timezones extension, and refactor timezone support i…
Browse files Browse the repository at this point in the history
…n modules

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

In order to implement this more easily, and just for generally better
design, the timezone support in the main Sequel module was separated
into the Sequel::Timezones module, which extends the main Sequel
module.  The named_timezones extension now uses a NamedTimezones
module which also extends Sequel.  And the new thread_local_timezones
extension adds a ThreadLocalTimezones which also extends Sequel.

A new private convert_timezone_setter_arg method was added to
ease implementation and allow the thread_local_timezones and
named_timezones extensions to work better together.
  • Loading branch information
jeremyevans committed Sep 16, 2009
1 parent 97c11f0 commit f899e55
Show file tree
Hide file tree
Showing 7 changed files with 290 additions and 170 deletions.
149 changes: 2 additions & 147 deletions lib/sequel/core.rb
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
#
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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')
Expand Down
50 changes: 28 additions & 22 deletions lib/sequel/extensions/named_timezones.rb
Expand Up @@ -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
48 changes: 48 additions & 0 deletions 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(<<END, __FILE__, __LINE__)
def #{t}_timezone
if tz = Thread.current[:#{t}_timezone]
tz unless tz == :nil
else
super
end
end
END
end
end

extend ThreadLocalTimezones
end

0 comments on commit f899e55

Please sign in to comment.