DZone Snippets is a public source code repository. Easily build up your personal collection of code snippets, categorize them with tags / keywords, and share them with the world

Snippets has posted 5883 posts at DZone. View Full User Profile

Pg_diff - Compare Two PostgreSQL Database Schemas

12.07.2005
| 15963 views |
  • submit to reddit
        
#!/bin/env ruby
# pg_diff - compare two PostgreSQL database schemas
#
# URL: http://snippets.dzone.com/posts/show/949
#
# This is a simple approach to track database schema changes in PostgreSQL.
# In some way it is similar to diff program, finding out structure changes
# and results in  SQL script to upgrade to new schema.
# 
# Differences are tracked on schemas, domains, sequences, views, tables, indices, constraints, rules, functions, triggers.
# Two objects with the same name are considered equal if they have the same definitions. 
#
# Missing features: tracking of ownership,  user rights, object dependencies, table inheritance, type casts, aggregates, operators.
# 
# Usage:
#    ./pg_diff dbname=db_v03_dev dbname=db_v04_dev
# 
# Developed using PostgreSQL v8.0.3, v8.1 with ruby-postgres libpq binding (20051127 snapshot).
# 
# This software is released under MIT License
# 
# Copyright (c) 2005 Dmitry Severin <dmitry.severin (at) gmail.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#

require 'postgres'
module PostgreSqlSchema

class Attribute
  attr_accessor :name, :type_def, :notnull, :default
  def initialize(name, typedef, notnull, default)
    @name = name
    @type_def = typedef
    @notnull = notnull
    @default = default
  end
  def definition
    out = ['    ', @name,  @type_def]
    out << 'NOT NULL' if @notnull
    out << 'DEFAULT ' + @default if @default
    out.join(" ")
  end
  def == (other)
    definition == other.definition
  end
end

class Table
  attr_accessor :table_name, :schema, :attributes, :constraints, :indexes
  
  def initialize(conn, schema, table_name)
    @schema = schema
    @table_name = table_name
    @attributes = {}
    @constraints = {}
    @indexes = {}
    @atlist = []
    
    att_query = <<-EOT
      select attname, format_type(atttypid, atttypmod) as a_type, attnotnull,  pg_get_expr(adbin, attrelid) as a_default 
      from pg_attribute left join pg_attrdef  on (adrelid = attrelid and adnum = attnum) 
      where attrelid = '#{schema}.#{table_name}'::regclass and not attisdropped and attnum > 0 
      order by attnum
    EOT
    conn.query(att_query).each do |row|
      attname = row[0]
      @attributes[attname] = Attribute.new(attname, row[1], row[2], row[3])
      @atlist << attname
    end
  
    ind_query = <<-EOT
      select indexrelid::regclass as indname, pg_get_indexdef(indexrelid) as def 
      from pg_index where indrelid = '#{schema}.#{table_name}'::regclass and not indisprimary
    EOT
    conn.query(ind_query).each do |row|
      @indexes[row[0]] = row[1]
    end

    cons_query = <<-EOT
      select conname, pg_get_constraintdef(oid) from pg_constraint where conrelid = '#{schema}.#{table_name}'::regclass
    EOT
    conn.query(cons_query).each do |row|
      @constraints[row[0]] = row[1]
    end
    @constraints.keys.each do |cname|
      @indexes.delete("#{schema}.#{cname}") if has_index?(cname)
    end
  end
  
  def has_attribute?(name)
    @attributes.has_key?(name)
  end

  def has_index?(name)
    @indexes.has_key?(name) || @indexes.has_key?("#{schema}.#{name}")
  end
  
  def has_constraint?(name)
    @constraints.has_key?(name)
  end
  
  def table_creation
    out = ["CREATE TABLE #{name} ("]
    stmt = []
    @atlist.each do |attname|
      stmt << @attributes[attname].definition
    end
    out << stmt.join(",\n")
    out << ");"
    out.join("\n")
  end

  def name
    "#{schema}.#{table_name}"
  end
  
  def constr_creation
    out = []
    @constraints.each do |n, c|
      out << "ALTER TABLE #{name} ADD CONSTRAINT #{n} #{c};" 
    end
    out.join("\n")
  end
  
  def index_creation
    out = []
    @indexes.values.each do |c|
      out << (c+";")
    end
    out.join("\n")
  end
end

class Sequence

  def initialize(conn, sch, relname)
     @name = "#{sch}.#{relname}"
  end
  
  def definition
    "CREATE SEQUENCE #{@name} ;"
  end
end

class View
  attr_reader :def, :name 
  
  def initialize(conn, sch, relname)
    @name = "#{sch}.#{relname}"
    view_qery = <<-EOT
      SELECT pg_catalog.pg_get_viewdef('#{@name}'::regclass, true)
    EOT
    @def = conn.query(view_qery)[0][0]
  end
  
  def definition
    "CREATE VIEW #{@name} AS #{@def}"
  end
end

class Database
  attr_accessor :tables, :views, :sequences, :schemas, :domains, :rules, :functions, :triggers
  def initialize(conn)
     cls_query = <<-EOT
      SELECT n.nspname, c.relname, c.relkind
      FROM pg_catalog.pg_class c
      LEFT JOIN pg_catalog.pg_user u ON u.usesysid = c.relowner
      LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
      WHERE c.relkind IN ('r','S','v')
      AND n.nspname NOT IN ('pg_catalog', 'pg_toast', 'information_schema')
      ORDER BY 1,2;
    EOT
    @views = {}
    @tables = {}
    @sequences = {}
    @schemas = {}
    @domains = {}
    @functions = {}
    @rules = {}
    @triggers = {}
    
    conn.query(cls_query).each do |row|
      schema, relname, relkind = row
      case relkind
        when 'r' then @tables["#{schema}.#{relname}"] = Table.new(conn, schema, relname)
        when 'v' then @views ["#{schema}.#{relname}"] = View.new(conn, schema, relname)
        when 'S' then @sequences["#{schema}.#{relname}"] = Sequence.new(conn, schema, relname)
      end
    end
    
  domain_qry = <<-EOT
    SELECT n.nspname, t.typname,  pg_catalog.format_type(t.typbasetype, t.typtypmod) || ' ' ||
       CASE WHEN t.typnotnull AND t.typdefault IS NOT NULL THEN 'not null default '||t.typdefault
            WHEN t.typnotnull AND t.typdefault IS NULL THEN 'not null'
            WHEN NOT t.typnotnull AND t.typdefault IS NOT NULL THEN 'default '||t.typdefault
            ELSE ''
       END 
    FROM pg_catalog.pg_type t
       LEFT JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
    WHERE t.typtype = 'd'
    ORDER BY 1, 2
  EOT
    conn.query(domain_qry).each do |row|
      @domains["#{row[0]}.#{row[1]}"] = row[2]
    end

    schema_qry = <<-EOT
      select nspname from pg_namespace
    EOT
    conn.query(schema_qry).each do |row|
      @schemas[row[0]]=row[0]
    end

    func_query = <<-EOT
     SELECT proname AS function_name
     , nspname AS namespace
     , lanname AS language_name
     , pg_catalog.obj_description(pg_proc.oid, 'pg_proc') AS comment
     , proargtypes AS function_args
     , proargnames AS function_arg_names
     , prosrc AS source_code
     , proretset AS returns_set
     , prorettype AS return_type,
     provolatile, proisstrict, prosecdef
     FROM pg_catalog.pg_proc
     JOIN pg_catalog.pg_language ON (pg_language.oid = prolang)
     JOIN pg_catalog.pg_namespace ON (pronamespace = pg_namespace.oid)
     JOIN pg_catalog.pg_type ON (prorettype = pg_type.oid)
     WHERE pg_namespace.nspname !~ 'pg_catalog|information_schema'
     AND proname != 'plpgsql_call_handler'
     AND proname != 'plpgsql_validator'
    EOT
    conn.exec(func_query).result.each do |tuple|
      func = Function.new(conn, tuple)
      @functions[func.signature] = func 
    end

    rule_query = <<-EOT
    select  schemaname || '.' ||  tablename || '.' || rulename as rule_name, 
            schemaname || '.' ||  tablename as tab_name,
      rulename, definition
    from pg_rules
    where schemaname !~ 'pg_catalog|information_schema'
    EOT
    conn.exec(rule_query).result.each do |tuple|
      @rules[tuple['rule_name']] = Rule.new(tuple['tab_name'], tuple['rulename'], tuple['definition'])
    end

    trigger_query =  <<-EOT
    select nspname || '.' || relname as tgtable, tgname, pg_get_triggerdef(t.oid) as tg_def
    from pg_trigger t join pg_class c ON (tgrelid = c.oid ) JOIN pg_namespace n ON (c.relnamespace = n.oid)
    where not tgisconstraint
    and nspname !~ 'pg_catalog|information_schema'
    EOT
    conn.exec(trigger_query).result.each do |tuple|
      @triggers[tuple['tgtable'] + "." + tuple['tgname']] = Trigger.new(tuple['tgtable'], tuple['tgname'], tuple['tg_def'])
    end
  end
end

class Rule
  attr_reader :table_name, :name, :definition
  def initialize(table_name, name, df)
    @table_name = table_name
    @name = name
    @definition = df
  end
  def == (other)
    other.definition == definition
  end
end

class Trigger
  attr_reader :table_name, :name, :definition
  def initialize(table_name, name, df)
    @table_name = table_name
    @name = name
    @definition = df + ";"
  end
  def == (other)
    other.definition == definition
  end
end

class Function
  def initialize(conn, tuple)
    @name = tuple['namespace'] + "." + tuple['function_name']
    @language = tuple['language_name']
    @src = tuple['source_code']
    @returns_set = tuple['returns_set']
    @return_type = format_type(conn, tuple['return_type'])
    @tipes = tuple['function_args'].split(" ")
    if tuple['function_arg_names'] && tuple['function_arg_names'] =~ /^\{(.*)\}$/
      @arnames = $1.split(',')
    elsif tuple['function_arg_names'].is_a? Array # my version of ruby-postgres
      @arnames = tuple['function_arg_names']
    else
      @arnames = [""] * @tipes.length
    end
    alist = []
    @tipes.each_with_index do |typ,idx|
      alist << (@arnames[idx] +" " + format_type(conn, typ))
    end
    @arglist = alist.join(" , ")
    @strict = tuple['proisstrict'] ? ' STRICT' : ''
    @secdef = tuple['prosecdef'] ? ' SECURITY DEFINER' : ''
    @volatile = case tuple['provolatile']
      when 'i' then ' IMMUTABLE'
      when 's' then ' STABLE'
      else ''
    end
  end
  def signature
    "#{@name}(#{@arglist})"
  end
  def definition
    <<-EOT
CREATE OR REPLACE FUNCTION #{@name} (#{@arglist}) RETURNS #{@returns_set ? 'SETOF' : ''} #{@return_type} AS $_$#{@src}$_$ LANGUAGE '#{@language}' #{@volatile}#{@strict}#{@secdef};
EOT
  end
  def == (other)
    definition == other.definition
  end
  def format_type(conn, oid)
    t_query = <<-EOT
    SELECT pg_catalog.format_type(pg_type.oid, typtypmod) AS type_name
     FROM pg_catalog.pg_type
     JOIN pg_catalog.pg_namespace ON (pg_namespace.oid = typnamespace)
     WHERE pg_type.oid = 
    EOT
    return conn.query(t_query + oid.to_s)[0][0]
  end
end
class Diff
 
  def initialize(old_db_spec, new_db_spec)
    @old_conn = PGconn.new(old_db_spec)
    @new_conn = PGconn.new(new_db_spec)
    @sections = [
      :triggers_drop,
      :rules_drop,
      :functions_drop,
      :indices_drop ,
      :constraints_drop,
      :views_drop,
      :sequences_drop ,
      :tables_drop ,
      :domains_drop ,
      :schemas_drop , 
      :schemas_create,
      :domains_create,
      :sequences_create,
      :tables_create ,
      :table_changes ,
      :views_create ,
      :functions_create ,
      :rules_create ,
      :triggers_create ,
      :indices_create,
      :constraints_create
    ]
    @script = {}
    @sections.each {|s| @script[s] = []}
  end
  
  def run_compare
    @old_database = Database.new(@old_conn)
    @new_database = Database.new(@new_conn)
    compare_schemas
    compare_domains
    compare_sequences
    compare_triggers_drop
    compare_rules_drop
    compare_views_drop
    compare_table_attrs
    compare_views_create
    compare_functions
    compare_rules_create
    compare_triggers_create
    compare_table_constraints
  end

  def add_script(section, statement)
    @script[section] << statement
  end
  
  def compare_schemas
    @old_database.schemas.keys.each do |name|
      add_script(:schemas_drop ,  "DROP SCHEMA #{name};") unless @new_database.schemas.has_key?(name)
    end
    @new_database.schemas.keys.each do |name|
      add_script(:schemas_create ,  "CREATE SCHEMA #{name};") unless @old_database.schemas.has_key?(name)
    end
  end

  def compare_domains
    @old_database.domains.keys.each do |name|
      add_script(:domains_drop ,  "DROP DOMAIN #{name} CASCADE;") unless @new_database.domains.has_key?(name)
    end
    @new_database.domains.each do |name, df|
      add_script(:domains_create ,  "CREATE DOMAIN #{name} AS #{df};") unless @old_database.domains.has_key?(name)
      old_domain = @old_database.domains[name]
      if old_domain && old_domain != df
         add_script(:domains_drop, "DROP DOMAIN #{name} CASCADE;")
         add_script(:domains_create,  "-- [changed domain] :")
         add_script(:domains_create,  "-- OLD: #{old_domain}")
         add_script(:domains_create,  "CREATE DOMAIN #{name} AS #{df};") 
      end
    end
  end

  def compare_sequences
    @old_database.sequences.keys.each do |name|
      add_script(:sequences_drop ,  "DROP SEQUENCE #{name} CASCADE;") unless @new_database.sequences.has_key?(name)
    end
    @new_database.sequences.keys.each do |name|
      add_script(:sequences_create ,  "CREATE SEQUENCE #{name};") unless @old_database.sequences.has_key?(name)
    end
  end
  
  def compare_functions
    @old_database.functions.keys.each do |name|
      add_script(:functions_drop ,  "DROP FUNCTION #{name} CASCADE;") unless @new_database.functions.has_key?(name)
    end
    @new_database.functions.each do |name, func|
      add_script(:functions_create ,   func.definition) unless @old_database.functions.has_key?(name)
      old_function = @old_database.functions[name]
      if old_function && old_function.definition != func.definition
        add_script(:functions_create , '-- [changed function] :')
        add_script(:functions_create , '-- OLD :')
        add_script(:functions_create ,  old_function.definition.gsub(/^/, "-->  ") )
        add_script(:functions_create ,   func.definition) 
      end
    end
  end

  def compare_rules_drop
    @old_database.rules.each do |name, rule|
      add_script(:rules_drop ,  "DROP RULE #{rule.name} ON #{rule.table_name} CASCADE;") unless @new_database.rules.has_key?(name)
    end
  end
  
  def compare_rules_create
    @new_database.rules.each do |name, rule|
      add_script(:rules_create ,   rule.definition) unless @old_database.rules.has_key?(name)
      old_rule = @old_database.rules[name]
      if old_rule && old_rule != rule
        add_script(:rules_drop ,  "DROP RULE #{rule.name} ON #{rule.table_name} CASCADE;") 
        add_script(:rules_create ,  "-- [changed rule] :")
        add_script(:rules_create ,  "-- OLD: #{old_rule.definition}")
        add_script(:rules_create ,   rule.definition )
      end
    end
  end

  def compare_triggers_drop
    @old_database.triggers.each do |name, trigger|
      add_script(:triggers_drop ,  "DROP trigger #{trigger.name} ON #{trigger.table_name} CASCADE;") unless @new_database.triggers.has_key?(name)
    end
  end
  
  def compare_triggers_create
    @new_database.triggers.each do |name, trigger|
      add_script(:triggers_create ,   trigger.definition) unless @old_database.triggers.has_key?(name)
      old_trigger = @old_database.triggers[name]
      if old_trigger && old_trigger != trigger
        add_script(:triggers_drop ,  "DROP trigger #{trigger.name} ON #{trigger.table_name} CASCADE;") 
        add_script(:triggers_create ,  "-- [changed trigger] :")
        add_script(:triggers_create ,  "-- OLD #{old_trigger.definition}")
        add_script(:triggers_create ,   trigger.definition) 
      end
    end
  end

  def compare_views_drop
    @old_database.views.keys.each do |name|
      add_script(:views_drop ,  "DROP VIEW #{name};") unless @new_database.views.has_key?(name)
    end
  end

  def compare_views_create
    @new_database.views.each do |name, df|
      add_script(:views_create ,   df.definition) unless @old_database.views.has_key?(name)
      old_view = @old_database.views[name]
      if old_view && df.definition != old_view.definition
        add_script(:views_drop ,  "DROP VIEW #{name};")
        add_script(:views_create ,  "-- [changed view] :")
        add_script(:views_create ,  "-- #{old_view.definition.gsub(/\n/, ' ')}")
        add_script(:views_create ,  df.definition)
      end
    end
  end
  
  def compare_table_attrs
    @old_database.tables.each do |name, table|
      add_script(:tables_drop, "DROP TABLE #{name} CASCADE;") unless @new_database.tables.has_key?(name)
    end
    @to_compare = []
    @new_database.tables.each do |name, table|
      unless @old_database.tables.has_key?(name)
        add_script(:tables_create ,  table.table_creation)
        add_script(:indices_create ,  table.index_creation) unless table.indexes.empty?
        @to_compare << name
      else
        diff_attributes(@old_database.tables[name], table)
        diff_indexes(@old_database.tables[name], table)
        @to_compare << name
      end
    end
  end

  def compare_table_constraints
    @c_check = []
    @c_primary = []
    @c_unique = []
    @c_foreign = []
    @to_compare.each do |name|
      if @old_database.tables[name] 
        diff_constraints(@old_database.tables[name], @new_database.tables[name])
      else
        @new_database.tables[name].constraints.each do |cname, cdef|
          add_cnstr(name,  cname, cdef) 
        end
      end
    end
    @script[:constraints_create] += @c_check
    @script[:constraints_create] += @c_primary
    @script[:constraints_create] += @c_unique
    @script[:constraints_create] += @c_foreign
  end

  def output
    out = []
    @sections.each do |sect|
      if @script[sect].empty?
         out << "-- [SKIP SECTION : #{sect.to_s.upcase}] : no changes\n"
      else
         out << "-- [START SECTION : #{sect.to_s.upcase}]"
         out += @script[sect]
         out << "-- [END SECTION : #{sect.to_s.upcase}]\n"
      end
    end
    out.join("\n")
  end
  
  def diff_attributes(old_table, new_table)
    dropped = []
    added   = []
    changed = []
    old_table.attributes.keys.each do |attname| 
      if new_table.has_attribute?(attname)
        changed << attname if old_table.attributes[attname] != new_table.attributes[attname]
      else
        dropped << attname
      end
    end
    new_table.attributes.keys.each do |attname|
      added << attname unless old_table.has_attribute?(attname)
    end
    add_script(:table_changes ,  "--  [#{old_table.name}] dropped attributes") unless dropped.empty?
    dropped.each do |attname|
      add_script(:table_changes ,  "ALTER TABLE #{old_table.name} DROP COLUMN #{attname} CASCADE;")
    end
    add_script(:table_changes ,  "--  [#{old_table.name}] added attributes") unless added.empty?
    added.each do |attname|
      add_script(:table_changes ,  "ALTER TABLE #{old_table.name} ADD COLUMN #{new_table.attributes[attname].definition};")
    end
    add_script(:table_changes ,  "--  [#{old_table.name}] changed attributes") unless changed.empty?
    changed.each do |attname|
      old_att = old_table.attributes[attname]
      new_att = new_table.attributes[attname]
      add_script(:table_changes ,  "-- attribute: #{attname}")
      add_script(:table_changes ,  "-- OLD : #{old_att.definition}")
      add_script(:table_changes ,  "-- NEW : #{new_att.definition}")
      if old_att.type_def != new_att.type_def
        add_script(:table_changes ,  "ALTER TABLE #{old_table.name} ALTER COLUMN #{attname} TYPE #{new_att.type_def};")
      end
      if old_att.default != new_att.default
        if new_att.default.nil?
          add_script(:table_changes ,  "ALTER TABLE #{old_table.name} ALTER COLUMN #{attname} DROP DEFAULT;")
        else
          add_script(:table_changes ,  "ALTER TABLE #{old_table.name} ALTER COLUMN #{attname} SET DEFAULT #{new_att.default};")
        end
      end
      if old_att.notnull != new_att.notnull
        add_script(:table_changes ,  "ALTER TABLE #{old_table.name} ALTER COLUMN #{attname} #{new_att.notnull ? 'SET' : 'DROP'} NOT NULL;")
      end
    end
  end

  def diff_constraints(old_table, new_table)
    dropped = []
    added   = []
    
    old_table.constraints.keys.each do |conname| 
      if new_table.has_constraint?(conname)
        if old_table.constraints[conname] != new_table.constraints[conname]
          dropped << conname
          added << conname
        end
      else
        dropped << conname
      end
    end
    new_table.constraints.keys.each do |conname| 
      added << conname unless old_table.has_constraint?(conname)
    end

    dropped.each do |name|  
      add_script(:constraints_drop ,  "ALTER TABLE #{old_table.name} DROP CONSTRAINT #{name};")
    end
    
    added.each do |name|  
      add_cnstr(old_table.name,  name, new_table.constraints[name]) 
    end
  end

  def add_cnstr(tablename, cnstrname, cnstrdef)
    c_string = "ALTER TABLE #{tablename} ADD CONSTRAINT #{cnstrname} #{cnstrdef} ;"
    case cnstrdef
      when /^CHECK /   then @c_check  << c_string
      when /^PRIMARY / then @c_primary << c_string
      when /^FOREIGN / then @c_foreign << c_string
      when /^UNIQUE /  then @c_unique  << c_string
    end
  end
  
  def diff_indexes(old_table, new_table)
    dropped = []
    added   = []
    
    old_table.indexes.keys.each do |name| 
      if new_table.has_index?(name)
        if old_table.indexes[name] != new_table.indexes[name]
          dropped << name
          added << name
        end
      else
        dropped << name
      end
    end
    new_table.indexes.each do |name| 
      added << name unless old_table.has_index?(name)
    end
   
    dropped.each do |name|  
      add_script(:indices_drop ,  "DROP INDEX #{name};")
    end
    added.each do |name|
      add_script(:indices_create ,  (new_table.indexes[name] + ";")) if new_table.indexes[name]
    end
  end

end

end

def parse_conn_params(str)
  h = {}
  str.split(/:/).each{|pair| key, value = pair.split('=', 2); h[key]=value}
  h
end

  diff = PostgreSqlSchema::Diff.new(parse_conn_params(ARGV[0]), parse_conn_params(ARGV[1]) )
  diff.run_compare
  puts diff.output


    

Comments

Snippets Manager replied on Tue, 2009/03/03 - 7:04am

Can four Postgre of SQL can be compared with DB? I may have to fix that up for an application which selling software online in a few ways.