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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +161 -0
- data/exe/rails2qdbd +6 -0
- data/lib/rails2qdbd/catalog.rb +68 -0
- data/lib/rails2qdbd/cli.rb +95 -0
- data/lib/rails2qdbd/config.rb +61 -0
- data/lib/rails2qdbd/converter.rb +96 -0
- data/lib/rails2qdbd/grouping.rb +23 -0
- data/lib/rails2qdbd/inflector.rb +65 -0
- data/lib/rails2qdbd/literal.rb +80 -0
- data/lib/rails2qdbd/model_scanner.rb +254 -0
- data/lib/rails2qdbd/output.rb +59 -0
- data/lib/rails2qdbd/resolver.rb +266 -0
- data/lib/rails2qdbd/schema_parser.rb +240 -0
- data/lib/rails2qdbd/version.rb +5 -0
- data/lib/rails2qdbd/writer.rb +115 -0
- data/lib/rails2qdbd.rb +26 -0
- metadata +105 -0
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,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
|