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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5b17ebb700917652e2ac95acd51f4e478739d75c4c4a9bc23d75268498e2cd04
4
+ data.tar.gz: 9e9185d57c57e0cb3602ce000e0a60a72ec558d48acca7fe28b6af75b8a82c19
5
+ SHA512:
6
+ metadata.gz: 6ecab2033502484b369de176755ddffcf013968803dec56b3c9e92470457279492c9eb9495fb83e4503b34a82318b08f4ea7196aa5b8686b08cf7e99810e3df4
7
+ data.tar.gz: 73f8b2ef9607de0fcf17b7d29924e323dcbeb77e3c722630a2a9aaa0c9d060518f4f7baa17a6855f12e9a004e538001d2325a670fccc289e2be291c47541ac90
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-26
10
+
11
+ ### Added
12
+ - Converter: `db/schema.rb` (+ extra schema files) and `app/models` → qdbd schema files,
13
+ parsed statically with Prism.
14
+ - Relationships from `add_foreign_key` / `t.references` and from `belongs_to`, `has_many`,
15
+ `has_one`, `has_and_belongs_to_many` (class_name, foreign_key, primary_key, join_table,
16
+ STI, concerns, `with_options`, namespaces, project inflections).
17
+ - Documentation notes for composite/expression/partial indexes, check constraints, enums,
18
+ polymorphic associations (incl. Active Storage), generated columns, STI, attachments.
19
+ - `.rails2qdbd.yml`: output, schemas, models, inflections, exclude, groups.
20
+ - CLI `rails2qdbd` with `--check` for CI.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fastcatch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # rails2qdbd
2
+
3
+ Turn a Rails application's `db/schema.rb` and `app/models` into
4
+ [qdbd](https://qdbd.dev/guide/) schema files: plain-text QuickDBD syntax that
5
+ qdbd checks, lays out and serves as an interactive ER diagram.
6
+
7
+ ```sh
8
+ rails2qdbd path/to/app -o schema/ # write schema/*.txt
9
+ qdbd serve schema/ # live diagram in the browser
10
+ ```
11
+
12
+ - Ruby files are parsed with [Prism](https://github.com/ruby/prism),
13
+ never executed. No app boot, no database, none of the app's gems needed.
14
+ - Relationships come from database foreign keys and from model
15
+ associations (`belongs_to`, `has_many`, `has_one`, `has_and_belongs_to_many`,
16
+ STI, concerns, namespaces, project inflections).
17
+ - What qdbd cannot draw (composite, expression and
18
+ partial indexes, enums, polymorphic associations, generated columns,
19
+ check constraints, attachments) becomes documentation that qdbd shows on hover.
20
+ Anything the tool cannot resolve is reported as a warning.
21
+ - Output is deterministic, and `--check` exits 1 when the
22
+ committed files are stale.
23
+
24
+ ## Installation
25
+
26
+ ```sh
27
+ gem install rails2qdbd
28
+ ```
29
+
30
+ or in the app's Gemfile (it never loads into the app; it only reads files):
31
+
32
+ ```ruby
33
+ gem "rails2qdbd", group: :development, require: false
34
+ ```
35
+
36
+ Requirements: Ruby ≥ 3.2 (tested on 3.2 – 4.0), with `prism` and
37
+ `activesupport` ≥ 7.1 as its only runtime dependencies, so it fits Rails 7.1 –
38
+ 8.1 apps. To look at the result you need
39
+ [qdbd](https://pypi.org/project/quickdbd-cli/) (`pipx install quickdbd-cli`).
40
+
41
+ ## Usage
42
+
43
+ ```
44
+ rails2qdbd [APP_ROOT] [options] # APP_ROOT defaults to "."
45
+ -o, --output PATH a .txt file or a directory (default: config, else stdout)
46
+ --schema FILE schema file, repeatable (default: db/schema.rb)
47
+ --models DIR models directory (default: app/models)
48
+ --no-models schema only, no model associations
49
+ -c, --config FILE config file (default: APP_ROOT/.rails2qdbd.yml)
50
+ --exclude GLOB leave out matching tables, repeatable
51
+ --check write nothing; exit 1 if the output is out of date
52
+ -q, --quiet no warnings or summary
53
+ -v, --version
54
+ ```
55
+
56
+ Paths on the command line are relative to the current directory; paths in the
57
+ config file are relative to the app root. Exit codes: `0` ok, `1` errors or
58
+ out of date, `2` usage error. Warnings go to stderr as
59
+ `path:line: warning: message`.
60
+
61
+ Multiple databases (Rails 8's `db/queue_schema.rb`, `db/cache_schema.rb`, …)
62
+ can be combined: `--schema db/schema.rb --schema db/queue_schema.rb`.
63
+
64
+ ### Configuration: `.rails2qdbd.yml`
65
+
66
+ Optional, in the app root:
67
+
68
+ ```yaml
69
+ output: schema/ # .txt file or directory
70
+ schemas: [db/schema.rb]
71
+ models: app/models # false: schema only
72
+ inflections: config/initializers/inflections.rb # false: ignore
73
+ exclude: [ar_internal_metadata, "solid_*"]
74
+ default_group: app
75
+ groups: # one file per group (qdbd shows each file as a group)
76
+ storage: ["active_storage_*"]
77
+ auth: [users, sessions]
78
+ ```
79
+
80
+ Groups split the output into one file per group when the output is a
81
+ directory; the first matching glob wins. Without groups, or with a `.txt`
82
+ output, everything goes into one file.
83
+
84
+ ### Keeping the diagram current in CI
85
+
86
+ ```sh
87
+ rails2qdbd -o schema/ --check && qdbd check --strict schema/
88
+ ```
89
+
90
+ Generated files begin with `# Generated by rails2qdbd …`. Only files with
91
+ that header are ever replaced or deleted, so hand-written qdbd files can live
92
+ alongside them; if an output file exists without it, nothing is written and the
93
+ command exits 1. qdbd's layout file (`qdbd.layout.json`) is
94
+ never touched, so table positions you arrange are kept across regenerations.
95
+
96
+ ## What you get
97
+
98
+ ```text
99
+ # unique index (tenant_id, email_address)
100
+ users
101
+ -
102
+ id PK bigint
103
+ email_address string
104
+ invalidated_at datetime NULL
105
+ # enum: member, owner
106
+ role string default=member
107
+ tenant_id bigint INDEX FK >- tenants.id
108
+ ```
109
+
110
+ | Rails | qdbd |
111
+ |---|---|
112
+ | implicit `id`, `id: :uuid`, `primary_key: "code"`, composite `primary_key: [...]`, `id: false` | `PK` on the key column(s) |
113
+ | column type and `limit:` / `precision:` / `scale:` / `array:` | `string(50)`, `decimal(10,2)`, `string[]` (Rails type names) |
114
+ | nullable (Rails default) | `NULL` (qdbd's default is NOT NULL) |
115
+ | `default:` (including `-> { "now()" }`) | `default=…`, quoted when needed |
116
+ | single-column unique / plain index | `UNIQUE` / `INDEX` |
117
+ | composite, expression, partial index; index on one column of a composite primary key; `check_constraint` | documentation line above the table |
118
+ | `add_foreign_key`, `t.references foreign_key: true` | `FK >- table.column` |
119
+ | association without a database FK | `FK >- …` plus the note *relationship from model association* |
120
+ | unique FK column, or `has_one` (without `has_many`) | one-to-one `FK - …` |
121
+ | `has_and_belongs_to_many` | FKs on the join table |
122
+ | polymorphic `belongs_to` / `has_many as:` / Active Storage | note: `polymorphic (type in x_type): Model, …` |
123
+ | `enum` (model) / `create_enum` (PostgreSQL) | note: `enum: a, b` |
124
+ | `t.virtual … as:` | note: `generated: …` |
125
+ | STI subclasses, `has_*_attached` | table notes |
126
+ | `comment:` on tables and columns | documentation lines |
127
+
128
+ ## Limitations
129
+
130
+ - `db/structure.sql` is not read (use `schema.rb`).
131
+ - Association options must be literals; `class_name: some_method` is skipped
132
+ with a warning.
133
+ - Relationships are never guessed from column names alone.
134
+ - Module-level `table_name_prefix` is not followed; use `self.table_name`.
135
+ - Only the literal `irregular / plural / singular / uncountable / acronym`
136
+ calls of `config/initializers/inflections.rb` are applied.
137
+
138
+ ## Development
139
+
140
+ ```sh
141
+ bin/setup # gems into .bundle/, qdbd into .venv/; nothing installed globally
142
+ bundle exec rake test # unit + integration tests (qdbd checks skip if qdbd is missing;
143
+ # REQUIRE_QDBD=1 makes them fail instead, as in CI)
144
+ bundle exec rake golden # regenerate test/fixtures/apps/*/expected after an intended output change
145
+ bundle exec exe/rails2qdbd --help
146
+ ```
147
+
148
+ Tests are written first (Minitest). `test/fixtures/apps/shop` is a synthetic
149
+ app covering every supported feature; its golden output is validated with
150
+ `qdbd check --strict`, and qdbd's own parser is used to assert what qdbd
151
+ understood. CI ([workflow](.github/workflows/ci.yml)) runs Ruby 3.3 – 4.0 on
152
+ the locked dependencies and Ruby 3.2 on the oldest allowed ones
153
+ (`gemfiles/minimum.gemfile`).
154
+
155
+ Releases are published to RubyGems.org by the
156
+ [release workflow](.github/workflows/release.yml) when a `vX.Y.Z` tag matching
157
+ `Rails2qdbd::VERSION` is pushed.
158
+
159
+ ## License
160
+
161
+ MIT
data/exe/rails2qdbd ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rails2qdbd"
5
+
6
+ exit Rails2qdbd::CLI.new(ARGV).run
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails2qdbd
4
+ # The intermediate representation shared by every stage. It knows neither Rails
5
+ # nor qdbd syntax, so new sources (structure.sql, live reflection) and new writers
6
+ # can plug in without touching the rest.
7
+ module Catalog
8
+ # Struct with keyword arguments and per-member defaults (procs build fresh objects).
9
+ def self.record(*members, **defaults, &body)
10
+ Struct.new(*members, keyword_init: true) do
11
+ define_method(:initialize) do |**attrs|
12
+ unknown = attrs.keys - members
13
+ raise ArgumentError, "unknown keywords: #{unknown.join(', ')}" if unknown.any?
14
+
15
+ values = defaults.to_h { |key, value| [key, value.respond_to?(:call) ? value.call : value] }
16
+ super(**values, **attrs)
17
+ end
18
+ class_eval(&body) if body
19
+ end
20
+ end
21
+
22
+ Column = record(:name, :type, :null, :default, :limit, :precision, :scale, :array, :comment,
23
+ :generated, :enum_values, null: true, array: false)
24
+
25
+ Index = record(:columns, :expression, :unique, :where, :name, columns: -> { [] }, unique: false) do
26
+ # Only these can be expressed with qdbd's column keywords (UNIQUE / INDEX).
27
+ def single_column?
28
+ columns.size == 1 && expression.nil? && where.nil?
29
+ end
30
+ end
31
+
32
+ # checks: check-constraint expressions, kept as documentation.
33
+ Table = record(:name, :columns, :primary_key, :indexes, :checks, :comment, :path, :line,
34
+ columns: -> { [] }, primary_key: -> { [] }, indexes: -> { [] }, checks: -> { [] }) do
35
+ def column(name)
36
+ columns.find { |column| column.name == name }
37
+ end
38
+
39
+ def index_on(column_name)
40
+ indexes.find { |index| index.single_column? && index.columns.first == column_name }
41
+ end
42
+ end
43
+
44
+ ForeignKey = record(:from_table, :from_column, :to_table, :to_column)
45
+
46
+ Schema = record(:tables, :foreign_keys, :enums, :version,
47
+ tables: -> { {} }, foreign_keys: -> { [] }, enums: -> { {} })
48
+
49
+ Association = record(:macro, :name, :options, :path, :line, options: -> { {} })
50
+
51
+ # primary_key: a column name, or an Array for a composite key.
52
+ Model = record(:name, :superclass, :abstract, :table_name, :primary_key, :associations, :enums,
53
+ :attachments, :includes, :path, :line,
54
+ abstract: false, associations: -> { [] }, enums: -> { {} }, attachments: -> { [] },
55
+ includes: -> { [] })
56
+
57
+ # cardinality: :many_to_one or :one_to_one; source: :database or :model.
58
+ Relationship = record(:from_table, :from_column, :to_table, :to_column, :cardinality, :source,
59
+ cardinality: :many_to_one)
60
+
61
+ Diagnostic = record(:severity, :message, :path, :line, severity: :warning) do
62
+ def to_s
63
+ location = [path, line].compact.join(":")
64
+ location.empty? ? "#{severity}: #{message}" : "#{location}: #{severity}: #{message}"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Rails2qdbd
6
+ # `rails2qdbd [APP_ROOT] [options]`. Exit codes: 0 ok,
7
+ # 1 errors or out of date (--check), 2 usage error. Paths given here are relative to cwd.
8
+ class CLI
9
+ def initialize(argv, out: $stdout, err: $stderr, cwd: Dir.pwd)
10
+ @argv = argv.dup
11
+ @out = out
12
+ @err = err
13
+ @cwd = cwd
14
+ @overrides = {}
15
+ end
16
+
17
+ def run
18
+ parser = option_parser
19
+ args = parser.parse(@argv)
20
+ return 0 if @done
21
+
22
+ app_root = File.expand_path(args.first || ".", @cwd)
23
+ config = Config.load(app_root, path: @config_path, overrides: @overrides)
24
+ result = Converter.new(config).run
25
+ report(result.diagnostics)
26
+ return 1 if result.diagnostics.any? { |d| d.severity == :error }
27
+
28
+ deliver(result, config)
29
+ rescue OptionParser::ParseError => e
30
+ @err.puts "rails2qdbd: #{e.message}", parser.banner
31
+ 2
32
+ rescue Error => e
33
+ @err.puts "rails2qdbd: #{e.message}"
34
+ 1
35
+ end
36
+
37
+ private
38
+
39
+ def option_parser
40
+ OptionParser.new do |o|
41
+ o.banner = "Usage: rails2qdbd [APP_ROOT] [options]"
42
+ o.separator "Converts db/schema.rb and app/models into qdbd schema files (APP_ROOT defaults to .)."
43
+ o.on("-o", "--output PATH", "a .txt file or a directory (default: config, else stdout)") { |v| @overrides[:output] = path(v) }
44
+ o.on("--schema FILE", "schema file, repeatable (default: db/schema.rb)") { |v| (@overrides[:schemas] ||= []) << path(v) }
45
+ o.on("--models DIR", "models directory (default: app/models)") { |v| @overrides[:models] = path(v) }
46
+ o.on("--no-models", "schema only, no model associations") { @overrides[:models] = false }
47
+ o.on("-c", "--config FILE", "config file (default: APP_ROOT/.rails2qdbd.yml)") { |v| @config_path = path(v) }
48
+ o.on("--exclude GLOB", "leave out matching tables, repeatable") { |v| (@overrides[:exclude] ||= []) << v }
49
+ o.on("--check", "write nothing; exit 1 if the output is out of date") { @check = true }
50
+ o.on("-q", "--quiet", "no warnings or summary") { @quiet = true }
51
+ o.on("-v", "--version", "print the version") { finish("rails2qdbd #{VERSION}") }
52
+ o.on("-h", "--help", "print this help") { finish(o.help) }
53
+ end
54
+ end
55
+
56
+ def finish(text)
57
+ @out.puts text
58
+ @done = true
59
+ end
60
+
61
+ def path(value) = File.expand_path(value, @cwd)
62
+
63
+ def report(diagnostics)
64
+ diagnostics.each { |d| @err.puts d.to_s if d.severity == :error || !@quiet }
65
+ end
66
+
67
+ def deliver(result, config)
68
+ output = config.output && File.expand_path(config.output, config.app_root)
69
+ return check(result, output) if @check
70
+ unless output
71
+ @out.print(result.files.values.join("\n"))
72
+ return 0
73
+ end
74
+
75
+ written = Output.write(result, output)
76
+ unless @quiet
77
+ @out.puts "wrote #{written.size} file(s) to #{output}: #{result.tables} tables, " \
78
+ "#{result.relationships} relationship#{'s' unless result.relationships == 1}"
79
+ end
80
+ 0
81
+ end
82
+
83
+ def check(result, output)
84
+ unless output
85
+ @err.puts "rails2qdbd: --check needs an output path (-o, or output: in the config)"
86
+ return 2
87
+ end
88
+
89
+ problems = Output.stale(result, output)
90
+ problems.each { |problem| @err.puts "out of date: #{problem}" }
91
+ @out.puts "up to date" if problems.empty?
92
+ problems.empty? ? 0 : 1
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Rails2qdbd
6
+ # Settings from .rails2qdbd.yml (or --config), overridden by command-line options.
7
+ # Paths are relative to the app root.
8
+ class Config
9
+ FILE = ".rails2qdbd.yml"
10
+ DEFAULTS = {
11
+ output: nil,
12
+ schemas: ["db/schema.rb"],
13
+ models: "app/models",
14
+ inflections: "config/initializers/inflections.rb",
15
+ exclude: [],
16
+ default_group: "app",
17
+ groups: {},
18
+ }.freeze
19
+
20
+ attr_reader :app_root, :path, *DEFAULTS.keys
21
+
22
+ # overrides: same keys as the file; :exclude adds to the file's list instead of replacing it.
23
+ def self.load(app_root, path: nil, overrides: {})
24
+ if path
25
+ raise Error, "config file not found: #{path}" unless File.file?(File.expand_path(path, app_root))
26
+ elsif File.file?(File.join(app_root, FILE))
27
+ path = FILE
28
+ end
29
+ values = path ? read(File.expand_path(path, app_root), path) : {}
30
+ new(app_root, path, values, overrides)
31
+ end
32
+
33
+ def self.read(full_path, label)
34
+ data = YAML.safe_load_file(full_path) || {}
35
+ raise Error, "#{label} must be a mapping of settings" unless data.is_a?(Hash)
36
+
37
+ data.transform_keys(&:to_sym).tap do |values|
38
+ unknown = values.keys - DEFAULTS.keys
39
+ raise Error, "unknown config key in #{label}: #{unknown.join(', ')}" if unknown.any?
40
+ unless values[:groups].nil? || values[:groups].is_a?(Hash)
41
+ raise Error, "groups in #{label} must map group names to table globs"
42
+ end
43
+ end
44
+ rescue Psych::Exception => e
45
+ raise Error, "#{label}: invalid YAML (#{e.message})"
46
+ end
47
+
48
+ def initialize(app_root, path, values, overrides)
49
+ @app_root = app_root
50
+ @path = path
51
+ settings = DEFAULTS.merge(values.compact).merge(overrides.except(:exclude).compact)
52
+ @output = settings[:output]
53
+ @schemas = Array(settings[:schemas]).map(&:to_s)
54
+ @models = settings[:models] == false ? false : settings[:models].to_s
55
+ @inflections = settings[:inflections] == false ? false : settings[:inflections].to_s
56
+ @exclude = (Array(settings[:exclude]) + Array(overrides[:exclude])).map(&:to_s)
57
+ @default_group = settings[:default_group].to_s
58
+ @groups = settings[:groups].to_h { |name, globs| [name.to_s, Array(globs).map(&:to_s)] }
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Rails2qdbd
6
+ # Runs the whole pipeline for one app: inflections -> schema -> models -> resolver -> writer.
7
+ class Converter
8
+ # files: { "name.txt" => text }; tables/relationships: counts for the CLI summary.
9
+ Result = Struct.new(:files, :diagnostics, :tables, :relationships, keyword_init: true)
10
+
11
+ SINGLE_FILE = "schema.txt"
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ @root = config.app_root
16
+ @grouping = Grouping.new(config)
17
+ end
18
+
19
+ def run
20
+ @diagnostics = []
21
+ inflector = load_inflector
22
+ parser = SchemaParser.new(inflector: inflector)
23
+ schemas = @config.schemas.map { |path| relative(path) }
24
+ schema = parser.parse_files(schemas, root: @root)
25
+ @diagnostics.concat(parser.diagnostics)
26
+ models = scan_models
27
+ resolver = Resolver.new(schema, models || {}, inflector: inflector, excluded: ->(table) { @grouping.excluded?(table) })
28
+ resolution = resolver.resolve
29
+ @diagnostics.concat(resolution.diagnostics)
30
+
31
+ tables = schema.tables.values.reject { |table| @grouping.excluded?(table.name) }.sort_by(&:name)
32
+ inputs = schemas + (models ? [relative(@config.models)] : [])
33
+ Result.new(files: render(tables, resolution, inputs), diagnostics: @diagnostics, tables: tables.size,
34
+ relationships: resolution.relationships.size)
35
+ end
36
+
37
+ private
38
+
39
+ def load_inflector
40
+ inflector = Inflector.new
41
+ path = @config.inflections
42
+ if path && File.file?(full = File.expand_path(path, @root))
43
+ @diagnostics.concat(inflector.apply_file(File.read(full), path: path))
44
+ end
45
+ inflector
46
+ end
47
+
48
+ # nil when model scanning is off or there is no models directory.
49
+ def scan_models
50
+ return nil unless @config.models
51
+
52
+ dir = File.expand_path(@config.models, @root)
53
+ unless File.directory?(dir)
54
+ @diagnostics << Catalog::Diagnostic.new(
55
+ message: "models directory not found: #{@config.models} (relationships come from the schema only)",
56
+ )
57
+ return nil
58
+ end
59
+
60
+ sources = Dir.glob("**/*.rb", base: dir).sort.to_h do |file|
61
+ [File.join(relative(@config.models), file), File.read(File.join(dir, file))]
62
+ end
63
+ scanner = ModelScanner.new
64
+ scanner.scan(sources: sources).tap { @diagnostics.concat(scanner.diagnostics) }
65
+ end
66
+
67
+ # Paths inside the app are shown relative to it, so output does not depend on the cwd.
68
+ def relative(path)
69
+ full = Pathname(File.expand_path(path, @root))
70
+ inside = full.relative_path_from(Pathname(File.expand_path(@root))).to_s
71
+ inside.start_with?("..") ? path : inside
72
+ end
73
+
74
+ def render(tables, resolution, inputs)
75
+ writer = Writer.new
76
+ by_file = tables.group_by { |table| file_for(table) }
77
+ # No tables still gives a headed file, so the output exists and --check has something to compare.
78
+ by_file = { SINGLE_FILE => [] } if by_file.empty?
79
+ by_file.sort.to_h do |file, file_tables|
80
+ texts = file_tables.map do |table|
81
+ writer.render_table(table, relationships: resolution.relationships,
82
+ table_notes: resolution.table_notes.fetch(table.name, []),
83
+ column_notes: resolution.column_notes.fetch(table.name, {}))
84
+ end
85
+ [file, writer.render_file(tables_text: texts, inputs: inputs)]
86
+ end
87
+ end
88
+
89
+ # One file per group only when groups are configured and the output is a directory.
90
+ def file_for(table)
91
+ output = @config.output
92
+ split = @config.groups.any? && output && !output.end_with?(".txt")
93
+ split ? "#{@grouping.group_for(table.name)}.txt" : SINGLE_FILE
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails2qdbd
4
+ # Assigns tables to output files (qdbd shows each file as a group) and applies excludes.
5
+ class Grouping
6
+ GLOB_FLAGS = File::FNM_EXTGLOB
7
+
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+
12
+ # The first group with a matching glob, else the default group.
13
+ def group_for(table)
14
+ @config.groups.find { |_, globs| matches?(globs, table) }&.first || @config.default_group
15
+ end
16
+
17
+ def excluded?(table) = matches?(@config.exclude, table)
18
+
19
+ private
20
+
21
+ def matches?(globs, table) = globs.any? { |glob| File.fnmatch?(glob, table, GLOB_FLAGS) }
22
+ end
23
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "active_support/inflector"
5
+
6
+ module Rails2qdbd
7
+ # ActiveSupport's inflector over a private copy of the English rules, so a project's own
8
+ # inflections apply to this conversion only and the global inflector is never mutated.
9
+ class Inflector
10
+ include ActiveSupport::Inflector
11
+
12
+ RULES = %i[irregular plural singular uncountable acronym].freeze
13
+
14
+ def initialize
15
+ english = ActiveSupport::Inflector.inflections(:en)
16
+ @inflections = english.dup
17
+ # Inflections#dup shares the uncountables' word list with the original; rebuild it so
18
+ # `uncountable` here cannot leak into the global inflector.
19
+ @inflections.clear(:uncountables)
20
+ @inflections.uncountable(english.uncountables.to_a)
21
+ end
22
+
23
+ # Every ActiveSupport::Inflector method looks its rules up through here.
24
+ def inflections(_locale = :en)
25
+ block_given? ? yield(@inflections) : @inflections
26
+ end
27
+
28
+ # Rails table names come from the demodulized class name (Admin::User -> users).
29
+ def tableize(class_name) = pluralize(underscore(demodulize(class_name.to_s)))
30
+
31
+ # Applies the literal inflection rules of config/initializers/inflections.rb without
32
+ # executing it. Returns warnings for everything it skipped.
33
+ def apply_file(source, path:)
34
+ diagnostics = []
35
+ result = Prism.parse(source)
36
+ return [Catalog::Diagnostic.new(severity: :warning, message: "syntax error, inflections ignored", path: path)] if result.failure?
37
+
38
+ each_call(result.value) do |call|
39
+ next unless call.receiver.is_a?(Prism::LocalVariableReadNode)
40
+
41
+ message = apply_rule(call)
42
+ diagnostics << Catalog::Diagnostic.new(message: message, path: path, line: call.location.start_line) if message
43
+ end
44
+ diagnostics
45
+ end
46
+
47
+ private
48
+
49
+ # nil when applied, else the reason it was skipped.
50
+ def apply_rule(call)
51
+ return "inflection `#{call.name}` ignored" unless RULES.include?(call.name)
52
+
53
+ args = (call.arguments&.arguments || []).map { |node| Literal.value(node) }
54
+ return "inflection `#{call.name}` with non-literal arguments ignored" unless args.all? { |arg| Literal.known?(arg) }
55
+
56
+ @inflections.public_send(call.name, *args)
57
+ nil
58
+ end
59
+
60
+ def each_call(node, &block)
61
+ yield node if node.is_a?(Prism::CallNode)
62
+ node.compact_child_nodes.each { |child| each_call(child, &block) }
63
+ end
64
+ end
65
+ end