lib/pg_conn/rdbms_methods.rb



module PgConn
  class Error < StandardError; end # Move to exception.rb
  class PsqlError < Error; end

  class RdbmsMethods
    attr_reader :conn

    def initialize(conn)
      @conn = conn
      # TODO: Check if conn is a superuser connection
    end

    # Return true if the database exists
    def exist?(database)
      conn.exist? %(
          select 1
          from pg_database
          where datname = '#{database}'
      )
    end

    # Create a new database
    def create(database, owner: ENV['USER'], template: "template1")
      owner_clause = owner ? "owner = \"#{owner}\"" : nil
      template_clause = template ? "template = \"#{template}\"" : nil
      stmt = ["create database \"#{database}\"", owner_clause, template_clause].compact.join(" ")
      conn.execute stmt # Note we're using #execute instead of #exec because
                        # create database can't run within a transaction
    end

    # Drop a database
    def drop(database, force: false)
      conn.execute "drop database if exists \"#{database}\"#{force ? ' with (force)' : ''}"
      true
    end

    # List databases in the RDBMS
    def list(all: false, exclude: [])
      exclude += POSTGRES_DATABASES if !all
      exclude_sql_list = "'" + exclude.join("', '") + "'"
      exclude_clause = exclude.empty? ? nil : "where datname not in (#{exclude_sql_list})"
      stmt = ["select datname from pg_database", exclude_clause].compact.join(" ")
      conn.values stmt
    end

    # Return the owner of a given database
    def owner(database)
      conn.value %(
        select r.rolname
        from (values ('#{database}')) as v (database)
        left join pg_database d on d.datname = v.database
        left join pg_roles r on r.oid = d.datdba
      )
    end

    # Return list of users currently logged in to the given database or to any
    # database if database is nil
    #
    # FIXME: There is a possible race-condition here where some process (eg.
    # auto-vacuum) is logged in to the database but has a nil username.  The
    # easy fix is to have 'usename is not null' but it would be nice to know
    # what exactly is triggering this problem
    #
    def users(database)
      database_clause = database ? "datname = '#{database}'" : nil
      query = ["select usename from pg_stat_activity", database_clause].compact.join(" where ")
      conn.values query
    end

    # Hollow-out a database by removing all schemas in the database. The public
    # schema is recreated afterwards unless :public is false. Uses the current
    # database if @database is nil
    #
    # Note that the database can have active users logged in while the database
    # is emptied. TODO Explain what happens if the users have active
    # transactions. Should the be terminated?
    #
    def empty!(database = nil, public: true, exclude: [])
      local = !database.nil?
      begin
        conn = local ? PgConn.new(database) : self.conn
        schemas =
            conn
              .values("select nspname from pg_namespace where nspowner != 10 or nspname = 'public'")
              .select { |schema| !exclude.include?(schema) }
              .join(", ")
        conn.exec "drop schema #{schemas} cascade"

        # FIXME FIXME FIXME SECURITY Why grant 'create' to public?
        conn.exec %(
            create schema public authorization postgres;
            grant usage, create on schema public to public
        ) if public
      ensure
        conn&.terminate if local
      end
    end

    # Fast copy using templates. Note that no user may be logged in to the
    # source database for this to work
    def copy(from_database, to_database, owner: ENV['USER'])
      create(to_database, owner: owner, template: from_database)
    end

    # TODO: This code is replicated across many projects. Should be moved to PgConn
    def load(database, file, role: ENV['USER'], gzip: nil)
      command_opt = role ? "-c \"set role #{role}\";\n" : nil
      if gzip
        pipe_cmd = file ? "gunzip --to-stdout #{file} |" : "gunzip --to-stdout |"
        file_opt = nil
      else
        pipe_cmd = nil
        file_opt = file ? "-f #{file}" : nil
      end
      cmd = [pipe_cmd, "psql -v ON_ERROR_STOP=1", command_opt, file_opt, database].compact.join(" ")
      stdout, stderr, status = Open3.capture3(cmd)
      status == 0 or raise PsqlError.new(stderr)
    end

    # TODO: This code is replicated across many projects. Should be moved to PgConn
    def save(database, file, data: true, schema: true, gzip: nil)
      data_opt = data ? nil : "--schema-only"
      schema_opt = schema ? nil : "--data-only"
      if gzip
        pipe_cmd = file ? "| gzip >#{file}" : "| gzip"
        file_opt = nil
      else
        pipe_cmd = nil
        file_opt = file ? "-f #{file}" : nil
      end
      cmd = ["pg_dump --no-owner", data_opt, schema_opt, file_opt, database, pipe_cmd].compact.join(" ")
      stdout, stderr, status = Open3.capture3(cmd)
      status == 0 or raise PsqlError.new(stderr)
    end

  private
    POSTGRES_DATABASES = %w(template0 template1 postgres)
  end
end