module PgGraph::Data class SqlRender attr_reader :database attr_reader :format def format=(format) constrain format, lambda { |v| [:sql, :exec, :psql] }, "Illegal value" @format = format end # Which data to delete: # none - don't delete any data # touched - delete data for tables in the fox file # recursive - delete data for table in the fox file including recursively depending tables # all - delete data from the whole database attr_reader :delete # Which data to truncate: # none - don't delete any data # touched - delete data for tables in the fox file # all - delete data from the whole database attr_reader :truncate # +ids+ is a map from table UID to ID. Records with larger IDs will # be emitted as insert statements, records with IDs less or equal to the # given ID is emitted as update statements # # +delete+ control which tables are deleted. It can be :none, :touched, # :recursive, :all Only records with an ID greater than the corresponding # ID from +ids+ will be deleted # # +truncate+ acts as +delete+ that has the major drawback that it doesn't # delete records in dependency order (FIXME This is an error). You can use # the SQL truncate statement instead of delete using this option but note # that +ids+ should be empty for this to work # # +files+ is a list of source file names to be included in the psql SQL # header as documentation. It can be set explicitly when #to_a or #to_h is # called (FIXME: is this used?) def initialize(database, format, ids: {}, delete: :all, truncate: :none, files: []) # puts "SqlRender#initialize" # puts " format: #{format.inspect}" # puts " ids: #{ids.inspect}" # puts " delete: #{delete.inspect}" # puts " files: #{files.inspect}" constrain database, Database constrain ids, { String => Integer } constrain ids.empty? || truncate == :none, true constrain delete == :none || truncate == :none, true @database = database self.format = format (@ids = ids.dup).default = 0 @delete = delete @truncate = truncate @files = files @tables = database.schemas.map(&:tables).flatten.sort @insert_tables = [] @update_tables = [] @insert_records = {} @update_records = [] @tables.each { |table| next if table.empty? @insert_tables << table if table.max_id > @ids[table.uid] @update_tables << table if table.ids.min || 0 <= @ids[table.uid] inserts, updates = table.records.partition { |record| record.id > @ids[table.uid] } @insert_records[table] = inserts if !inserts.empty? @update_records += updates } @table_uids = @tables.select { |table| !table.empty? }.map(&:uid) @materialized_views = @tables.map(&:type).map(&:depending_materialized_views).flatten.uniq end def to_a(files = @files) case format when :sql; to_sql.flatten when :exec; to_exec.flatten when :psql; to_psql(files).flatten.compact end end def to_s(files = @files) case format when :sql; to_a.join("\n") when :exec; to_a.join("\n") when :psql; to_psql(files).map { |group| group.join("\n") }.join("\n\n") end end def to_h @to_h ||= { disable: render_triggers(:disable), delete: render_deletes(delete), truncate: render_truncates(truncate), update: render_updates, insert: render_inserts, restart: render_restart_sequences, enable: render_triggers(:enable), refresh: render_refresh_materialized_views } end protected # Returns a single-element array of array of SQL statements def to_sql if delete != :none [to_h[:delete] + to_h[:update] + to_h[:insert]] else [to_h[:truncate] + to_h[:update] + to_h[:insert]] end end # Returns an array of non-empty arrays of SQL statements def to_exec() to_h.values.reject(&:empty?) end # Returns an array of arrays of SQL statements def to_psql(files = []) [render_psql_header(files), render_begin] + to_exec.select { |a| !a.empty? } + [render_commit] end def render_psql_header(files = []) if files.empty? files_text = "" else files_text = " from " + files.join(", ") end [ "-- Auto-generated by fox(1)" + files_text, "", "\\set QUIET", "\\set ON_ERROR_STOP" ] end def render_begin() [ "begin;" ] end def render_truncate() @tables.empty? ? [] : [ "truncate #{@tables.map(&:uid).join(", ")} restart identity cascade;" ] end # :call-seq: # render_triggers(arg) # # +arg+ can be :disable or :enable # def render_triggers(arg) [:disable, :enable].include?(arg) or raise Error, "Illegal value" tables = @with_deletes ? @tables : @tables.reject(&:empty?) tables.map { |table| "alter table #{table.uid} #{arg} trigger all;" } end def render_deletes(kind) table_uids = case kind when :none; [] when :touched; @tables.reject(&:empty?).map(&:uid) when :recursive tables = @tables.reject(&:empty?) (tables.map(&:uid) + tables.map { |table| table.type.depending_tables.map(&:uid) }.flatten).uniq when :all; @tables.map(&:uid) else raise ArgumentError end # FIXME: Not in dependency order table_uids.map { |uid| if !@ids.key?(uid) "delete from #{uid};" else "delete from #{uid} where id > #{@ids[uid]};" end } end def render_truncates(kind) table_uids = case kind when :none; [] when :touched; @tables.reject(&:empty?).map(&:uid) when :all; @tables.map(&:uid) else raise ArgumentError end table_uids.map { |uid| "truncate #{uid} cascade;" } end def render_updates @update_records.map { |record| # Temporary fix for the case where a kind field is updated. Kind fields # are not considered value_columns (!) so kind fields are skipped in # the original code below. FIXME FIXME FIXME "update #{record.table.uid} set " + record.to_h.keys .map { |key| record[key] } .reject { |column| column.type.primary_key? } .map { |column| "#{column.name} = #{render_value(column)}" }.join(", ") + " " \ + "where id = #{record.id};" # "update #{record.table.uid} set " \ # + record.value_columns # .select { |column| !column.type.primary_key? } # .map { |column| "#{column.name} = #{render_value(column)}" } # .join(", ") + " " \ # + "where id = #{record.id};" } end def render_inserts @insert_records.map { |table, records| "insert into #{table.uid} (#{table.type.value_columns.map(&:name).join(', ')}) values " + \ records.sort_by(&:id).map { |record| "(" + record.type.value_columns.map { |column_type| record.field?(column_type.name) ? render_value(record[column_type.name]) : 'DEFAULT' }.join(", ") + ")" }.join(", ") + ";" } end def render_literal(value, element_type = nil) case value when TrueClass, FalseClass; value.to_s when Integer; value.to_s when String; "'#{PG::Connection.escape_string(value.to_s)}'" when NilClass; "NULL" when Array if value.empty? "ARRAY[]::#{element_type}[]" # FIXME: Doesn't handle multidimensional arrays else "ARRAY[" + value.map { |v| render_literal(v) }.join(",") + "]" end when Hash "'" + value.to_json + "'" when Time "'" + value.to_s + "'" else raise "Oops: got #{value.inspect} (#{value.class})" end end def render_value(column) type = column.value_type.type if type.array? render_literal(column.value, type.element_type) else render_literal(column.value) end end def render_restart_sequences() @tables.map { |table| if table.type.sub_table? nil elsif table.empty? && @with_deletes || !table.empty? && table.max_id > @ids[table.uid] "alter table #{table.uid} alter column id restart with #{table.max_id+1};" else nil end }.compact end def render_refresh_materialized_views() if @materialized_views.empty? [] else @materialized_views.map { |view| "refresh materialized view #{view.uid};" } end end def render_commit() [ "commit;" ] end end end