rails2qdbd 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.
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "active_support/inflector"
5
+
6
+ module Rails2qdbd
7
+ # Reads the db/schema.rb dump DSL statically into a Catalog::Schema.
8
+ class SchemaParser
9
+ include Catalog
10
+
11
+ # Calls inside `create_table` that are not columns.
12
+ TABLE_IGNORED = %i[exclusion_constraint unique_constraint foreign_key].freeze
13
+ COLUMN_OPTIONS = %i[null default limit precision scale array comment].freeze
14
+
15
+ attr_reader :diagnostics
16
+
17
+ # inflector: anything answering #singularize and #pluralize (the project's Inflector later on).
18
+ def initialize(inflector: ActiveSupport::Inflector)
19
+ @inflector = inflector
20
+ @diagnostics = []
21
+ end
22
+
23
+ # paths are relative to root and are reported that way; tables share one namespace.
24
+ def parse_files(paths, root:)
25
+ merged = Schema.new
26
+ paths.each do |path|
27
+ full = File.expand_path(path, root)
28
+ raise Error, "schema file not found: #{path}" unless File.file?(full)
29
+
30
+ schema = parse(File.read(full), path: path)
31
+ merged.version ||= schema.version
32
+ merged.enums.merge!(schema.enums)
33
+ merged.foreign_keys.concat(schema.foreign_keys)
34
+ schema.tables.each do |name, table|
35
+ if (first = merged.tables[name])
36
+ error(%(table "#{name}" already defined at #{first.path}:#{first.line}), path, table.line)
37
+ else
38
+ merged.tables[name] = table
39
+ end
40
+ end
41
+ end
42
+ merged
43
+ end
44
+
45
+ def parse(source, path:)
46
+ @path = path
47
+ @schema = Schema.new
48
+ @pending_enums = []
49
+ result = Prism.parse(source)
50
+ if result.failure?
51
+ result.errors.each { |e| error("syntax error: #{e.message}", path, e.location.start_line) }
52
+ return @schema
53
+ end
54
+
55
+ statements(define_body(result.value)).each { |node| top_level(node) }
56
+ @pending_enums.each { |column, type| column.enum_values = @schema.enums[type] }
57
+ @schema
58
+ end
59
+
60
+ private
61
+
62
+ # The block of `ActiveRecord::Schema[8.1].define(...)`; falls back to the whole program.
63
+ def define_body(program)
64
+ define = statements(program).find { |n| n.is_a?(Prism::CallNode) && n.name == :define && n.block }
65
+ return program unless define
66
+
67
+ receiver = define.receiver
68
+ if receiver.is_a?(Prism::CallNode) && receiver.name == :[]
69
+ version = Literal.value(receiver.arguments&.arguments&.first)
70
+ @schema.version = version.to_s if Literal.known?(version)
71
+ end
72
+ define.block
73
+ end
74
+
75
+ def statements(node)
76
+ body = node.respond_to?(:statements) ? node.statements : node.body
77
+ body = body.body if body.is_a?(Prism::StatementsNode)
78
+ Array(body)
79
+ end
80
+
81
+ def top_level(node)
82
+ return unless node.is_a?(Prism::CallNode) && node.receiver.nil?
83
+
84
+ args, options = Literal.arguments(node)
85
+ case node.name
86
+ when :create_table then create_table(node, args, options)
87
+ when :add_foreign_key then add_foreign_key(args, options)
88
+ when :add_index then add_index(args, options)
89
+ when :create_enum then create_enum(args)
90
+ end
91
+ # Anything else (enable_extension, execute, create_view, ...) is not drawn.
92
+ end
93
+
94
+ def create_table(node, args, options)
95
+ name = literal(args.first)
96
+ return warn("create_table without a literal table name", node) unless name.is_a?(String) || name.is_a?(Symbol)
97
+
98
+ table = Table.new(name: name.to_s, path: @path, line: node.location.start_line)
99
+ table.comment = literal(options[:comment])
100
+ add_primary_key(table, options)
101
+ statements(node.block).each { |statement| table_statement(table, statement) } if node.block&.body
102
+ @schema.tables[table.name] = table
103
+ end
104
+
105
+ def add_primary_key(table, options)
106
+ id = options.key?(:id) ? literal(options[:id]) : :bigint
107
+ primary_key = options.key?(:primary_key) ? literal(options[:primary_key]) : "id"
108
+ return if id == false
109
+
110
+ if primary_key.is_a?(Array) # composite: the columns are declared in the block
111
+ table.primary_key = primary_key.map(&:to_s)
112
+ return
113
+ end
114
+
115
+ # `id: :uuid`, or `id: { type: :string, limit: 2 }` with column options inline.
116
+ attrs = id.is_a?(Hash) ? id.slice(*COLUMN_OPTIONS).except(:null) : {}
117
+ type = id.is_a?(Hash) ? id.fetch(:type, :bigint) : id
118
+ attrs[:default] = literal(options[:default]) if options.key?(:default)
119
+ table.primary_key = [primary_key.to_s]
120
+ table.columns << Column.new(name: primary_key.to_s, type: type.to_s, null: false, **attrs)
121
+ end
122
+
123
+ def table_statement(table, node)
124
+ return unless node.is_a?(Prism::CallNode) && node.receiver
125
+
126
+ args, options = Literal.arguments(node)
127
+ case node.name
128
+ when :index then table.indexes << build_index(args.first, options)
129
+ when :references, :belongs_to then add_references(table, args, options)
130
+ when :timestamps then add_timestamps(table, options)
131
+ when :check_constraint then table.checks << literal(args.first).to_s
132
+ when :virtual then add_column(table, node, args.first, literal(options[:type]), options)
133
+ when :column then add_column(table, node, args.first, literal(args[1]), options)
134
+ when *TABLE_IGNORED then nil
135
+ else
136
+ return warn("unrecognized `#{node.name}` in create_table \"#{table.name}\"", node) if args.empty?
137
+
138
+ add_column(table, node, args.first, node.name, options)
139
+ end
140
+ end
141
+
142
+ def add_column(table, node, name_node, type, options)
143
+ name = literal(name_node)
144
+ return warn("column without a literal name in \"#{table.name}\"", node) unless name.is_a?(String) || name.is_a?(Symbol)
145
+
146
+ attrs = COLUMN_OPTIONS.filter_map { |key| [key, literal(options[key])] if options.key?(key) }.to_h
147
+ column = Column.new(name: name.to_s, type: type.to_s, **attrs)
148
+ column.null = true if column.null.nil?
149
+ column.generated = generated(options) if options.key?(:as)
150
+ if type.to_s == "enum" && options.key?(:enum_type)
151
+ column.type = literal(options[:enum_type]).to_s
152
+ @pending_enums << [column, column.type]
153
+ end
154
+ table.primary_key << column.name if literal(options[:primary_key]) == true
155
+ table.columns << column
156
+ end
157
+
158
+ def generated(options)
159
+ expression = literal(options[:as]).to_s
160
+ literal(options[:stored]) == true ? "#{expression} (stored)" : expression
161
+ end
162
+
163
+ def add_references(table, args, options)
164
+ null = options.key?(:null) ? literal(options[:null]) : true
165
+ type = options.key?(:type) ? literal(options[:type]).to_s : "bigint"
166
+ polymorphic = literal(options[:polymorphic])
167
+ args.each do |arg|
168
+ name = literal(arg).to_s
169
+ columns = ["#{name}_id"]
170
+ if polymorphic
171
+ table.columns << Column.new(name: "#{name}_type", type: "string", null: null)
172
+ columns.unshift("#{name}_type")
173
+ end
174
+ table.columns << Column.new(name: "#{name}_id", type: type, null: null)
175
+ table.indexes << Index.new(columns: columns) unless literal(options[:index]) == false
176
+ add_reference_fk(table, name, literal(options[:foreign_key])) unless polymorphic
177
+ end
178
+ end
179
+
180
+ def add_reference_fk(table, name, foreign_key)
181
+ return unless foreign_key
182
+
183
+ to_table = foreign_key.is_a?(Hash) && foreign_key[:to_table] ? foreign_key[:to_table].to_s : @inflector.pluralize(name)
184
+ @schema.foreign_keys << ForeignKey.new(from_table: table.name, from_column: "#{name}_id", to_table: to_table, to_column: "id")
185
+ end
186
+
187
+ def add_timestamps(table, options)
188
+ null = options.key?(:null) ? literal(options[:null]) : false
189
+ %w[created_at updated_at].each { |name| table.columns << Column.new(name: name, type: "datetime", null: null) }
190
+ end
191
+
192
+ def add_foreign_key(args, options)
193
+ from, to = args.map { |node| literal(node).to_s }
194
+ column = options.key?(:column) ? literal(options[:column]).to_s : "#{@inflector.singularize(to)}_id"
195
+ primary_key = options.key?(:primary_key) ? literal(options[:primary_key]).to_s : "id"
196
+ @schema.foreign_keys << ForeignKey.new(from_table: from, from_column: column, to_table: to, to_column: primary_key)
197
+ end
198
+
199
+ def add_index(args, options)
200
+ table = @schema.tables[literal(args.first).to_s]
201
+ table&.indexes&.<<(build_index(args[1], options))
202
+ end
203
+
204
+ # Arrays and symbols name columns; a string is a column only if it looks like an identifier.
205
+ def build_index(target, options)
206
+ value = literal(target)
207
+ columns, expression = case value
208
+ when Array then [value.map(&:to_s), nil]
209
+ when Symbol then [[value.to_s], nil]
210
+ when /\A\w+\z/ then [[value], nil]
211
+ else [[], value.to_s]
212
+ end
213
+ Index.new(columns: columns, expression: expression, unique: literal(options[:unique]) == true,
214
+ where: options.key?(:where) ? literal(options[:where]) : nil,
215
+ name: options.key?(:name) ? literal(options[:name]) : nil)
216
+ end
217
+
218
+ def create_enum(args)
219
+ name, values = args.map { |node| literal(node) }
220
+ @schema.enums[name.to_s] = Array(values).map(&:to_s)
221
+ end
222
+
223
+ # Literal value, or the source text for anything computed (good enough for a diagram).
224
+ def literal(node)
225
+ return nil if node.nil?
226
+
227
+ value = Literal.value(node)
228
+ Literal.known?(value) ? value : node.slice
229
+ end
230
+
231
+ def warn(message, node)
232
+ @diagnostics << Diagnostic.new(severity: :warning, message: message, path: @path, line: node.location.start_line)
233
+ nil
234
+ end
235
+
236
+ def error(message, path, line)
237
+ @diagnostics << Diagnostic.new(severity: :error, message: message, path: path, line: line)
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails2qdbd
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails2qdbd
4
+ # Renders Catalog tables as QuickDBD text, following qdbd's parser rules.
5
+ class Writer
6
+ HEADER = "# Generated by rails2qdbd"
7
+ IDENTIFIER = /\A[A-Za-z0-9_]+\z/
8
+ LIMITED_TYPES = %w[string text binary char varchar citext].freeze
9
+ OPERATORS = { many_to_one: ">-", one_to_one: "-" }.freeze
10
+
11
+ # Bare when qdbd accepts it as is, else double-quoted (qdbd has no escape for '"').
12
+ def self.quote_identifier(name)
13
+ name = name.to_s
14
+ name.match?(IDENTIFIER) ? name : %("#{name.tr('"', "'")}")
15
+ end
16
+
17
+ # The text after `default=`; nil when there is no default to show.
18
+ def self.format_default(value)
19
+ text = case value
20
+ when nil then return nil
21
+ when Array then "[#{value.map { |v| format_default(v).to_s.delete('"') }.join(',')}]"
22
+ when Hash then value.empty? ? "{}" : value.to_s
23
+ else value.to_s
24
+ end
25
+ text.empty? || text.match?(/[\s#"]/) ? %("#{text.tr('"', "'")}") : text
26
+ end
27
+
28
+ # One `# ` line per text line; '#' would end qdbd's comment, so it becomes a full-width #.
29
+ def self.comment_lines(text)
30
+ text.to_s.split(/\r?\n/).map { |line| line.split.join(" ").tr("#", "#") }.reject(&:empty?).map { |line| "# #{line}" }
31
+ end
32
+
33
+ # relationships: { [table, column] => Relationship }; notes are extra documentation lines.
34
+ def render_table(table, relationships: {}, table_notes: [], column_notes: {})
35
+ lines = documentation([table.comment, *table_notes, *index_notes(table), *table.checks.map { |c| "check (#{c})" }])
36
+ lines << self.class.quote_identifier(table.name) << "-"
37
+ ordered_columns(table).each do |column|
38
+ lines.concat(documentation(column_docs(column) + column_notes.fetch(column.name, [])))
39
+ lines << column_line(table, column, relationships[[table.name, column.name]])
40
+ end
41
+ lines.join("\n")
42
+ end
43
+
44
+ def render_file(tables_text:, inputs:)
45
+ header = ["#{HEADER} #{VERSION} from #{inputs.join(', ')}.",
46
+ "# Re-run the conversion to refresh; manual edits will be overwritten."]
47
+ "#{[header.join("\n"), *tables_text].join("\n\n")}\n"
48
+ end
49
+
50
+ private
51
+
52
+ def documentation(texts) = texts.compact.flat_map { |text| self.class.comment_lines(text) }
53
+
54
+ def ordered_columns(table)
55
+ pk, rest = table.columns.partition { |column| table.primary_key.include?(column.name) }
56
+ pk + rest
57
+ end
58
+
59
+ def column_docs(column)
60
+ docs = [column.comment]
61
+ docs << "generated: #{column.generated}" if column.generated
62
+ docs << "enum: #{column.enum_values.join(', ')}" if column.enum_values&.any?
63
+ docs
64
+ end
65
+
66
+ def column_line(table, column, relationship)
67
+ pk = table.primary_key.include?(column.name)
68
+ parts = [self.class.quote_identifier(column.name)]
69
+ parts << "PK" if pk
70
+ parts << type(column)
71
+ parts << index_keyword(table, column) unless pk
72
+ parts << "NULL" if column.null && !pk
73
+ default = self.class.format_default(column.default)
74
+ parts << "default=#{default}" if default
75
+ if relationship
76
+ target = "#{self.class.quote_identifier(relationship.to_table)}.#{self.class.quote_identifier(relationship.to_column)}"
77
+ parts << "FK" << OPERATORS.fetch(relationship.cardinality) << target
78
+ end
79
+ parts.compact.join(" ")
80
+ end
81
+
82
+ # qdbd types are one token without '=', '#' or '"'.
83
+ def type(column)
84
+ base = column.type.to_s.strip.gsub(/\s+/, "_").tr("=", ":").delete('#"')
85
+ base = "unknown" if base.empty?
86
+ size = if column.precision && column.scale then "(#{column.precision},#{column.scale})"
87
+ elsif column.precision then "(#{column.precision})"
88
+ elsif column.limit && LIMITED_TYPES.include?(base) then "(#{column.limit})"
89
+ end
90
+ "#{base}#{size}#{'[]' if column.array}"
91
+ end
92
+
93
+ def index_keyword(table, column)
94
+ single = table.indexes.select { |index| index.single_column? && index.columns.first == column.name }
95
+ if single.any?(&:unique) then "UNIQUE"
96
+ elsif single.any? then "INDEX"
97
+ end
98
+ end
99
+
100
+ # Single-column indexes are column keywords, except on a PK column, which takes none: there it is
101
+ # redundant on a one-column key and noted on a composite one.
102
+ def keyword_index?(table, index)
103
+ index.single_column? && (table.primary_key.size == 1 || !table.primary_key.include?(index.columns.first))
104
+ end
105
+
106
+ # Indexes qdbd cannot express as a column keyword.
107
+ def index_notes(table)
108
+ table.indexes.reject { |index| keyword_index?(table, index) }.map do |index|
109
+ target = index.expression || index.columns.join(", ")
110
+ note = "#{'unique ' if index.unique}index (#{target})"
111
+ index.where ? "#{note} where #{index.where}" : note
112
+ end
113
+ end
114
+ end
115
+ end
data/lib/rails2qdbd.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails2qdbd/version"
4
+ require_relative "rails2qdbd/catalog"
5
+ require_relative "rails2qdbd/literal"
6
+ require_relative "rails2qdbd/inflector"
7
+ require_relative "rails2qdbd/schema_parser"
8
+ require_relative "rails2qdbd/model_scanner"
9
+ require_relative "rails2qdbd/resolver"
10
+ require_relative "rails2qdbd/writer"
11
+ require_relative "rails2qdbd/config"
12
+ require_relative "rails2qdbd/grouping"
13
+ require_relative "rails2qdbd/converter"
14
+ require_relative "rails2qdbd/output"
15
+ require_relative "rails2qdbd/cli"
16
+
17
+ # Converts a Rails project's db/schema.rb and app/models into qdbd schema files.
18
+ # Pipeline (see Converter): SchemaParser + ModelScanner -> Resolver -> Writer -> Output.
19
+ module Rails2qdbd
20
+ class Error < StandardError; end
21
+
22
+ # Converts the app at app_root. overrides: Config keys (output:, models:, exclude:, ...).
23
+ def self.convert(app_root, config_path: nil, **overrides)
24
+ Converter.new(Config.load(app_root, path: config_path, overrides: overrides)).run
25
+ end
26
+ end
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails2qdbd
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - fastcatch
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: prism
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '3'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '1.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '3'
52
+ description: |
53
+ rails2qdbd reads db/schema.rb and app/models statically (no app boot, no database)
54
+ and writes QuickDBD-syntax schema files for qdbd, the ER-diagram toolkit.
55
+ email:
56
+ - fastcatch@gmail.com
57
+ executables:
58
+ - rails2qdbd
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - CHANGELOG.md
63
+ - LICENSE.txt
64
+ - README.md
65
+ - exe/rails2qdbd
66
+ - lib/rails2qdbd.rb
67
+ - lib/rails2qdbd/catalog.rb
68
+ - lib/rails2qdbd/cli.rb
69
+ - lib/rails2qdbd/config.rb
70
+ - lib/rails2qdbd/converter.rb
71
+ - lib/rails2qdbd/grouping.rb
72
+ - lib/rails2qdbd/inflector.rb
73
+ - lib/rails2qdbd/literal.rb
74
+ - lib/rails2qdbd/model_scanner.rb
75
+ - lib/rails2qdbd/output.rb
76
+ - lib/rails2qdbd/resolver.rb
77
+ - lib/rails2qdbd/schema_parser.rb
78
+ - lib/rails2qdbd/version.rb
79
+ - lib/rails2qdbd/writer.rb
80
+ homepage: https://github.com/fastcatch/rails2qdbd
81
+ licenses:
82
+ - MIT
83
+ metadata:
84
+ source_code_uri: https://github.com/fastcatch/rails2qdbd
85
+ changelog_uri: https://github.com/fastcatch/rails2qdbd/blob/main/CHANGELOG.md
86
+ bug_tracker_uri: https://github.com/fastcatch/rails2qdbd/issues
87
+ rubygems_mfa_required: 'true'
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '3.2'
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 4.0.20
103
+ specification_version: 4
104
+ summary: Convert a Rails app's schema.rb and models into qdbd (QuickDBD) schema files.
105
+ test_files: []