pg-erd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # Postgres ERD
2
+
3
+ ## Generate Entity-Relationship Diagrams for Postgresql Databases
4
+
5
+ Postgres ERD is a program that allows you to easily generate a diagram based on a postgresql database schema. The diagram gives an overview of how your tables are related. Having a diagram to describes your tables is perfect for documentation.
6
+
7
+ Postgres ERD is heavily inspired by [Rails ERD](https://voormedia.github.io/rails-erd/) but it serves some different use cases. First and foremost it does not rely on Rails (like Rails ERD). Thus you can use it for any database schema regardless of your project's language.
8
+
9
+ ## An Example
10
+
11
+
12
+ # Requirements
13
+
14
+ - Ruby 2.4+
15
+ - postgresql
16
+
17
+ # Getting started
18
+
19
+ [It's part of a two-step program](https://www.youtube.com/watch?v=_c1NJQ0UP_Q):
20
+
21
+ Install the gem
22
+
23
+ ```sh
24
+ gem install pg-erd
25
+ ```
26
+
27
+ Use the thing:
28
+
29
+ ```sh
30
+ pg-erd {{the-name-of-your-database}}
31
+ ```
32
+
33
+ This will output a diagram of your database in [dot format](http://www.graphviz.org/). You can save it
34
+
35
+ ```sh
36
+ pg-erd {{the-name-of-your-database}} > my-awesome-database.dot
37
+ ```
38
+
39
+ ... or run it though graphviz to create a png file:
40
+
41
+ ```sh
42
+ pg-erd {{the-name-of-your-database}} | dot -Tpng > erd.png
43
+ ```
44
+
45
+ ... which you may also do like this:
46
+
47
+ ```sh
48
+ pg-erd --title "Secret Project X" --format png {{the-name-of-your-database}} > erd.png
49
+ ```
50
+
51
+ There are many more formats and a few more useful options. You can find them all with `--help`
52
+
53
+ # Free extras
54
+
55
+ ## pg-erd-everything
56
+
57
+ This command will create an ERD for every database on your system. It will write many image files, one for each database, to the current directory.
58
+
59
+ ## pg-list-databases
60
+
61
+ Like `ls` but it lists the names of your postgres databases one by one.
62
+
63
+ ## pg-inspect
64
+
65
+ This command outputs a summary of your database structure to the console. It shows the same information as an ERD but then in plain text.
66
+
data/bin/pg-erd ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env ruby
2
+ require 'optparse'
3
+ require_relative '../lib/pgerd'
4
+
5
+ def parse_arguments!(args = ARGV)
6
+ options = {
7
+ format: 'dot',
8
+ size: nil,
9
+ show_id: true,
10
+ show_timestamps: true,
11
+ verbose: false
12
+ }
13
+
14
+ opt_parser = OptionParser.new do |opts|
15
+ opts.banner = "Usage: #{$0} [options] DATABASE"
16
+ opts.separator ''
17
+
18
+ opts.on("-fFORMAT", "--format FORMAT", GraphViz::Constants::FORMATS.join(', ') + " (default=dot)") do |format|
19
+ options[:format] = format.downcase
20
+ end
21
+
22
+ opts.on("--[no-]id", 'Show (or hide) the id column (default=show)') do |show_id|
23
+ options[:show_id] = show_id
24
+ end
25
+
26
+ opts.on("--[no-]timestamps", 'Show (or hide) the timestamp columns (default=show)') do |show_timestamps|
27
+ options[:show_timestamps] = show_timestamps
28
+ end
29
+
30
+ opts.on("-tTITLE", "--title TITLE", "override the title") do |title|
31
+ options[:title] = title
32
+ end
33
+
34
+ opts.on("--a4", 'try to fit the erd on an A4') do |size|
35
+ options[:size] = 'A4'
36
+ end
37
+
38
+ opts.on("-v", "--[no-]verbose", "Verbose output") do |v|
39
+ options[:verbose] = v
40
+ end
41
+
42
+ opts.on_tail("-h", "--help", "Show this message") do
43
+ puts opts
44
+ exit
45
+ end
46
+ end
47
+ opt_parser.parse!(args)
48
+ options
49
+ end
50
+
51
+ options = parse_arguments!
52
+
53
+ STDERR.puts options if options[:verbose]
54
+
55
+ require 'pg'
56
+ CONNECTION = PG.connect(dbname: ARGV.last)
57
+
58
+ include Pgerd
59
+
60
+ database = Database.new(ARGV.first)
61
+ erd = Erd.new(database, options)
62
+
63
+ puts erd.diagram.output(options[:format] => String)
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'pg'
4
+ require 'optparse'
5
+ require_relative '../lib/pgerd'
6
+ include Pgerd
7
+
8
+ def parse_arguments!(args = ARGV)
9
+ options = {
10
+ format: 'svg',
11
+ show_id: true,
12
+ show_timestamps: true,
13
+ verbose: false
14
+ }
15
+
16
+ opt_parser = OptionParser.new do |opts|
17
+ opts.banner = "Usage: #{$0} [options] DATABASE"
18
+ opts.separator ''
19
+
20
+ # opts.on("-fFORMAT", "--format FORMAT", GraphViz::Constants::FORMATS.join(', ') + " (default=dot)") do |format|
21
+ # options[:format] = format.downcase
22
+ # end
23
+
24
+ opts.on("--[no-]id", 'Show (or hide) the id column (default=show)') do |show_id|
25
+ options[:show_id] = show_id
26
+ end
27
+
28
+ opts.on("--[no-]timestamps", 'Show (or hide) the timestamp columns (default=show)') do |show_timestamps|
29
+ options[:show_timestamps] = show_timestamps
30
+ end
31
+
32
+ opts.on("-tTITLE", "--title TITLE", "override the title") do |title|
33
+ options[:title] = title
34
+ end
35
+
36
+ opts.on("-v", "--[no-]verbose", "Verbose output") do |v|
37
+ options[:verbose] = v
38
+ end
39
+
40
+ opts.on_tail("-h", "--help", "Show this message") do
41
+ puts opts
42
+ exit
43
+ end
44
+ end
45
+ opt_parser.parse!(args)
46
+ options
47
+ end
48
+
49
+ options = parse_arguments!
50
+
51
+ STDERR.puts options if options[:verbose]
52
+
53
+ def each_database
54
+ PG.connect.query("SELECT datname FROM pg_database WHERE datistemplate = false;").each do |result|
55
+ yield result['datname']
56
+ end
57
+ end
58
+
59
+ each_database do |name|
60
+ puts name
61
+ database = Database.new(name)
62
+ erd = Erd.new(database, options)
63
+ File.open("#{name}.#{options[:format]}", 'w') do |file|
64
+ file.puts erd.diagram.output(options[:format] => String)
65
+ end
66
+ end
data/bin/pg-erd-png ADDED
@@ -0,0 +1,4 @@
1
+ #!/bin/sh
2
+
3
+ pg-erd $@ | dot -Tpng > erd.png
4
+ optipng erd.png
data/bin/pg-erd-svg ADDED
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+
3
+ pg-erd --format svg $@
data/bin/pg-inspect ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+ require 'pg'
3
+ CONNECTION = PG.connect(dbname: ARGV.first)
4
+
5
+ require_relative '../lib/pgerd'
6
+ include Pgerd
7
+
8
+ database = Database.new(ARGV.first)
9
+
10
+ database.tables.each do |table|
11
+ puts "#{table.name}"
12
+ puts "-" * 20
13
+ puts table.columns.map { |column| "#{column.name} - #{column.data_type}" }
14
+ puts
15
+ end
16
+
17
+ if database.foreign_keys.any?
18
+ puts '# foreign keys'
19
+ database.foreign_keys.each { |x| puts x }
20
+ end
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'pg'
4
+
5
+ PG.connect.query("SELECT datname FROM pg_database WHERE datistemplate = false;").each do |result|
6
+ puts result['datname']
7
+ end
data/lib/dot/id.rb ADDED
@@ -0,0 +1,24 @@
1
+ module Dot
2
+ # An ID is one of the following:
3
+
4
+ # Any string of alphabetic ([a-zA-Z\200-\377]) characters, underscores ('_') or digits ([0-9]), not beginning with a digit;
5
+ # a numeral [-]?(.[0-9]+ | [0-9]+(.[0-9]*)? );
6
+ # any double-quoted string ("...") possibly containing escaped quotes ('");
7
+ # an HTML string (<...>).
8
+ class Id
9
+ def initialize(string)
10
+ @string = string.to_s
11
+ raise ArgumentError if @string.empty?
12
+ end
13
+
14
+ def escaped
15
+ @string.gsub!("\"", "\\\"")
16
+ @string.gsub!("\'", "\\\'")
17
+ @string
18
+ end
19
+
20
+ def to_dot
21
+ "\"#{escaped}\""
22
+ end
23
+ end
24
+ end
data/lib/dot/node.rb ADDED
@@ -0,0 +1,4 @@
1
+ module Dot
2
+ class Node
3
+ end
4
+ end
@@ -0,0 +1,48 @@
1
+ module Pgerd
2
+ class Column
3
+ include Comparable
4
+ attr_accessor :name, :data_type
5
+ ID = %w(id)
6
+ GROUPS = %i(id other timestamps)
7
+
8
+ def initialize(name, data_type)
9
+ @name = name
10
+ @data_type = data_type
11
+ end
12
+
13
+ def <=>(other)
14
+ to_a_for_sorting <=> other.to_a_for_sorting
15
+ end
16
+
17
+ def group
18
+ if ID.include? @name
19
+ :id
20
+ elsif @data_type.include?("timestamp")
21
+ :timestamps
22
+ else
23
+ :other
24
+ end
25
+ end
26
+
27
+ def group_for_sorting
28
+ GROUPS.index(group)
29
+ end
30
+
31
+ def abbreviated_data_type
32
+ # The SQL standard requires that writing just timestamp be equivalent to timestamp without time zone,
33
+ # and PostgreSQL honors that behavior.
34
+ @data_type
35
+ .gsub('timestamp without time zone', 'timestamp')
36
+ .gsub(/\bwithout\b/, 'w/o')
37
+ .gsub(/\bwith\b/, 'w/')
38
+ end
39
+
40
+ def to_s
41
+ "#{name} [label = <>]"
42
+ end
43
+
44
+ def to_a_for_sorting
45
+ [group_for_sorting, name, data_type]
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,75 @@
1
+ require_relative 'foreign_key'
2
+ require_relative 'table'
3
+ require_relative 'view'
4
+
5
+ module Pgerd
6
+ class Database
7
+ attr_reader :name
8
+ TABLES_TO_IGNORE = %w(ar_internal_metadata schema_migrations)
9
+
10
+ def initialize(name)
11
+ @name = name
12
+ end
13
+
14
+ def all_table_names
15
+ connection
16
+ .query("SELECT tablename FROM pg_catalog.pg_tables")
17
+ .map { |table| table['tablename'] }
18
+ .reject { |name| name.start_with?('pg_') || name.start_with?('sql_')}
19
+ end
20
+
21
+ def all_view_names
22
+ connection
23
+ .query("SELECT viewname FROM pg_catalog.pg_views WHERE schemaname NOT IN ('pg_catalog', 'information_schema')")
24
+ .map { |view| view['viewname'] }
25
+ .reject { |name| name.start_with?('pg_') || name.start_with?('sql_')}
26
+ end
27
+
28
+ def foreign_keys
29
+ @foreign_keys ||= raw_foreign_keys.map { |data| ForeignKey.new(data) }
30
+ end
31
+
32
+ def raw_foreign_keys
33
+ connection
34
+ .query <<-END_SQL
35
+ select c.constraint_name
36
+ , x.table_schema as from_schema
37
+ , x.table_name as from_table
38
+ , x.column_name as from_column
39
+ , y.table_schema as to_schema
40
+ , y.table_name as to_table
41
+ , y.column_name as to_column
42
+ from information_schema.referential_constraints c
43
+ join information_schema.key_column_usage x
44
+ on x.constraint_name = c.constraint_name
45
+ join information_schema.key_column_usage y
46
+ on y.ordinal_position = x.position_in_unique_constraint
47
+ and y.constraint_name = c.unique_constraint_name
48
+ order by c.constraint_name, x.ordinal_position
49
+ END_SQL
50
+ end
51
+
52
+ def table_names
53
+ all_table_names - TABLES_TO_IGNORE
54
+ end
55
+
56
+ def tables
57
+ @tables ||=
58
+ table_names
59
+ .map { |name| Table.new(self, name) }
60
+ .sort_by { |table| table.foreign_keys.size }
61
+ .reverse
62
+ end
63
+
64
+ def views
65
+ @views ||=
66
+ all_view_names
67
+ .map { |name| View.new(self, name) }
68
+ .reverse
69
+ end
70
+
71
+ def connection
72
+ @connection ||= PG.connect(dbname: name)
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,8 @@
1
+ require 'erb'
2
+ require 'ostruct'
3
+
4
+ class ErbalT < OpenStruct
5
+ def render(template)
6
+ ERB.new(template).result(binding)
7
+ end
8
+ end
@@ -0,0 +1,46 @@
1
+ require_relative '../erbal_t'
2
+
3
+ module Pgerd
4
+ class Erd
5
+ class TableNode
6
+ TABLE_TEMPLATE = <<-END_HTML
7
+ <table border="1" align="center" cellborder="0" cellpadding="2" cellspacing="2">
8
+ <tr><td bgcolor="#f8f8f8" align="center" cellpadding="8"><font point-size="11"><%= name %></font></td></tr>
9
+ <% grouped_columns.each_with_index do |group, index| %>
10
+ <% if index > 0 %>
11
+ <tr><td height='3'></td></tr>
12
+ <% end %>
13
+ <% group.each do |column| %>
14
+ <tr>
15
+ <td align="left" port="<%= column.name %>"><%= column.name %> <font color="grey60"><%= column.abbreviated_data_type %></font></td>
16
+ </tr>
17
+ <% end %>
18
+ <% end %>
19
+ </table>
20
+ END_HTML
21
+
22
+ attr_reader :table, :groups_to_hide
23
+
24
+ def initialize(table, options = {})
25
+ @table = table
26
+
27
+ @groups_to_hide = []
28
+ @groups_to_hide << :id unless options.fetch(:show_id, true)
29
+ @groups_to_hide << :timestamps unless options.fetch(:show_timestamps, true)
30
+ end
31
+
32
+ def self.render(table, options = {})
33
+ new(table, options).to_html
34
+ end
35
+
36
+ def to_html
37
+ grouped_columns = @table.columns
38
+ .reject { |column| @groups_to_hide.include? column.group }
39
+ .group_by(&:group)
40
+ .values
41
+
42
+ ErbalT.new(name: @table.name, grouped_columns: grouped_columns).render(TABLE_TEMPLATE).strip
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,46 @@
1
+ require_relative '../erbal_t'
2
+
3
+ module Pgerd
4
+ class Erd
5
+ class ViewNode
6
+ VIEW_TEMPLATE = <<-END_HTML
7
+ <table border="1" align="center" cellborder="0" cellpadding="2" cellspacing="2">
8
+ <tr><td bgcolor="#fafaf8" align="center" cellpadding="8"><font point-size="11"><%= name %> *view*</font></td></tr>
9
+ <% grouped_columns.each_with_index do |group, index| %>
10
+ <% if index > 0 %>
11
+ <tr><td height='3'></td></tr>
12
+ <% end %>
13
+ <% group.each do |column| %>
14
+ <tr>
15
+ <td align="left" port="<%= column.name %>"><%= column.name %> <font color="grey60"><%= column.abbreviated_data_type %></font></td>
16
+ </tr>
17
+ <% end %>
18
+ <% end %>
19
+ </table>
20
+ END_HTML
21
+
22
+ attr_reader :view, :groups_to_hide
23
+
24
+ def initialize(view, options = {})
25
+ @view = view
26
+
27
+ @groups_to_hide = []
28
+ @groups_to_hide << :id unless options.fetch(:show_id, true)
29
+ @groups_to_hide << :timestamps unless options.fetch(:show_timestamps, true)
30
+ end
31
+
32
+ def self.render(view, options = {})
33
+ new(view, options).to_html
34
+ end
35
+
36
+ def to_html
37
+ grouped_columns = @view.columns
38
+ .reject { |column| @groups_to_hide.include? column.group }
39
+ .group_by(&:group)
40
+ .values
41
+
42
+ ErbalT.new(name: @view.name, grouped_columns: grouped_columns).render(VIEW_TEMPLATE).strip
43
+ end
44
+ end
45
+ end
46
+ end
data/lib/pgerd/erd.rb ADDED
@@ -0,0 +1,136 @@
1
+ require 'ruby-graphviz'
2
+ require 'ruby-progressbar'
3
+ require_relative 'erd/table_node'
4
+ require_relative 'erd/view_node'
5
+
6
+ module Pgerd
7
+ class Erd
8
+ attr_reader :diagram
9
+
10
+ def initialize(database, options = {})
11
+ @database = database
12
+ @title = options[:title] || @database.name
13
+ @size = options[:size].to_s.downcase
14
+ @options = options
15
+
16
+ create_a_digraph
17
+ draw_the_tables
18
+ draw_the_views
19
+ draw_the_foreign_keys
20
+ end
21
+
22
+ def to_s
23
+ @diagram.output(dot: String)
24
+ end
25
+
26
+ def a4?
27
+ @size == 'a4'
28
+ end
29
+
30
+ private
31
+
32
+ def create_a_digraph
33
+ @diagram = GraphViz.new(@title,
34
+ bgcolor: '#f4f8fa',
35
+ center: 1,
36
+ concentrate: true,
37
+ fontcolor: '#000000d0',
38
+ fontname: 'Helvetica',
39
+ fontsize: 13,
40
+ label: @title + "\n\n",
41
+ labelloc: 't',
42
+ # margin: 0,
43
+ nodesep: 0.2,
44
+ outputorder: 'nodesfirst',
45
+ overlap: false,
46
+ pad: '0.4,0.4',
47
+ rankdir: 'LR',
48
+ ranksep: '0.5',
49
+ splines: true,
50
+ type: :digraph)
51
+
52
+ if a4?
53
+ diagram[:size] = '8.3,11.7!'
54
+ diagram[:ratio] = 'compress'
55
+ end
56
+
57
+ @diagram.node[:color] = '#000000d0'
58
+ @diagram.node[:fillcolor] = '#ffffff'
59
+ @diagram.node[:fontname] = 'Helvetica'
60
+ @diagram.node[:fontsize] = 10
61
+ # @diagram.node[:margin] = "0.07,0.05"
62
+ @diagram.node[:margin] = "0"
63
+ @diagram.node[:penwidth] = "1.0"
64
+ @diagram.node[:shape] = 'box'
65
+ @diagram.node[:style] = 'filled'
66
+
67
+ @diagram.edge[:arrowsize] = "0.9"
68
+ @diagram.edge[:arrowtail] = "none"
69
+ @diagram.edge[:color] = '#000000d0'
70
+ @diagram.edge[:dir] = "both"
71
+ @diagram.edge[:fontname] = "Helvetica"
72
+ @diagram.edge[:fontsize] = "7"
73
+ @diagram.edge[:labelangle] = "32"
74
+ @diagram.edge[:labeldistance] = "1.8"
75
+ @diagram.edge[:penwidth] = "1.0"
76
+ end
77
+
78
+ def draw_the_tables
79
+ progress = ProgressBar.create(
80
+ title: 'Tables',
81
+ total: @database.tables.count,
82
+ format: '%t: |%B| %%%p %E',
83
+ output: $stderr)
84
+ @database.tables.each do |table|
85
+ draw_table(table)
86
+ progress.increment
87
+ end
88
+ end
89
+
90
+ def draw_table(table)
91
+ html = Erd::TableNode.render(table, @options)
92
+ @diagram.add_node(table.name, label: "<#{html}>")
93
+ end
94
+
95
+ def draw_the_views
96
+ progress = ProgressBar.create(
97
+ title: 'Views',
98
+ total: @database.tables.count,
99
+ format: '%t: |%B| %%%p %E',
100
+ output: $stderr)
101
+ @database.views.each do |view|
102
+ draw_view(view)
103
+ progress.increment
104
+ end
105
+ end
106
+
107
+ def draw_view(view)
108
+ html = Erd::ViewNode.render(view, @options)
109
+ @diagram.add_node(view.name, label: "<#{html}>")
110
+ end
111
+
112
+ def draw_the_foreign_keys
113
+ progress = ProgressBar.create(
114
+ title: 'Foreign Keys',
115
+ total: @database.foreign_keys.count,
116
+ format: '%t: |%B| %%%p %E',
117
+ output: $stderr)
118
+ @database.foreign_keys.each do |fk|
119
+ draw_foreign_key(fk)
120
+ progress.increment
121
+ end
122
+ end
123
+
124
+ def draw_foreign_key(fk)
125
+ from = address(fk.from_table, fk.from_column)
126
+ to = address(fk.to_table, fk.to_column)
127
+
128
+ @diagram.add_edge(from, to, weight: 2)
129
+ end
130
+
131
+ def address(table, column)
132
+ return table if column == 'id'
133
+ { table => column }
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,29 @@
1
+ module Pgerd
2
+ class ForeignKey
3
+ include Comparable
4
+ attr_reader :from_table, :from_column, :to_table, :to_column
5
+
6
+ def initialize(data)
7
+ @from_table = data['from_table']
8
+ @from_column = data['from_column']
9
+ @to_table = data['to_table']
10
+ @to_column = data['to_column']
11
+ end
12
+
13
+ def to_s
14
+ "#{@from_table}.#{@from_column} --> #{@to_table}.#{@to_column}"
15
+ end
16
+
17
+ def <=>(other)
18
+ to_a_for_sorting <=> other.to_a_for_sorting
19
+ end
20
+
21
+ def to_a_for_sorting
22
+ [@from_table, @from_column, @to_table, @to_column]
23
+ end
24
+
25
+ def for_table?(table)
26
+ @from_table == table || @to_table == table
27
+ end
28
+ end
29
+ end