# $Id: tuplemgr.rb 269 2003-11-07 15:54:17Z bolzer $
# Author::  Oliver M. Bolzer (mailto:oliver@fakeroot.net)
# Copyright:: (c) Oliver M. Bolzer, 2002
# License:: Distributes under the same terms as Ruby
#
#:nodoc:

require 'dbi'

require 'vapor/utils'
require 'vapor/persistable'
require 'vapor/exceptions'

unless DBI::VERSION >= "0.0.20"
  raise LoadError, "VAPOR requires DBI >= 0.0.20"
end

module Vapor

  # The TupleManger handles the backend storage, providing the
  # requested information in form of Hashes. This TupleManager
  # uses an PostgreSQL ORDBMS as backend.
  class TupleManager #:nodoc:
    include Exceptions

    # Version of Schema implemented
    SchemaVersion = 3 

    # create a TupleManager, connecting it to a PostgreSQL database server
    def initialize( dbname, dbuser, dbpass, dbhost = "localhost", dbport = 5432 )
      
      ## connect to database
      driver_url = [ 'DBI', 'pg', dbname, dbhost, dbport ].join(':')
      @dbh = DBI.connect( driver_url, dbuser, dbpass )
     
      ## check schema version of Repository
      begin
        schemaversion = @dbh.execute( %!SELECT value FROM ":Vapor::RepositoryInfo" WHERE name = 'schema-version'! ).fetch[0].to_i
        raise RepositoryOfflineError unless schemaversion == SchemaVersion
      rescue
        raise RepositoryOfflineError, "Repository has incompatible schema version"
      end

      ## read in class->table map
      @classtable = Hash.new
      @dbh.execute( 'SELECT _name, _table FROM ":Vapor::ClassMetaData";' ).each{|row|
        @classtable[ row[0] ] = row[1]
      }

      # initialize some instance variables
      @in_transaction = false
      @transaction_log = nil
    end # initialize()

    # OID of log object belonging to current transaction
    attr_reader :transaction_log
    def transaction_log=( oid )
      raise TypeError unless oid.is_a? Integer
      @transaction_log = oid
    end
    

    # retrieve a tuple for a specific object
    #
    # Throws: BackendInconsistentError if there is some kind of inconsistency
    # in the backend
    def get_tuple( oid )
      raise TypeError unless oid.kind_of?( Integer )

      ## get class of object
      klass = @dbh.execute( 'SELECT _class FROM ":Vapor::ObjectList" WHERE _oid = ' + oid.to_s + ' LIMIT 1;' )
      
      ## object not known if no result
      klass = klass.fetch
      return nil if klass.nil? 

      ## which table is that info in?
      klass = klass[0]
      table = @classtable[ klass ]
      raise BackendInconsistentError if table.nil?   # should not happen that the table is unknown

      ## now get that object from the database
      obj = @dbh.execute( 'SELECT * FROM "' + table + '" WHERE _oid = ' + oid.to_s + ' LIMIT 1;' )
      obj = obj.fetch_hash
      raise BackendInconsistentError unless obj.is_a?( Hash )    # can't be that we don't find the object at this stage

      ## clean up the hash, removing the '_' prefix from the columns
      result = Hash.new
     
      obj.each{|key, value|
        if value.is_a? DBI::Timestamp then  # convert DBI::Timestamp to ::Time
          value = value.to_time
        end
        result[ key ] = value
      }

      # retrieve Class-Object
      modules = klass.split('::')
      klass = Object
      while (! modules.empty? )
        klass = klass.const_get( modules.shift ) 
      end
      result['_type'] = klass 
      
      return result
    end # get_tuple()

    # retrieve archived (old) tuple from historic table
    def get_archived_tuple( klass, oid, revision )
      raise TypeError unless oid.kind_of?( Integer )
      raise TypeError unless revision.kind_of?( Integer )
     
      klass = klass.to_s

      table = "_" + @classtable[ klass ]
      raise BackendInconsistentError, "could not find table for tuple #{oid}" if table.nil?   # should not happen that the table is unknown

      ## now get that object from the database
      obj = @dbh.execute( %!SELECT * FROM "#{table}" WHERE _oid = #{oid.to_s} AND _revision = #{revision.to_s} LIMIT 1! )
      obj = obj.fetch_hash
      return nil if obj.nil?  # tuple not found

      ## clean up the hash
      result = Hash.new
     
      obj.each{|key, value|
        if value.is_a? DBI::Timestamp then  # convert DBI::Timestamp to ::Time
          value = value.to_time
        end
        result[ key ] = value
      }
     
      # retrieve Class-Object
      modules = klass.split('::')
      klass = Object
      while (! modules.empty? )
        klass = klass.const_get( modules.shift ) 
      end
      result['_type'] = klass

      return result

    end # get_archived_tuple()


    # retrieve metadata about a class, Array of reference-attributes
    def get_class_attributes( klass )

      class_id = nil
      superclass = '' 
      
      ## retrieve class' OID
      row = @dbh.select_one( 'SELECT _oid, _superclass FROM ":Vapor::ClassMetaData" WHERE _name = \'' + klass.to_s + "';")

     if row then
       class_id = row[0]
       superclass = row[1]
     else
       return nil   # no result, class doesn't exist
     end

      ## we know the class' oid, let'sretrieve all reference attributes
      refattrs = Array.new
      @dbh.execute( 'SELECT _name, _type, _array FROM ":Vapor::AttributeMetaData" WHERE _oid = ' + class_id.to_s + ";" ){|result|
        result.each{|row|

	  refattrs << ClassAttribute.new( row[0], row[1], row[2] )
	}
      }
     
      ## return array of Reference-Attribute's names, empty if none
      ## append superclass's attributes
      superattrs =  get_class_attributes( superclass )
      if superattrs then
        return refattrs + superattrs
      else
        return refattrs
      end
      
    end # get_class_attributes()

    # retrieve an Array of all OIDs of a class
    def get_all_instances( klass, subclasses = true )
      raise ArgumentError if klass.to_s.empty?

      ## find which table the  
      table = @classtable[ klass.to_s ]
      if table.nil? then
        return Array.new  # not a known class :-(
      end
     
      ## now retrieve all OIDs from the table
      result = Array.new
      statement = "SELECT _oid FROM "
      if !subclasses then
        statement += "ONLY "
      end
      statement += %!"#{table}"!

      @dbh.execute( statement ){|oids|
          oids.each{|row|
	    result << row[0] 
	  }
      }
     
      ## return array of Reference-Attribute's names, empty if none
      return result

    end #get_all_instances()

    # Create a new (empty) tuple for the specified class and OID with
    # Revision 0(zero). Raises a ClassNotFoundError when the class is
    # not known to the Repository.
    def new_tuple( klass, oid )
      raise TypeError, "#{klass.name} not a Persistable: " unless klass.ancestors.include? Vapor::Persistable

      table = @classtable[ klass.to_s ]
      raise ClassNotKnownError, klass.to_s if table.nil?

      begin
        @dbh.execute( %!INSERT INTO ":Vapor::ObjectList" VALUES ( #{oid}, '#{klass.name}' )! )
        @dbh.execute( %!INSERT INTO "#{table}" (_oid, _revision) VALUES ('#{oid.to_s}, 0 )! )
      rescue DBI::ProgrammingError => e
        raise VaporException, "Creation of new tuple #{oid} of type #{klass.name} failed: #{e}"
      end
      
    end # new_tuple()

    # Update a tuple, increasing it's Revision number. Raises a
    # DeletedObjectError if a tuple with specified klass and OID can't 
    # be found (anymore). Raises an StaleObjectError is the tuple's 
    # revision on disk is newer than the tuple wanting to be updated.
    def update_tuple( klass, oid, attributes )
      raise TypeError, "#{klass.name} not a Class" unless klass.is_a? Class and klass.ancestors.include?( Persistable )
      raise TypeError, "#{oid.inspect} does not seem like an OID" unless oid.is_a? Integer
      raise TypeError, "#{attributes.inspect} is not a Hash" unless attributes.is_a? Hash
      
      table = @classtable[ klass.to_s ]
      raise ClassNotKnownError, klass.to_s if table.nil?

      # get current revision and lock for update, disable inheritance
      sql = %!SELECT _revision FROM ONLY "#{table}" WHERE _oid = #{oid} FOR UPDATE!
      revision = @dbh.execute( sql )
      if revision.nil? then
        raise BackendInconsistentError, "could now determine current revision of object #{oid}"
      end
      revision = revision.fetch
      if revision.nil? then          # no result found, object not there anymore
        raise DeletedObjectError
      end
      
      disk_revision = revision[0]
      object_revision = attributes[ '_revision' ]

      if object_revision != disk_revision then # object on-disk is newer
        raise StaleObjectError
      end
     
      ## save old entry to historic table
      sql = %!INSERT INTO "_#{table}" (SELECT * FROM "#{table}" WHERE _oid = #{oid})!
      @dbh.execute( sql )

      ## update table entry
      new_revision = disk_revision + 1 
     
      attributes = attributes.dup
      attributes.delete( '_oid' )
      attributes.delete( '_revision' )
      attributes.delete( '_last_change' )

      # create update clause
      sets = Array.new
      attributes.each{|key,value|
        val =  @dbh.quote(value)
        sets << %!"#{key}" = #{val}!
      }

      sql  = %!UPDATE "#{table}" SET _revision = #{new_revision}, _last_change = #{@transaction_log}, !
      sql += sets.join(',') 
      sql += " WHERE _oid = #{oid};"
      
      begin
        @dbh.execute( sql )
      rescue DBI::ProgrammingError => e
        if e.errstr =~ /duplicate key into unique/ then
          raise UniquenessError
        else
          raise e
        end
      end
    end # update_tuple()

    # retrieve unique number used for generating OIDs
    def next_oid_high

      oid_high = @dbh.execute( %!SELECT nextval('":Vapor::oid_high"')! )
      oid_high = oid_high.fetch
      raise BackendInconsistentError, "next_oid_high unknown" if oid_high.nil?

      oid_high = oid_high[0]

      return oid_high
    end # next_oid_high()

    # delete a tuple from the repository. Raises a StaleObjectError if
    # the revision of the tuple to be deleted is smaller than the
    # revision on-disk. Revision 0(zero). Raises a ClassNotFoundError
    # when the class is not known to the Repository.
    def delete_tuple( klass, oid, object_revision )
      raise TypeError, "#{klass.name} not a Persistable: " unless klass.ancestors.include? Vapor::Persistable

      table = @classtable[ klass.to_s ]
      raise ClassNotKnownError, klass.to_s if table.nil?

      # get current revision and lock for update, disable inheritance
      sql = %!SELECT _revision FROM ONLY "#{table}" WHERE _oid = #{oid} FOR UPDATE!
      disk_revision = @dbh.execute( sql )
      if disk_revision.nil? then
        raise BackendInconsistentError, "could now determine current revision of object #{oid}"
      end
      disk_revision = disk_revision.fetch
      return if disk_revision.nil? # skip verification if object seems to be already deleted
      disk_revision = disk_revision[0]

      if object_revision != disk_revision then # object on-disk is newer
        raise StaleObjectError
      end
      
      ## save old entry to historic table
      sql = %!INSERT INTO "_#{table}" (SELECT * FROM "#{table}" WHERE _oid = #{oid})!
      @dbh.execute( sql )
        
      # actually delete
      @dbh.execute( 'DELETE FROM ":Vapor::ObjectList" WHERE _oid = ' + oid.to_s )
      @dbh.execute( 'DELETE FROM "' + table + '" WHERE _oid = ' + oid.to_s )

    end # delete_tuple()

    # search for tuples matching a certain criteria, returns an array of OIDs
    # Raises a ClassNotKnownError if the class is not known to the Repository.
    def search_tuples( klass, statement, subclasses = true )
      raise TypeError, "#{klass.inspect} not a Persistable" unless klass.is_a? Class and klass.ancestors.include? Vapor::Persistable
      raise TypeError, "#{statement.type} not a QueryStatement" unless statement.kind_of? QueryStatement

      # get the correct table
      table = @classtable[ klass.to_s ]
      raise ClassNotKnownError, klass.to_s if table.nil?

      where_clause = query_to_sql( statement )

      oids = Array.new
      statement = 'SELECT _oid FROM ' 
      
      if !subclasses then
        statement += 'ONLY '
      end

      statement += %!"#{table}" WHERE #{where_clause}!

      results = @dbh.execute( statement )
      results.each{|row|
        oids << row[0]
      }

      if oids.empty? then
        return nil
      else
        return oids
      end

    end # search_tuples()

    # convert a QueryStatement into a usable SQL WHERE-statement
    def query_to_sql( statement )
      raise TypeError unless statement.is_a? QueryStatement

      case statement
      when BasicQueryStatement
        if ['oid','revision'].include? statement.fieldname then  # system columns
          query = %!_#{statement.fieldname}!
        else
          query = %!"#{statement.fieldname}"!
        end
        case statement.operator
        when '='
          query += ' = '
          query += @dbh.quote(statement.value)
        when '~'           # similar to operator, convert search string to SQL-style
          query += ' LIKE '
          searchstring = ''
          backslashed = false
          statement.value.scan(/./){|c|
            if backslashed then # escaped string
              searchstring += c
              backslashed = false
              next
            end
            
            # not escaped
            searchstring += case c
            when '\\'
              backslashed = true
              '' 
            when '%'
              '\\%'
            when '_'
              '\\_'
            when '?'
              '_'
            when '*'
              '%'
            else
              c
            end
          }
          query += @dbh.quote(searchstring) 
        end
        return query

      when ComplexQueryStatement
        query1 = query_to_sql( statement.statement1 )
        query2 = query_to_sql( statement.statement2 )

        return "(#{query1}) #{statement.condition} (#{query2})"
      end

    end # query_to_sql()

    # Start a Datastore transaction. Raises a NestedTransactionError if another
    # transaction is already active.
    def begin_transaction
      if @in_transaction then
        raise NestedTransactionError
      else
        @dbh.execute( "BEGIN" )
        @in_transaction = true
      end
    end # begin_transaction()

    # Commit a running Datastore transaction. Raises a StaleTransactionError
    # if no transaction is active.
    def commit_transaction
      if @in_transaction then
        @dbh.execute( "COMMIT" )
        @in_transaction = false
      else
        raise StaleTransactionError
      end
    end # commit_transaction()

    # Abort a running Datastore transaction and roll back to the state
    # before the transaction began. Raises a StaleTransactionError
    # if no transaction is active.
    def rollback_transaction
      if @in_transaction then
        @dbh.execute( "ROLLBACK" )
        @in_transaction = false
      else
        raise StaleTransactionError
      end
    end # rollback_transaction()

    # Returns <tt>true</tt> is a Datastore transaction is currently
    # active.
    def in_transaction?
      @in_transaction
    end #in_transaction?()
  end # class TupleManager

end # module Vapor
