# $Id: repositorymgr.rb 268 2003-11-07 11:34:34Z bolzer $
# Author::  Oliver M. Bolzer (mailto:oliver@fakeroot.net)
# Copyright:: (c) Oliver M. Bolzer, 2002
# License:: Distributes under the same terms as Ruby

require 'dbi'

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

unless DBI::VERSION >= "0.0.20"

    raise LoadError, "VAPOR requires DBI >= 0.0.20"
end

module Vapor
  
  # Manager for metadata of an VAPOR Repository. Can add new classes or
  # initialize a fresh Repository. 
  class RepositoryManager
    include Exceptions

    # version of schema generated
    SchemaVersion = 3 

    # Initialize the Datastore as a Repository.
    # Raises BackendInconsistentError if the Datastore is not empty.
    def init_repository

      # make sure that the database is clean (== empty)
      result = @dbh.execute( "SELECT  relname FROM pg_class WHERE relname !~ '^pg_'")
      if result.fetch then
        raise BackendInconsistentError, "repository database is not empty prior to initialization"
      end

      # now let's start creating
      classmetadata = [ ['_oid', 'BIGINT PRIMARY KEY'],
                        ['_name', 'VARCHAR(255)'],
                        ['_superclass', 'VARCHAR(255)'],
                        ['_table', 'VARCHAR(64)']
                      ] 
      create_table( ":Vapor::ClassMetaData", classmetadata )

      attributemetadata = [ ['_oid', 'BIGINT'],
                            ['_name', 'VARCHAR(255)'],
                            ['_type', 'VARCHAR(16)'],
                            ['_array', 'BOOLEAN']
                          ] 
      create_table( ":Vapor::AttributeMetaData", attributemetadata )

      objectlist = [ ['_oid', 'BIGINT PRIMARY KEY'],
                     ['_class', 'VARCHAR(64)']
                   ] 
      create_table( ":Vapor::ObjectList", objectlist )

      repositoryinfo = [ ['name', 'TEXT'],
                         ['value', 'TEXT']
                       ]
      create_table( ":Vapor::RepositoryInfo", repositoryinfo )
      @dbh.execute( %!INSERT INTO ":Vapor::RepositoryInfo" (name, value) VALUES ('created', '#{Time.now.to_s}' )! )
      @dbh.execute( %!INSERT INTO ":Vapor::RepositoryInfo" (name, value) VALUES ('last-modified', '#{Time.now.to_s}' )! )
      @dbh.execute( %!INSERT INTO ":Vapor::RepositoryInfo" (name, value) VALUES ('schema-version', #{SchemaVersion} )! )

      @dbh.execute( 'CREATE SEQUENCE ":Vapor::oid_high" MAXVALUE 281474976710655' )
    
      # remember Repository initialized
      @repository_ok = true
      
      # insert metadata about TransactionLog
      tlog = ClassMetaData.new( Vapor::TransactionLog.name, "", ":" + Vapor::TransactionLog.name )
      tlog.attributes.concat( Vapor::TransactionLog.metadata )
      addclass( tlog )


    end # init_repository()

    # Create a table using an Array of Arrays of the form [ name, type ] with 
    # the inhparent table as parent for inheritance plus the indexes and
    # uniqueness constraints for the attributes specified.
    def create_table( name, columns, inhparent = '', primkeys = [], indexes = [], unique = [] )
      raise TypeError, "wrong type for columns: #{columns.type}" unless columns.is_a? Array 
      # header
      tabledef  = 'CREATE TABLE "' + name.to_s + '" ('
      
      # columns
      tabledef += columns.collect{|c| %!"#{c[0]}" #{c[1]}! }.join(',')
      
      # primary keys
      primkeys.empty? or tabledef += ", PRIMARY KEY (" +
        primkeys.collect{|field| %!"#{field}"! }.join(',') + ")"

      # uniquueeness constraints
      unique.empty? or tabledef += "," + unique.collect{|uniq|
        unique_fields = uniq.collect{|e| %!"#{e}"!}.join(',')
        "UNIQUE (#{unique_fields})"
      }.join(',')
      
      # footer
      tabledef += ")"

      # inheritance
      if !inhparent.empty? then
        tabledef += %! INHERITS ("#{inhparent}")! 
      end
      @dbh.execute( tabledef )

      # add any indexes
      index_number = 0
      indexes.each{|index|
        create_index( name, index_number, index, false )
        index_number += 1
      }

    end # self.create_table()
    private :create_table

    # Create a Manager for the Repository at the Datastore specified in
    # <tt>db_spec</tt> 
    #
    # <tt>db_spec</tt> is a <tt>String</tt> with fields separated by 
    # colons: "<em>driver:database:host[:port]</em>".
    # 
    # If <tt>check_repository</tt> is false, validity of Repository is not
    # checked. Use when connecting to an uninitialized Repository. 
    def initialize( db_spec, user, pass, check_repository = true )
      raise TypeError unless db_spec.is_a? String and user.is_a? String and pass.is_a? String

      # check for validity of database-specifier
      database = db_spec.split(':')
      raise TypeError, "invalid db_spec" unless database.size == 3 or database.size == 4
      raise TypeError, "invalid database driver" unless database[0] == 'pg' # currently only know PostgreSQL

      # connect to database
      driver_url = database.unshift( 'DBI' ).join(':')
      begin
        @dbh = DBI.connect( driver_url, user, pass )
      rescue DBI::OperationalError => e
        raise RepositoryOfflineError, e.message
      end

      # check PostgreSQL version
      @psql_version = @dbh.execute('SELECT version()').fetch[0]
      @psql_version = /^PostgreSQL ([\d\.]+)/.match( @psql_version )[1]

      # initialize some instance variables
      @known_klasses = nil
      @oid_gen = OIDGenerator.new
      @oid_gen.high_source = self
      @repository_ok = false

      # silence PostgreSQL >= 7.3 when creating tables
      if @psql_version >= "7.3" then 
        @dbh.execute( 'SET CLIENT_MIN_MESSAGES TO ERROR ' )
      end

      # make sure that the database looks like a repository 
      return if !check_repository
      
      result = @dbh.execute( "SELECT  relname FROM pg_class WHERE relname LIKE ':Vapor::%' AND relkind = 'r'")
      result = result.fetch_all
      if result.size != 5 then
        raise BackendInconsistentError, "database does not seem to be a valid repository,"
      else
        result.each{|row|
          if ![":Vapor::AttributeMetaData",":Vapor::ClassMetaData",":Vapor::ObjectList", ":Vapor::RepositoryInfo", ":Vapor::TransactionLog"].include? row[0] then
            raise BackendInconsistentError, "database does not seem to be a valid repository"
          end
        }
      end
      @repository_ok = true


    end # initialize()

    # Register a new class's metadata with the Repository. Takes a
    # ClassMetaData as arugment.
    #
    # Raises a DuplicateClassError if the class is already known to the
    # Repository and a UnknownSuperclassError if the superclass is unknown.
    # A BackendInconsistentError is raised if the Datastore has an error due 
    # to unknown reasons. Raises a InvalidMetadtaError if the class is 
    # trying to redefine a superclass' attribute in a incompatible manner or
    # an array attribute is part of an uniqueness constraint (PostgreSQL
    # restriction)
    def addclass( klass )
      raise TypeError unless klass.is_a? ClassMetaData

      raise BackendInconsistentError unless @repository_ok

      if known_classes().find{|k| k.name == klass.name } then
        raise DuplicateClassError, "Class #{klass.name} already registered with Repository"
      end

      # check superclass
      if klass.superclass.empty? then
        supertable = ''
      else
        superclass = known_classes().find{|k| k.name == klass.superclass }
        if superclass.nil? then
          raise UnknownSuperclassError, "superclass #{klass.superclass} for #{klass.name} not known to Repository"
        else
          supertable = superclass.table 
        end
      end 

      oid = @oid_gen.next_oid
     
      # insert into ":Vapor::ClassMetaData"
      result = @dbh.execute( %!INSERT INTO ":Vapor::ClassMetaData" (_oid, _name, _superclass, _table) VALUES ( #{oid}, '#{klass.name}', '#{klass.superclass}', '#{klass.table}' )! )
      if result.nil? or result.rows != 1 then
        raise BackendInconsistentError, "could not insert #{klass.name} into ClassMetaData" 
      end

      # insert attributes into ":Vapor::AttributeMetaData"
      klass.attributes.each{|attr|
        result = @dbh.execute( %!INSERT INTO ":Vapor::AttributeMetaData" (_oid, _name, _type, _array ) VALUES ( #{oid}, '#{attr.name}', '#{attr.type_s}', #{attr.is_array} )! )
        if result.nil? or result.rows != 1 then
          raise BackendInconsistentError, "could not insert #{attr.name} into AttributeMetaData"
        end
      }

      # prepare and create the class' table
      tabledef = Array.new
      tabledef << ['_oid', 'BIGINT PRIMARY KEY']
      tabledef << ['_revision', 'BIGINT']
      tabledef << ['_last_change','BIGINT']
      klass.attributes.each{|attr|
        tabledef << [ "#{attr.name}", attr.type_sql ]
      }
      
      begin
        # main table: PRIMARY KEY => oid
        create_table( klass.table, tabledef, supertable, [],  klass.indexes, klass.unique )
        # historic table: PRIMARY KEY => (oid, revision)
        tabledef[0] = ['_oid', 'BIGINT']
        primkey =  ['_oid','_revision']
        supertable = "_" + supertable unless supertable.empty?
        create_table( "_" + klass.table, tabledef, supertable, primkey )
      rescue DBI::ProgrammingError => e
        case e.message
        when %r!CREATE TABLE.*attribute.*\"(\S+)\".*type conflict!
          raise InvalidMetadataError, "can't override attribute #{$~[1]} defined in superclass"
        when %r!ERROR.*no.*default operator class.*btree!
          raise InvalidMetadataError, "array tyes can't be part of uniqueness constraints due to Datastore restrictions"
        else
          raise e
        end
      end
      # we know this class, don't we
      known_classes() << klass
      
      # finally update Repository modification date
      @dbh.execute( %!UPDATE ":Vapor::RepositoryInfo" SET value = '#{Time.now.to_s}' WHERE name = 'last-modified'! )

    end # addclass()

    # Returns an unique number used for generating OIDs.
    def next_oid_high  #:nodoc:
      oid_high = @dbh.execute( %!SELECT nextval('":Vapor::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()
    
    # Returns an <tt>Array</tt> of ClassMetaData, for all classes
    # known to the Repository.
    def known_classes
      if @known_klasses then
        return @known_klasses
      end

      @known_klasses = Array.new
      @dbh.execute( 'SELECT _name, _superclass, _table, _oid FROM ":Vapor::ClassMetaData"' ).each{ |k|
        klass_metadata = ClassMetaData.new( k[0],k[1],k[2] )
        klass_metadata.attributes.concat( get_class_attributes( k[0] ) )
        @known_klasses << klass_metadata 
      }

      return @known_klasses
    end # known_classes()
    private :known_classes

    # Returns <tt>true</tt> if a class with the given name is known to
    # the Repository.
    def class_known?( klass )
      if known_classes().detect{ |k| klass.to_s == k.name } then
        return true
      else
        return false
      end
    end # class_known?()
    
    # 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()

    # Begin an transaction to manipulate the Repository.
    def start_transaction
      @dbh['AutoCommit'] = false
    end # begin_transaction

    # Commit running transaction, actually making changes to the Repository.
    def commit_transaction
      @dbh.commit
      @dbh['AutoCommit'] = true
    end # commit_transaction

    # Remove metadata of a class and all it's instances. Will fail with a
    # <tt>ClassNotKnownError</tt> if the class is not known. If the class
    # has child classes it will fail with a <tt>ClassNotDeletableError</tt>
    # unless <tt>recursive</tt> is set to <tt>true</tt>.
    def removeclass( klass, recursive )
      klass = known_classes().find{|k| k.name == klass.to_s }
      
      # check if the class is known to the repository
      raise ClassNotKnownError unless klass 

      # check if the class has any child  classes
      children = known_classes().select{|k| k.superclass == klass.name }.collect{|k| k.name}

      if !children.empty? then  # child classes exist
        if recursive then       # delete children recursivly
          children.each{|c| removeclass( c, recursive ) }
        else                    # don't delete children
          raise ClassNotDeletableError.new( "Class is not deletable because of child classes", children )
        end                     # recursion?
      end                       # children?

      ## let's start removing, start with meta-data
      
      # first get class's oid 
      oid = @dbh.execute( %!SELECT _oid FROM ":Vapor::ClassMetaData" WHERE _name = '#{klass.name}'! ).fetch[0]

      # remove attributes
      @dbh.execute( %!DELETE FROM ":Vapor::AttributeMetaData" WHERE _oid = #{oid}! ) 

      # remove class itself
      @dbh.execute( %!DELETE FROM ":Vapor::ClassMetaData" WHERE _oid = '#{oid}' ! )

      ## remove tables, shouldn't be a problem, child classes have been removed
      @dbh.execute( %!DROP TABLE "_#{klass.table}"! )  # historic
      @dbh.execute( %!DROP TABLE "#{klass.table}"! )   # actual data

      ## remove from list of known classes
      known_classes().delete( klass )

    end # removeclass

    # Update metadata of a Persistable class that is already registered 
    # with the Repository. Raises a <tt>ClassNotKnownError</tt> if the
    # class is not known to the Repository and a <tt>InvalidMetadataError</tt>
    # with a explaining message on other errors.
    def updateclass( new_klassdef )
      raise TypeError unless new_klassdef.is_a? ClassMetaData
      
      old_klassdef = known_classes().find{|k| k.name == new_klassdef.name }
      
      # check if the class is known to the repository
      raise ClassNotKnownError unless old_klassdef

      # check if superclass still the same
      unless old_klassdef.superclass == new_klassdef.superclass
        raise InvalidMetadataError, "different superclass `#{new_klassdef.superclass}' for `#{new_klassdef.name}'"
      end
     
      # get the class' oid
      oid = @dbh.select_one( %!SELECT _oid FROM ":Vapor::ClassMetaData" WHERE _name = '#{old_klassdef.name}' ! )[0]
      
      # let's check out each attribute
      new_klassdef.attributes.each{|new_attr|
        # search for same name field
        old_attr = old_klassdef.attributes.find{|a| a.name == new_attr.name }
        if !old_attr.nil? # an attribute with same name exists
          if old_attr == new_attr then # skip it if it's exactly same
           next  
          else                         # changing not allowed
           raise InvalidMetadataError, "can't change type of defined attribute `#{old_attr.name}'"
         end
        end # !old_attr.nil?
        
        ### new attribute not in the class' attribute list, must be new

        ## attempt to modify the table(s)
        begin
          @dbh.execute( %!ALTER TABLE "#{old_klassdef.table}" ADD COLUMN "#{new_attr.name}" #{new_attr.type_sql}! )
          @dbh.execute( %!ALTER TABLE "_#{old_klassdef.table}" ADD COLUMN "#{new_attr.name}" #{new_attr.type_sql}! )
        rescue DBI::ProgrammingError => e
          case e.message
          when %r!ALTER TABLE: column.*already exists in table "#{old_klassdef.table}"!  # redefining a parent class's attribute
            raise InvalidMetadataError, "can't override attribute `#{new_attr.name}' defined in superclass"
          when %r!ALTER TABLE: column.*already exists in table "(\w+)"! # defining attribute in child class
            raise InvalidMetadataError, "creation of attribute `#{new_attr.name}' with same name as attribute defined in child class `#{$~[1]}' not supported by PostgreSQL 7.2"
          else
            raise e
          end
        end
        
        ## add to metadata
        
        # insert attribute
        @dbh.execute( %!INSERT INTO ":Vapor::AttributeMetaData" (_oid, _name, _type, _array ) VALUES ( #{oid}, '#{new_attr.name}', '#{new_attr.type_s}', #{new_attr.is_array} )! )
      } # new_klassdef.attributes.each

      ## update indexes by first dropping all and then (re)creating them
      
      # drop non-unique indexes (common method to PostgreSQL 7.2 and 7.3)
      @dbh.execute( %!SELECT i.relname FROM pg_index index, pg_class r, pg_class i WHERE r.relname = '#{old_klassdef.table}' AND index.indisprimary = false AND index.indisunique = false AND r.oid = index.indrelid AND index.indexrelid = i.oid! ).each{|index|
        @dbh.execute( %!DROP INDEX "#{index[0]}"! )
      }

      # drop unique indexes (method dependent on PostgreSQL version)
      @dbh.execute( %!SELECT i.relname FROM pg_index index, pg_class r, pg_class i WHERE r.relname = '#{old_klassdef.table}' AND index.indisprimary = false AND index.indisunique = true AND r.oid = index.indrelid AND index.indexrelid = i.oid! ).each{|index|
      
        if @psql_version >= "7.3"
          @dbh.execute( %!ALTER TABLE "#{old_klassdef.table}" DROP CONSTRAINT "#{index[0]}"! )
        else  # PostgreSQL 7.2
          @dbh.execute( %!DROP INDEX "#{index[0]}"! )
        end
      }

      index_number = 0
      new_klassdef.indexes.each{|index|
        create_index( new_klassdef.name, index_number, index, false )
        index_number += 1
      }
      new_klassdef.unique.each{|index|
        create_index( new_klassdef.name, index_number, index, true )
        index_number += 1
      }

    end # updateclass

    # Create an (unique) index on a table from an Array of column names.
    # For uniqueness of index names, a running number on the number of indexes
    # on the table is required. 
    def create_index( table, index_number = 0, fields = [], unique = false )
      index_name = "#{table}_#{fields.first}_#{index_number}"
      index_fields = fields.collect{|field|  %!"#{field}"! }.join(',')
      if unique then
        indexdef = %!CREATE UNIQUE INDEX!
      else
        indexdef = %!CREATE INDEX!
      end
      indexdef += %! "#{index_name}" on "#{table}" (#{index_fields})!
      @dbh.execute( indexdef )  
    end # create_index()
    private :create_index

  end # class RepositoryManager
end # module Vapor

