# sybase_adaptor.rb # Author: John Sheets # # 01 Mar 2006: Initial version. # 17 Mar 2006: Added support for migrations; fixed issues with :boolean columns. # 13 Apr 2006: Improved column type support to properly handle dates and user-defined # types; fixed quoting of integer columns. # # Based on code from Will Sobel (http://dev.rubyonrails.org/ticket/2030) # require 'active_record/connection_adapters/abstract_adapter' begin 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, config, 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: # # * :host -- The name of the database server. No default, must be provided. # * :database -- The name of the database. No default, must be provided. # * :username -- Defaults to "sa". # * :password -- Defaults to empty string. # # Usage Notes: # # * The sybase-ctlib bindings do not support the DATE SQL column type; use DATETIME instead. # * Table and column names are limited to 30 chars in Sybase 12.5 # * :binary columns not yet supported # * :boolean columns use the BIT SQL type, which does not allow nulls or # indexes. If a DEFAULT is not specified for ALTER TABLE commands, the # column will be declared with DEFAULT 0 (false). # # Migrations: # # The Sybase adapter supports migrations, but for ALTER TABLE commands to # work, the database must have the database option 'select into' set to # 'true' with sp_dboption (see below). The sp_helpdb command lists the current # options for all databases. # # 1> use mydb # 2> go # 1> master..sp_dboption mydb, "select into", true # 2> go # 1> checkpoint # 2> go 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, @identity, @primary = type_cast(default), identity, 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_time(string) return string unless string.is_a?(String) # Since Sybase doesn't handle DATE or TIME, handle it here. # Populate nil year/month/day with string_to_dummy_time() values. time_array = ParseDate.parsedate(string)[0..5] time_array[0] ||= 2000; time_array[1] ||= 1; time_array[2] ||= 1; Time.send(Base.default_timezone, *time_array) rescue nil 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, config = {}, logger = nil) super(connection, logger) context = connection.context context.init(logger) @config = config @numconvert = config.has_key?(:numconvert) ? config[:numconvert] : true @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 => "bit" } } 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!" # disconnect! # connect! # Not yet implemented end def table_alias_length 30 end def select_all(sql, name = nil) select(sql, name) end 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 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 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 current_database select_one("select DB_NAME() as name")["name"] 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 quoted_true "1" end def quoted_false "0" end def quote(value, column = nil) case value when String if column && column.type == :binary && column.class.respond_to?(:string_to_binary) "#{quote_string(column.class.string_to_binary(value))}" elsif @numconvert && force_numeric?(column) && value =~ /^[+-]?[0-9]+$/o value else "'#{quote_string(value)}'" end when NilClass then (column && column.type == :boolean) ? '0' : "NULL" when TrueClass then '1' when FalseClass then '0' when Float, Fixnum, Bignum force_numeric?(column) ? value.to_s : "'#{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 # True if column is explicitly declared non-numeric, or # if column is nil (not specified). def force_numeric?(column) (column.nil? || [:integer, :float].include?(column.type)) end def quote_string(s) s.gsub(/'/, "''") # ' (for ruby-mode) end def quote_column_name(name) # If column name is close to max length, skip the quotes, since they # seem to count as part of the length. ((name.to_s.length + 2) <= table_alias_length) ? "[#{name}]" : name.to_s 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 def supports_migrations? #:nodoc: true end def rename_table(name, new_name) execute "EXEC sp_rename '#{name}', '#{new_name}'" end def rename_column(table, column, new_column_name) execute "EXEC sp_rename '#{table}.#{column}', '#{new_column_name}'" end def change_column(table_name, column_name, type, options = {}) #:nodoc: begin execute "ALTER TABLE #{table_name} MODIFY #{column_name} #{type_to_sql(type, options[:limit])}" rescue StatementInvalid => e # Swallow exception if no-op. raise e unless e.message =~ /no columns to drop, add or modify/ end if options[:default] remove_default_constraint(table_name, column_name) execute "ALTER TABLE #{table_name} REPLACE #{column_name} DEFAULT #{options[:default]}" end end def remove_column(table_name, column_name) remove_default_constraint(table_name, column_name) execute "ALTER TABLE #{table_name} DROP #{column_name}" end def remove_default_constraint(table_name, column_name) defaults = select "select def.name from sysobjects def, syscolumns col, sysobjects tab where col.cdefault = def.id and col.name = '#{column_name}' and tab.name = '#{table_name}' and col.id = tab.id" defaults.each {|constraint| execute "ALTER TABLE #{table_name} DROP CONSTRAINT #{constraint["name"]}" } end def remove_index(table_name, options = {}) execute "DROP INDEX #{table_name}.#{index_name(table_name, options)}" end def add_column_options!(sql, options) #:nodoc: sql << " DEFAULT #{quote(options[:default], options[:column])}" unless options[:default].nil? if check_null_for_column?(options[:column], sql) sql << (options[:null] == false ? " NOT NULL" : " NULL") end sql end def enable_identity_insert(table_name, enable = true) if has_identity_column(table_name) execute "SET IDENTITY_INSERT #{table_name} #{enable ? 'ON' : 'OFF'}" end end private def check_null_for_column?(col, sql) # Sybase columns are NOT NULL by default, so explicitly set NULL # if :null option is omitted. Disallow NULLs for boolean. type = col.nil? ? "" : col[:type] # Ignore :null if a primary key return false if type =~ /PRIMARY KEY/i # Ignore :null if a :boolean or BIT column if (sql =~ /\s+bit(\s+DEFAULT)?/i) || type == :boolean # If no default clause found on a boolean column, add one. sql << " DEFAULT 0" if $1.nil? return false end true end # 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 || 'off'})" 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 = results.columns.map { |column| column.sub(/_$/, '') } 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 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 ||= {} @table_columns[table_name] ||= columns(table_name) @table_columns[table_name].each do |col| return col.name if col.identity end nil end def query_contains_identity_column(sql, col) sql =~ /\[#{col}\]/ end def table_structure(table_name) sql = <= 128 primary = (sysstat2 & 8) == 8 columns << [name, default_value, type, nullable, identity, primary] end columns else nil end end # Resolve all user-defined types (udt) to their fundamental types. def resolve_type(field_type) (@udts ||= {})[field_type] ||= select_one("sp_help #{field_type}")["Storage_type"].strip 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 = resolve_type(field_type.strip) end size = '' if prec size = "(#{prec})" elsif length && !(type =~ /date|time/) 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| @connection.enable_identity_insert(table_name, true) @connection.execute "INSERT INTO #{@table_name} (#{fixture.key_list}) VALUES (#{fixture.value_list})", 'Fixture Insert' @connection.enable_identity_insert(table_name, false) end end end rescue LoadError => cannot_require_sybase # Couldn't load sybase adapter end