Browse files

Added Sybase database adapter that relies on the Sybase Open Client b…

…indings (see http://raa.ruby-lang.org/project/sybase-ctlib) (closes #3765) [John Sheets]

git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@3735 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
  • Loading branch information...
1 parent 6a3f4c9 commit ec4dd572e777db80f7dc74c2093ba2ea951fedec @dhh dhh committed Mar 2, 2006
Showing with 599 additions and 0 deletions.
  1. +599 −0 activerecord/lib/active_record/connection_adapters/sybase_adapter.rb
View
599 activerecord/lib/active_record/connection_adapters/sybase_adapter.rb
@@ -0,0 +1,599 @@
+# sybase_adaptor.rb
+# Author: John Sheets <dev@metacasa.net>
+# Date: 01 Mar 2006
+#
+# Based on code from Will Sobel (http://dev.rubyonrails.org/ticket/2030)
+
+require 'active_record/connection_adapters/abstract_adapter'
+require 'sybsql'
+
+module ActiveRecord
+ class Base
+ # Establishes a connection to the database that's used by all Active Record objects
+ def self.sybase_connection(config) # :nodoc:
+ config = config.symbolize_keys
+
+ username = config[:username] ? config[:username].to_s : 'sa'
+ password = config[:password] ? config[:password].to_s : ''
+
+ if config.has_key?(:host)
+ host = config[:host]
+ else
+ raise ArgumentError, "No database server name specified. Missing argument: host."
+ end
+
+ if config.has_key?(:database)
+ database = config[:database]
+ else
+ raise ArgumentError, "No database specified. Missing argument: database."
+ end
+
+ ConnectionAdapters::SybaseAdapter.new(
+ SybSQL.new({'S' => host, 'U' => username, 'P' => password},
+ ConnectionAdapters::SybaseAdapterContext), database, logger)
+ end
+ end # class Base
+
+ module ConnectionAdapters
+
+ # ActiveRecord connection adapter for Sybase Open Client bindings
+ # (see http://raa.ruby-lang.org/project/sybase-ctlib).
+ #
+ # Options:
+ #
+ # * <tt>:host</tt> -- The name of the database server. No default, must be provided.
+ # * <tt>:database</tt> -- The name of the database. No default, must be provided.
+ # * <tt>:username</tt> -- Defaults to sa.
+ # * <tt>:password</tt> -- Defaults to empty string.
+ #
+ # Usage Notes:
+ # * Does not support DATE SQL column types; use DATETIME instead.
+ # * Date columns on HABTM join tables are returned as String, not Time.
+ # * Insertions are potentially broken for :polymorphic join tables
+ # * BLOB column access not yet fully supported
+ #
+ class SybaseAdapter < AbstractAdapter # :nodoc:
+ class ColumnWithIdentity < Column
+ attr_reader :identity, :primary
+
+ def initialize(name, default, sql_type = nil, nullable = nil, identity = nil, primary = nil)
+ super(name, default, sql_type, nullable)
+ @default = type_cast(default)
+ @identity = identity
+ @primary = primary
+ end
+
+ def simplified_type(field_type)
+ case field_type
+ when /int|bigint|smallint|tinyint/i then :integer
+ when /float|double|decimal|money|numeric|real|smallmoney/i then :float
+ when /text|ntext/i then :text
+ when /binary|image|varbinary/i then :binary
+ when /char|nchar|nvarchar|string|varchar/i then :string
+ when /bit/i then :boolean
+ when /datetime|smalldatetime/i then :datetime
+ else super
+ end
+ end
+
+ def self.string_to_binary(value)
+ "0x#{value.unpack("H*")[0]}"
+ end
+
+ def self.binary_to_string(value)
+ # FIXME: sybase-ctlib uses separate sql method for binary columns.
+ value
+ end
+ end # class ColumnWithIdentity
+
+ # Sybase adapter
+ def initialize(connection, database, logger = nil)
+ super(connection, logger)
+ context = connection.context
+ context.init(logger)
+ @limit = @offset = 0
+ unless connection.sql_norow("USE #{database}")
+ raise "Cannot USE #{database}"
+ end
+ end
+
+ def native_database_types
+ {
+ :primary_key => "numeric(9,0) IDENTITY PRIMARY KEY",
+ :string => { :name => "varchar", :limit => 255 },
+ :text => { :name => "text" },
+ :integer => { :name => "int" },
+ :float => { :name => "float", :limit => 8 },
+ :datetime => { :name => "datetime" },
+ :timestamp => { :name => "timestamp" },
+ :time => { :name => "time" },
+ :date => { :name => "datetime" },
+ :binary => { :name => "image"},
+ :boolean => { :name => "tinyint", :limit => 1 }
+ }
+ end
+
+ def adapter_name
+ 'Sybase'
+ end
+
+ def active?
+ !(@connection.connection.nil? || @connection.connection_dead?)
+ end
+
+ def disconnect!
+ @connection.close rescue nil
+ end
+
+ def reconnect!
+ raise "Sybase Connection Adapter does not yet support reconnect!"
+ #@connection.close rescue nil
+ end
+
+ # Check for a limit statement and parse out the limit and
+ # offset if specified. Remove the limit from the sql statement
+ # and call select.
+ def select_all(sql, name = nil)
+ select(sql, name)
+ end
+
+ # Remove limit clause from statement. This will almost always
+ # contain LIMIT 1 from the caller. set the rowcount to 1 before
+ # calling select.
+ def select_one(sql, name = nil)
+ result = select(sql, name)
+ result.nil? ? nil : result.first
+ end
+
+ def columns(table_name, name = nil)
+ table_structure(table_name).inject([]) do |columns, column|
+ name, default, type, nullable, identity, primary = column
+ columns << ColumnWithIdentity.new(name, default, type, nullable, identity, primary)
+ columns
+ end
+ end
+
+ def insert(sql, name = nil, pk = nil, id_value = nil, sequence_name = nil)
+ begin
+ table_name = get_table_name(sql)
+ col = get_identity_column(table_name)
+ ii_enabled = false
+
+ if col != nil
+ if query_contains_identity_column(sql, col)
+ begin
+ execute enable_identity_insert(table_name, true)
+ ii_enabled = true
+ rescue Exception => e
+ raise ActiveRecordError, "IDENTITY_INSERT could not be turned ON"
+ end
+ end
+ end
+
+ log(sql, name) do
+ execute(sql, name)
+ ident = select_one("SELECT @@IDENTITY AS last_id")["last_id"]
+ id_value || ident
+ end
+ ensure
+ if ii_enabled
+ begin
+ execute enable_identity_insert(table_name, false)
+ rescue Exception => e
+ raise ActiveRecordError, "IDENTITY_INSERT could not be turned OFF"
+ end
+ end
+ end
+ end
+
+ def execute(sql, name = nil)
+ log(sql, name) do
+ @connection.context.reset
+ @connection.set_rowcount(@limit || 0)
+ @limit = @offset = nil
+ @connection.sql_norow(sql)
+ if @connection.cmd_fail? or @connection.context.failed?
+ raise "SQL Command Failed for #{name}: #{sql}\nMessage: #{@connection.context.message}"
+ end
+ end
+ # Return rows affected
+ @connection.results[0].row_count
+ end
+
+ alias_method :update, :execute
+ alias_method :delete, :execute
+
+ def begin_db_transaction() execute "BEGIN TRAN" end
+ def commit_db_transaction() execute "COMMIT TRAN" end
+ def rollback_db_transaction() execute "ROLLBACK TRAN" end
+
+ def tables(name = nil)
+ tables = []
+ select("select name from sysobjects where type='U'", name).each do |row|
+ tables << row['name']
+ end
+ tables
+ end
+
+ def indexes(table_name, name = nil)
+ indexes = []
+ select("exec sp_helpindex #{table_name}", name).each do |index|
+ unique = index["index_description"] =~ /unique/
+ primary = index["index_description"] =~ /^clustered/
+ if !primary
+ cols = index["index_keys"].split(", ").each { |col| col.strip! }
+ indexes << IndexDefinition.new(table_name, index["index_name"], unique, cols)
+ end
+ end
+ indexes
+ end
+
+ def remove_index(table_name, options = {})
+ # Override for different Sybase SQL syntax.
+ execute "DROP INDEX #{table_name}.#{index_name(table_name, options)}"
+ end
+
+ def quoted_true
+ "1"
+ end
+
+ def quoted_false
+ "0"
+ end
+
+ def quote(value, column = nil)
+ case value
+ when String
+ if column && column.type == :binary
+ "#{quote_string(column.class.string_to_binary(value))}"
+ elsif value =~ /^[+-]?[0-9]+$/o
+ value
+ else
+ "'#{quote_string(value)}'"
+ end
+ when NilClass then "NULL"
+ when TrueClass then '1'
+ when FalseClass then '0'
+ when Float, Fixnum, Bignum then value.to_s
+ when Date then "'#{value.to_s}'"
+ when Time, DateTime then "'#{value.strftime("%Y-%m-%d %H:%M:%S")}'"
+ else "'#{quote_string(value.to_yaml)}'"
+ end
+ end
+
+ def quote_column(type, value)
+ case type
+ when :boolean
+ case value
+ when String then value =~ /^[ty]/o ? 1 : 0
+ when true then 1
+ when false then 0
+ else value.to_i
+ end
+ when :integer then value.to_i
+ when :float then value.to_f
+ when :text, :string, :enum
+ case value
+ when String, Symbol, Fixnum, Float, Bignum, TrueClass, FalseClass
+ "'#{quote_string(value.to_s)}'"
+ else
+ "'#{quote_string(value.to_yaml)}'"
+ end
+ when :date, :datetime, :time
+ case value
+ when Time, DateTime then "'#{value.strftime("%Y-%m-%d %H:%M:%S")}'"
+ when Date then "'#{value.to_s}'"
+ else "'#{quote_string(value)}'"
+ end
+ else "'#{quote_string(value.to_yaml)}'"
+ end
+ end
+
+ def quote_string(s)
+ s.gsub(/'/, "''") # ' (for ruby-mode)
+ end
+
+ def quote_column_name(name)
+ "[#{name}]"
+ end
+
+ def add_limit_offset!(sql, options) # :nodoc:
+ @limit = options[:limit]
+ @offset = options[:offset]
+ if !normal_select?
+ # Use temp table to hack offset with Sybase
+ sql.sub!(/ FROM /i, ' INTO #artemp FROM ')
+ elsif zero_limit?
+ # "SET ROWCOUNT 0" turns off limits, so we have
+ # to use a cheap trick.
+ if sql =~ /WHERE/i
+ sql.sub!(/WHERE/i, 'WHERE 1 = 2 AND ')
+ elsif sql =~ /ORDER\s+BY/i
+ sql.sub!(/ORDER\s+BY/i, 'WHERE 1 = 2 ORDER BY')
+ else
+ sql << 'WHERE 1 = 2'
+ end
+ end
+ end
+
+ private
+ # Return the last value of the identity global value.
+ def last_insert_id
+ @connection.sql("SELECT @@IDENTITY")
+ unless @connection.cmd_fail?
+ id = @connection.top_row_result.rows.first.first
+ if id
+ id = id.to_i
+ id = nil if id == 0
+ end
+ else
+ id = nil
+ end
+ id
+ end
+
+ def affected_rows(name = nil)
+ @connection.sql("SELECT @@ROWCOUNT")
+ unless @connection.cmd_fail?
+ count = @connection.top_row_result.rows.first.first
+ count = count.to_i if count
+ else
+ 0
+ end
+ end
+
+ def normal_select?
+ # If limit is not set at all, we can ignore offset;
+ # If limit *is* set but offset is zero, use normal select
+ # with simple SET ROWCOUNT. Thus, only use the temp table
+ # if limit is set and offset > 0.
+ has_limit = !@limit.nil?
+ has_offset = !@offset.nil? && @offset > 0
+ !has_limit || !has_offset
+ end
+
+ def zero_limit?
+ !@limit.nil? && @limit == 0
+ end
+
+ # Select limit number of rows starting at optional offset.
+ def select(sql, name = nil)
+ @connection.context.reset
+ log(sql, name) do
+ if normal_select?
+ # If limit is not explicitly set, return all results.
+ @logger.debug "Setting row count to (#{@limit})" if @logger
+
+ # Run a normal select
+ @connection.set_rowcount(@limit || 0)
+ @connection.sql(sql)
+ else
+ # Select into a temp table and prune results
+ @logger.debug "Selecting #{@limit + (@offset || 0)} or fewer rows into #artemp" if @logger
+ @connection.set_rowcount(@limit + (@offset || 0))
+ @connection.sql_norow(sql) # Select into temp table
+ @logger.debug "Deleting #{@offset || 0} or fewer rows from #artemp" if @logger
+ @connection.set_rowcount(@offset || 0)
+ @connection.sql_norow("delete from #artemp") # Delete leading rows
+ @connection.set_rowcount(0)
+ @connection.sql("select * from #artemp") # Return the rest
+ end
+ end
+
+ rows = []
+ if @connection.context.failed? or @connection.cmd_fail?
+ raise StatementInvalid, "SQL Command Failed for #{name}: #{sql}\nMessage: #{@connection.context.message}"
+ else
+ results = @connection.top_row_result
+ if results && results.rows.length > 0
+ fields = fixup_column_names(results.columns)
+ results.rows.each do |row|
+ hashed_row = {}
+ row.zip(fields) { |cell, column| hashed_row[column] = cell }
+ rows << hashed_row
+ end
+ end
+ end
+ @connection.sql_norow("drop table #artemp") if !normal_select?
+ @limit = @offset = nil
+ return rows
+ end
+
+ def enable_identity_insert(table_name, enable = true)
+ if has_identity_column(table_name)
+ "SET IDENTITY_INSERT #{table_name} #{enable ? 'ON' : 'OFF'}"
+ end
+ end
+
+ def get_table_name(sql)
+ if sql =~ /^\s*insert\s+into\s+([^\(\s]+)\s*|^\s*update\s+([^\(\s]+)\s*/i
+ $1
+ elsif sql =~ /from\s+([^\(\s]+)\s*/i
+ $1
+ else
+ nil
+ end
+ end
+
+ def has_identity_column(table_name)
+ !get_identity_column(table_name).nil?
+ end
+
+ def get_identity_column(table_name)
+ @table_columns = {} unless @table_columns
+ @table_columns[table_name] = columns(table_name) if @table_columns[table_name] == nil
+ @table_columns[table_name].each do |col|
+ return col.name if col.identity
+ end
+
+ return nil
+ end
+
+ def query_contains_identity_column(sql, col)
+ sql =~ /\[#{col}\]/
+ end
+
+ # Remove trailing _ from names.
+ def fixup_column_names(columns)
+ columns.map { |column| column.sub(/_$/, '') }
+ end
+
+ def table_structure(table_name)
+ sql = <<SQLTEXT
+SELECT col.name AS name, type.name AS type, col.prec, col.scale, col.length,
+ col.status, obj.sysstat2, def.text
+ FROM sysobjects obj, syscolumns col, systypes type, syscomments def
+ WHERE obj.id = col.id AND col.usertype = type.usertype AND col.cdefault *= def.id
+ AND obj.type = 'U' AND obj.name = '#{table_name}' ORDER BY col.colid
+SQLTEXT
+ log(sql, "Get Column Info ") do
+ @connection.set_rowcount(0)
+ @connection.sql(sql)
+ end
+ if @connection.context.failed?
+ raise "SQL Command for table_structure for #{table_name} failed\nMessage: #{@connection.context.message}"
+ elsif !@connection.cmd_fail?
+ columns = []
+ results = @connection.top_row_result
+ results.rows.each do |row|
+ name, type, prec, scale, length, status, sysstat2, default = row
+ type = normalize_type(type, prec, scale, length)
+ default_value = nil
+ name.sub!(/_$/o, '')
+ if default =~ /DEFAULT\s+(.+)/o
+ default_value = $1.strip
+ default_value = default_value[1...-1] if default_value =~ /^['"]/o
+ end
+ nullable = (status & 8) == 8
+ identity = status >= 128
+ primary = (sysstat2 & 8) == 8
+
+ columns << [name, default_value, type, nullable, identity, primary]
+ end
+ columns
+ else
+ nil
+ end
+ end
+
+ def normalize_type(field_type, prec, scale, length)
+ if field_type =~ /numeric/i and (scale.nil? or scale == 0)
+ type = 'int'
+ elsif field_type =~ /money/i
+ type = 'numeric'
+ else
+ type = field_type
+ end
+ size = ''
+ if prec
+ size = "(#{prec})"
+ elsif length
+ size = "(#{length})"
+ end
+ return type + size
+ end
+
+ def default_value(value)
+ end
+ end # class SybaseAdapter
+
+ class SybaseAdapterContext < SybSQLContext
+ DEADLOCK = 1205
+ attr_reader :message
+
+ def init(logger = nil)
+ @deadlocked = false
+ @failed = false
+ @logger = logger
+ @message = nil
+ end
+
+ def srvmsgCB(con, msg)
+ # Do not log change of context messages.
+ if msg['severity'] == 10 or msg['severity'] == 0
+ return true
+ end
+
+ if msg['msgnumber'] == DEADLOCK
+ @deadlocked = true
+ else
+ @logger.info "SQL Command failed!" if @logger
+ @failed = true
+ end
+
+ if @logger
+ @logger.error "** SybSQLContext Server Message: **"
+ @logger.error " Message number #{msg['msgnumber']} Severity #{msg['severity']} State #{msg['state']} Line #{msg['line']}"
+ @logger.error " Server #{msg['srvname']}"
+ @logger.error " Procedure #{msg['proc']}"
+ @logger.error " Message String: #{msg['text']}"
+ end
+
+ @message = msg['text']
+
+ true
+ end
+
+ def deadlocked?
+ @deadlocked
+ end
+
+ def failed?
+ @failed
+ end
+
+ def reset
+ @deadlocked = false
+ @failed = false
+ @message = nil
+ end
+
+ def cltmsgCB(con, msg)
+ return true unless ( msg.kind_of?(Hash) )
+ unless ( msg[ "severity" ] ) then
+ return true
+ end
+
+ if @logger
+ @logger.error "** SybSQLContext Client-Message: **"
+ @logger.error " Message number: LAYER=#{msg[ 'layer' ]} ORIGIN=#{msg[ 'origin' ]} SEVERITY=#{msg[ 'severity' ]} NUMBER=#{msg[ 'number' ]}"
+ @logger.error " Message String: #{msg['msgstring']}"
+ @logger.error " OS Error: #{msg['osstring']}"
+
+ @message = msg['msgstring']
+ end
+
+ @failed = true
+
+ # Not retry , CS_CV_RETRY_FAIL( probability TimeOut )
+ if( msg[ 'severity' ] == "RETRY_FAIL" ) then
+ @timeout_p = true
+ return false
+ end
+
+ return true
+ end
+ end # class SybaseAdapterContext
+
+ end # module ConnectionAdapters
+end # module ActiveRecord
+
+
+# Allow identity inserts for fixtures.
+require "active_record/fixtures"
+class Fixtures
+ alias :original_insert_fixtures :insert_fixtures
+
+ def insert_fixtures
+ values.each do |fixture|
+ allow_identity_inserts table_name, true
+ @connection.execute "INSERT INTO #{@table_name} (#{fixture.key_list}) VALUES (#{fixture.value_list})", 'Fixture Insert'
+ allow_identity_inserts table_name, false
+ end
+ end
+
+ def allow_identity_inserts(table_name, enable)
+ @connection.execute "SET IDENTITY_INSERT #{table_name} #{enable ? 'ON' : 'OFF'}" rescue nil
+ end
+
+end

0 comments on commit ec4dd57

Please sign in to comment.