#-- # Copyright (c) 2008 Jeremy Hinegardner # All rights reserved. See LICENSE and/or COPYING for details. #++ require 'amalgalite3' require 'amalgalite/statement' require 'amalgalite/trace_tap' require 'amalgalite/profile_tap' require 'amalgalite/type_maps/default_map' module Amalgalite # # The encapsulation of a connection to an SQLite3 database. # # Example opening and possibly creating a new daabase # # db = Amalgalite::Database.new( "mydb.db" ) # db.execute( "SELECT * FROM table" ) do |row| # puts row # end # # db.close # # Open a database read only: # # db = Amalgalite::Database.new( "mydb.db", "r" ) # # class Database # Error thrown if a database is opened with an invalid mode class InvalidModeError < ::Amalgalite::Error; end ## # container class for holding transaction behavior constants. These are the # SQLite values passed to a START TRANSACTION SQL statement. # class TransactionBehavior # no read or write locks are created until the first statement is executed # that requries a read or a write DEFERRED = "DEFERRED" # a readlock is obtained immediately so that no other process can write to # the database IMMEDIATE = "IMMEDIATE" # a read+write lock is obtained, no other proces can read or write to the # database EXCLUSIVE = "EXCLUSIVE" # list of valid transaction behavior constants VALID = [ DEFERRED, IMMEDIATE, EXCLUSIVE ] # # is the given mode a valid transaction mode # def self.valid?( mode ) VALID.include? mode end end include Amalgalite::SQLite3::Constants # list of valid modes for opening an Amalgalite::Database VALID_MODES = { "r" => Open::READONLY, "r+" => Open::READWRITE, "w+" => Open::READWRITE | Open::CREATE, } # the low level Amalgalite::SQLite3::Database attr_reader :api # An object that follows the TraceTap protocol, or nil. By default this is nil attr_reader :trace_tap # An object that follows the ProfileTap protocol, or nil. By default this is nil attr_reader :profile_tap # An object that follows the TypeMap protocol, or nil. # By default this is an instances of TypeMaps::DefaultMap attr_reader :type_map ## # Create a new Amalgalite database # # :call-seq: # Amalgalite::Database.new( filename, "w+", opts = {}) -> Database # # The first parameter is the filename of the sqlite database. # The second parameter is the standard file modes of how to open a file. # # The modes are: # # * r - Read-only # * r+ - Read/write, an error is thrown if the database does not already exist # * w+ - Read/write, create a new database if it doesn't exist # # w+ is the default as this is how most databases will want to be utilized. # # opts is a hash of available options for the database: # # * :utf16 option to set the database to a utf16 encoding if creating a database. # # By default, databases are created with an encoding of utf8. Setting this to # true and opening an already existing database has no effect. # # *NOTE* Currently :utf16 is not supported by Amalgalite, it is planned # for a later release # # def initialize( filename, mode = "w+", opts = {}) @open = false @profile_tap = nil @trace_tap = nil @type_map = ::Amalgalite::TypeMaps::DefaultMap.new unless VALID_MODES.keys.include?( mode ) raise InvalidModeError, "#{mode} is invalid, must be one of #{VALID_MODES.keys.join(', ')}" end if not File.exist?( filename ) and opts[:utf16] then raise NotImplementedError, "Currently Amalgalite has not implemented utf16 support" else @api = Amalgalite::SQLite3::Database.open( filename, VALID_MODES[mode] ) end @open = true end ## # Is the database open or not # def open? @open end ## # Close the database # def close if open? then @api.close end end ## # Is the database in autocommit mode or not # def autocommit? @api.autocommit? end ## # Return the rowid of the last inserted row # def last_insert_rowid @api.last_insert_rowid end ## # Is the database utf16 or not? A database is utf16 if the encoding is not # UTF-8. Database can only be UTF-8 or UTF-16, and the default is UTF-8 # def utf16? unless @utf16.nil? @utf16 = (encoding != "UTF-8") end return @utf16 end ## # return the encoding of the database # def encoding unless @encoding @encoding = pragma( "encoding" ).first['encoding'] end return @encoding end ## # return whether or not the database is currently in a transaction or not # def in_transaction? not @api.autocommit? end ## # return how many rows changed in the last insert, update or delete statement. # def row_changes @api.row_changes end ## # return how many rows have changed since this connection to the database was # opened. # def total_changes @api.total_changes end ## # Prepare a statement for execution # # If called with a block, the statement is yielded to the block and the # statement is closed when the block is done. # # db.prepare( "SELECT * FROM table WHERE c = ?" ) do |stmt| # list_of_c_values.each do |c| # stmt.execute( c ) do |row| # puts "when c = #{c} : #{row.inspect}" # end # end # end # # Or without a block: # # stmt = db.prepare( "INSERT INTO t1(x, y, z) VALUES ( : # def prepare( sql ) stmt = Amalgalite::Statement.new( self, sql ) if block_given? then begin yield stmt ensure stmt.close stmt = nil end end return stmt end ## # Execute a single SQL statement. # # If called with a block and there are result rows, then they are iteratively # yielded to the block. # # If no block passed and there are results, then a ResultSet is returned. # Otherwise nil is returned. On an error an exception is thrown. # # This is just a wrapper around the preparation of an Amalgalite Statement and # iterating over the results. # def execute( sql, *bind_params ) stmt = prepare( sql ) stmt.bind( *bind_params ) if block_given? then stmt.each { |row| yield row } else return stmt.all_rows end ensure stmt.close if stmt end ## # Execute a batch of statements, this will execute all the sql in the given # string until no more sql can be found in the string. It will bind the # same parameters to each statement. All data that would be returned from # all of the statements is thrown away. # # All statements to be executed in the batch must be terminated with a ';' # Returns the number of statements executed # # def execute_batch( sql, *bind_params) count = 0 while sql prepare( sql ) do |stmt| stmt.execute( *bind_params ) sql = stmt.remaining_sql sql = nil unless (sql.index(";") and Amalgalite::SQLite3.complete?( sql )) end count += 1 end return count end ## # clear all the current taps # def clear_taps! self.trace_tap = nil self.profile_tap = nil end ## # call-seq: # db.trace_tap = obj # # Register a trace tap. # # Registering a trace tap measn that the +obj+ registered will have its # +trace+ method called with a string parameter at various times. # If the object doesn't respond to the +trace+ method then +write+ # will be called. # # For instance: # # db.trace_tap = Amalgalite::TraceTap.new( logger, 'debug' ) # # This will register an instance of TraceTap, which wraps an logger object. # On each +trace+ event the TraceTap#trace method will be called, which in # turn will call the logger.debug method # # db.trace_tap = $stderr # # This will register the $stderr io stream as a trace tap. Every time a # +trace+ event happens then $stderr.write( msg ) will be called. # # db.trace_tap = nil # # This will unregistere the trace tap # # def trace_tap=( tap_obj ) # unregister any previous trace tap # unless @trace_tap.nil? @trace_tap.trace( 'unregistered as trace tap' ) @trace_tap = nil end return @trace_tap if tap_obj.nil? # wrap the tap if we need to # if tap_obj.respond_to?( 'trace' ) then @trace_tap = tap_obj elsif tap_obj.respond_to?( 'write' ) then @trace_tap = Amalgalite::TraceTap.new( tap_obj, 'write' ) else raise Amalgalite::Error, "#{tap_obj.class.name} cannot be used to tap. It has no 'write' or 'trace' method. Look at wrapping it in a Tap instances." end # and do the low level registration # @api.register_trace_tap( @trace_tap ) @trace_tap.trace( 'registered as trace tap' ) end ## # call-seq: # db.profile_tap = obj # # Register a profile tap. # # Registering a profile tap means that the +obj+ registered will have its # +profile+ method called with an Integer and a String parameter every time # a profile event happens. The Integer is the number of nanoseconds it took # for the String (SQL) to execute in wall-clock time. # # That is, every time a profile event happens in SQLite the following is # invoked: # # obj.profile( str, int ) # # For instance: # # db.profile_tap = Amalgalite::ProfileTap.new( logger, 'debug' ) # # This will register an instance of ProfileTap, which wraps an logger object. # On each +profile+ event the ProfileTap#profile method will be called # which in turn will call logger.debug with a formatted string containing # the String and Integer from the profile event. # # db.profile_tap = nil # # This will unregister the profile tap # # def profile_tap=( tap_obj ) # unregister any previous profile tap unless @profile_tap.nil? @profile_tap.profile( 'unregistered as profile tap', 0.0 ) @profile_tap = nil end return @profile_tap if tap_obj.nil? if tap_obj.respond_to?( 'profile' ) then @profile_tap = tap_obj else raise Amalgalite::Error, "#{tap_obj.class.name} cannot be used to tap. It has no 'profile' method" end @api.register_profile_tap( @profile_tap ) @profile_tap.profile( 'registered as profile tap', 0.0 ) end ## # call-seq: # db.type_map = DefaultMap.new # # Assign your own TypeMap instance to do type conversions. The value # assigned here must respond to +bind_type_of+ and +result_value_of+ # methods. See the TypeMap class for more details. # # def type_map=( type_map_obj ) %w[ bind_type_of result_value_of ].each do |method| unless type_map_obj.respond_to?( method ) raise Amalgalite::Error, "#{type_map_obj.class.name} cannot be used to do type mapping. It does not respond to '#{method}'" end end @type_map = type_map_obj end ## # :call-seq: # db.schema( dbname = "main" ) -> Schema # # Returns a Schema object containing the table and column structure of the # database. # def schema( dbname = "main" ) @schema ||= ::Amalgalite::Schema.new( self, dbname ) end ## # :call-seq: # db.reload_schema! -> Schema # # By default once the schema is obtained, it is cached. This is here to # force the schema to be reloaded. # def reload_schema!( dbname = "main" ) @schema = nil schema( dbname ) end ## # Run a pragma command against the database # # Returns the result set of the pragma def pragma( cmd, &block ) execute("PRAGMA #{cmd}", &block) end ## # Begin a transaction. The valid transaction types are: # # DEFERRED:: no read or write locks are created until the first # statement is executed that requries a read or a write # IMMEDIATE:: a readlock is obtained immediately so that no other process # can write to the database # EXCLUSIVE:: a read+write lock is obtained, no other proces can read or # write to the database # # As a convenience, these are constants available in the # Database::TransactionBehavior class. # # Amalgalite Transactions are database level transactions, just as SQLite's # are. # # If a block is passed in, then when the block exits, it is guaranteed that # either 'COMMIT' or 'ROLLBACK' has been executed. # # If any exception happens during the transaction that is caught by Amalgalite, # then a 'ROLLBACK' is issued when the block closes. # # If no exception happens during the transaction then a 'COMMIT' is # issued upon leaving the block. # # If no block is passed in then you are on your own. # # Nested transactions are not supported by SQLite, but they are faked here. # If you call transaction within a transaction, no new transaction is # started, the current one is just continued. # def transaction( mode = TransactionBehavior::DEFERRED ) raise Amalgalite::Error, "Invalid transaction behavior mode #{mode}" unless TransactionBehavior.valid?( mode ) # if already in a transaction, no need to start a new one. if not in_transaction? then execute( "BEGIN #{mode} TRANSACTION" ) end if block_given? then begin return ( yield self ) ensure if $! then rollback raise $! else commit end end else return in_transaction? end end ## # Commit a transaction # def commit execute( "COMMIT" ) if in_transaction? end ## # Rollback a transaction # def rollback execute( "ROLLBACK" ) if in_transaction? end end end