Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add topological sorting for dumped views using TSort #416

Merged
merged 4 commits into from
May 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,9 @@ jobs:
strategy:
fail-fast: false
matrix:
ruby: ["2.7", "3.0", "3.1", "3.2"]
ruby: ["3.1", "3.2"]
rails: ["6.1", "7.0"]
continue-on-error: [false]
exclude:
- ruby: "3.2"
rails: "6.1"

runs-on: ubuntu-latest

Expand Down
4 changes: 2 additions & 2 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ source "https://rubygems.org"
# Specify your gem's dependencies in scenic.gemspec
gemspec

rails_version = ENV.fetch("RAILS_VERSION", "6.1")
rails_version = ENV.fetch("RAILS_VERSION", "7.0")

rails_constraint = if rails_version == "master"
rails_constraint = if rails_version == "main"
{github: "rails/rails"}
else
"~> #{rails_version}.0"
Expand Down
68 changes: 66 additions & 2 deletions lib/scenic/schema_dumper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,34 @@
module Scenic
# @api private
module SchemaDumper
# A hash to do topological sort
class TSortableHash < Hash
include TSort

alias_method :tsort_each_node, :each_key
def tsort_each_child(node, &)
fetch(node).each(&)
end
end

# Query for the dependencies between views
DEPENDENT_SQL = <<~SQL.freeze
SELECT distinct dependent_ns.nspname AS dependent_schema
, dependent_view.relname AS dependent_view
, source_ns.nspname AS source_schema
, source_table.relname AS source_table
FROM pg_depend
JOIN pg_rewrite ON pg_depend.objid = pg_rewrite.oid
JOIN pg_class as dependent_view ON pg_rewrite.ev_class = dependent_view.oid
JOIN pg_class as source_table ON pg_depend.refobjid = source_table.oid
JOIN pg_namespace dependent_ns ON dependent_ns.oid = dependent_view.relnamespace
JOIN pg_namespace source_ns ON source_ns.oid = source_table.relnamespace
WHERE dependent_ns.nspname = ANY (current_schemas(false)) AND source_ns.nspname = ANY (current_schemas(false))
AND source_table.relname != dependent_view.relname
AND source_table.relkind IN ('m', 'v') AND dependent_view.relkind IN ('m', 'v')
ORDER BY dependent_view.relname;
SQL

def tables(stream)
super
views(stream)
Expand All @@ -22,11 +50,47 @@ def views(stream)
private

def dumpable_views_in_database
@dumpable_views_in_database ||= Scenic.database.views.reject do |view|
ignored?(view.name)
@ordered_dumpable_views_in_database ||= begin
existing_views = Scenic.database.views.reject do |view|
ignored?(view.name)
end

tsorted_views(existing_views.map(&:name)).map do |view_name|
existing_views.find do |ev|
ev.name == view_name || ev.name == view_name.split(".").last
end
end.compact
end
end

# When dumping the views, their order must be topologically
# sorted to take into account dependencies
def tsorted_views(views_names)
views_hash = TSortableHash.new

::Scenic.database.execute(DEPENDENT_SQL).each do |relation|
source_v = [
relation["source_schema"],
relation["source_table"]
].compact.join(".")
dependent = [
relation["dependent_schema"],
relation["dependent_view"]
].compact.join(".")
views_hash[dependent] ||= []
views_hash[source_v] ||= []
views_hash[dependent] << source_v
views_names.delete(relation["source_table"])
views_names.delete(relation["dependent_view"])
end

# after dependencies, there might be some views left
# that don't have any dependencies
views_names.sort.each { |v| views_hash[v] ||= [] }

views_hash.tsort
end

unless ActiveRecord::SchemaDumper.private_instance_methods(false).include?(:ignored?)
# This method will be present in Rails 4.2.0 and can be removed then.
def ignored?(table_name)
Expand Down
2 changes: 1 addition & 1 deletion scenic.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Gem::Specification.new do |spec|
spec.add_development_dependency "database_cleaner"
spec.add_development_dependency "rake"
spec.add_development_dependency "rspec", ">= 3.3"
spec.add_development_dependency "pg", "~> 0.19"
spec.add_development_dependency "pg"
spec.add_development_dependency "pry"
spec.add_development_dependency "ammeter", ">= 1.1.3"
spec.add_development_dependency "yard"
Expand Down
16 changes: 16 additions & 0 deletions spec/scenic/schema_dumper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ class SearchInAHaystack < ActiveRecord::Base

Search.connection.drop_view :"scenic.searches"
end

it "sorts dependency order when views exist in a non-public schema" do
Search.connection.execute("CREATE SCHEMA IF NOT EXISTS scenic; SET search_path TO public, scenic")
Search.connection.execute("CREATE VIEW scenic.apples AS SELECT 1;")
Search.connection.execute("CREATE VIEW scenic.bananas AS SELECT 2;")
Search.connection.execute("CREATE OR REPLACE VIEW scenic.apples AS SELECT * FROM scenic.bananas;")
stream = StringIO.new

ActiveRecord::SchemaDumper.dump(Search.connection, stream)
views = stream.string.lines.grep(/create_view/).map do |view_line|
view_line.match('create_view "(?<name>.*)"')[:name]
end
expect(views).to eq(%w[scenic.bananas scenic.apples])

Search.connection.execute("DROP SCHEMA IF EXISTS scenic CASCADE; SET search_path TO public")
end
end

it "handles active record table name prefixes and suffixes" do
Expand Down
Loading