Ruby on Rails | Screencasts | Download | Documentation | Weblog | Community | Source

Ticket #2030 (closed enhancement: fixed)

Opened 3 years ago

Last modified 2 years ago

[PATCH] New sybase adapter.

Reported by: willsobel@mac.com Assigned to: David
Priority: normal Milestone:
Component: ActiveRecord Version: 0.12.1
Severity: normal Keywords:
Cc:

Description

This is an adapter for sybase 12.5. It uses the ctlib library available from

http://raa.ruby-lang.org/project/sybase-ctlib/0.2.7

I have made a small change to support text objects in that library by setting the SYB_CTLIB_VERSION to CS_VERSION_125. This allows the text objects containing the session state to function correctly since the standard 100 varchar binding will only allow 255 characters. This change will support 32K. I have mailed the maintainer with this change...

This adapter has been tested and works. I wouldn't say it is optimal since it does not handle limits very well, but there doesn't seem to be any way to do this in an efficient manor in Sybase. We'll need to wait until version 15 for that.

There is one additional feature here that I use. I have special handling of ENUM type: sp_addtype ENUM, "VARCHAR(16)" ;

I have also created a subclass of the Column class to handle the values in the check constraint. With a little imagination and some code I have created a helper (not in contributable form yet) that creates drop-downs and some validation support, all for free. Please take a look and tell me what you think.

I'm sorry I cannot test this code with the latest version because I no longer have access to a Sybase instance. If anyone does or can help, it would be greatly appreciated. I have tested this with 12.1 as that is the latest version we are currently using in production.

Hope this helps, Will Sobel

# sybase_adaptor.rb
# author: Will Sobel
# notes: 

require 'active_record/connection_adapters/abstract_adapter'
require 'parsedate'
require 'sybsql'

module ActiveRecord
    module ConnectionAdapters
        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 SybaseAdapter < AbstractAdapter # :nodoc:
            class Column < ::ActiveRecord::ConnectionAdapters::Column
                attr_reader :values

                def initialize(name, default, sql_type = nil, values = nil)
                    super(name, default, sql_type)
                    # puts "#{@name} : #{sql_type} => #{@type}"
                    @values = values
                    @default = type_cast(default)
                end

                def klass
                    if @type == :enum
                        Symbol
                    else
                        super
                    end
                end
                
                def simplified_type(type)
                    case type
                    when /enum/i
                        :enum

                    else
                        super
                    end
                end

                def type_cast(value)
                    return nil if value.nil?
                    
                    case @type
                    when :enum
                        if Symbol === value
                            value
                        elsif String === value and !value.empty?
                            value.intern
                        else
                            nil
                        end
                        
                    when :boolean
                        value == 1 || value == true || value == '1' || value == 'true'
                        
                    else
                        super
                    end
                end
            end

            # 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

            # 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, values = column
                    columns << Column.new(name, default, type, values)
                    columns
                end
            end

            def insert(sql, name = nil, pk = nil, id_value = nil)
                execute(sql, name = nil)
                return id_value || last_insert_id
            end

            def execute(sql, name = nil)
                log(sql, name, @connection) do |connection|
                    @connection.context.reset
                    connection.set_rowcount(@limit)
                    @limit = @offset = 0
                    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
            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

            # Handle quoting of enumerated types and override default
            # boolean handling.
            def quote(value, column = nil)
                unless column
                    # Special hack for numbers. We need to make sure they
                    # are not quoted.
                    if value =~ /^[+-]?[0-9]+$/o
                        value
                    else
                        super
                    end
                else
                    unless value.nil?
                        case column.type
                        when :boolean
                            case value
                            when String
                                value =~ /^[ty]/o ? 1 : 0
                            when true
                                1
                                
                            when false
                                0
                                
                            else
                                value.to_i
                            end
                            
                        when :integer
                            value.to_i
                            
                        when :float
                            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
                                "'#{value.strftime("%Y-%m-%d %H:%M:%S")}'"
                            else
                                "'#{quote_string(value)}'"
                            end
                        else
                            "'#{quote_string(value.to_yaml)}'"
                        end
                    else
                        'NULL'
                    end
                end
            end

            def quote_string(s)
                s.gsub(/'/, "''") # ' (for ruby-mode)
            end

            def quote_column_name(name)
                if name == 'role' or name == 'output'
                    name + '_'
                else
                    name
                end
            end

            def add_limit_with_offset!(sql, limit, offset)
                @offset = offset
                @limit = offset + limit
                @logger.debug "Limit: #{@limit} Offset: #{@offset}"
            end
            
            def add_limit_without_offset!(sql, limit)
                @limit = limit.to_i
                @offset = 0
                @logger.debug "Limit: #{@limit} Offset: #{@offset}"
            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
                # puts "@@identity => #{id.inspect}"
                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

            # Select limit number of rows starting at optional offset.
            def select(sql, name = nil)
                @connection.context.reset
                dump = @offset
                log(sql, name, @connection) do |connection|
                    @logger.debug "Setting row count to #{@limit}" if @logger
                    connection.set_rowcount(@limit)
                    @limit = @offset = 0
                    connection.sql(sql)
                end

                rows = []
                if @connection.context.failed? or @connection.cmd_fail?
                    raise "SQL Command Failed for #{name}: #{sql}\nMessage: #{@connection.context.message}"
                else
                    results = @connection.top_row_result
                    if results.rows.length > 0
                        fields = fixup_column_names(results.columns)
                        results.rows.each do |row|
                            if dump > 0
                                dump -= 1
                                next
                            end
                            hashed_row = {}
                            row.zip(fields) { |cell, column| hashed_row[column] = cell }
                            rows << hashed_row
                        end
                    end
                end
                return rows
            end

            # Remove trailing _ from names.
            def fixup_column_names(columns)
                columns.map { |column| column.sub(/_$/, '') }
            end

            def table_structure(table_name)
                sql = "	SELECT col.name AS name, type.name AS type, col.prec, col.scale, col.length," +
                      "com.text AS \"Constraint\", def.text \"Default\" FROM " +
	                  "sysobjects obj JOIN (syscolumns col JOIN systypes type ON col.usertype = type.usertype " +
	                  "LEFT JOIN (sysconstraints con JOIN syscomments com ON com.id = con.constrid) " +
		              "ON col.colid = con.colid AND col.id = con.tableid " +
	                  "LEFT JOIN syscomments def ON def.id = col.cdefault) " +
	                  "ON obj.id = col.id " +
	                  "WHERE obj.type = 'U' AND obj.name = '#{table_name}'"
                log(sql, "Get Column Info ", @connection) do |connection|
                    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, const, default = row
                        type = normalize_type(type, prec, scale, length)
                        default_value = values = nil
                        name.sub!(/_$/o, '')
                        if type =~ /enum/i and const and const =~ /check\s+\([a-z0-9_]+\s+in\s+\((.+?)\)\)/imo
                            values = $1.split(',').map { |s| s.strip[1...-1].intern }
                        end
                        if default =~ /DEFAULT\s+(.+)/o
                            default_value = $1.strip
                            default_value = default_value[1...-1] if default_value =~ /^['"]/o
                            default_value = default_value.intern if values
                        end

                        columns << [name, default_value, type, values]
                    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
    end

    class Base
        # Establishes a connection to the database that's used by all Active Record objects
        def self.sybase_connection(config) # :nodoc:
            symbolize_strings_in_hash(config)
            host     = config[:host]
            username = config[:username].to_s
            password = config[:password].to_s

            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
end

Change History

03/18/06 16:18:55 changed by david

  • status changed from new to closed.
  • resolution set to fixed.