canvas_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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/README.md +86 -0
- data/exe/canvas_erd +9 -0
- data/lib/canvas_erd/application_loader.rb +37 -0
- data/lib/canvas_erd/browser.rb +40 -0
- data/lib/canvas_erd/cli.rb +125 -0
- data/lib/canvas_erd/diagram.rb +130 -0
- data/lib/canvas_erd/diagram_store.rb +70 -0
- data/lib/canvas_erd/png_document.rb +80 -0
- data/lib/canvas_erd/schema_provider.rb +15 -0
- data/lib/canvas_erd/server.rb +210 -0
- data/lib/canvas_erd/version.rb +6 -0
- data/lib/canvas_erd/web/app.js +1394 -0
- data/lib/canvas_erd/web/document.js +473 -0
- data/lib/canvas_erd/web/index.html +119 -0
- data/lib/canvas_erd/web/layout.js +74 -0
- data/lib/canvas_erd/web/png.js +146 -0
- data/lib/canvas_erd/web/styles.css +439 -0
- data/lib/canvas_erd/web/vendor/elk-api.js +227 -0
- data/lib/canvas_erd/web/vendor/elk-worker.min.js +6317 -0
- data/lib/canvas_erd/web/vendor/fabric.min.js +473 -0
- data/lib/canvas_erd.rb +14 -0
- data/licenses/ELKJS-LICENSE.md +263 -0
- data/licenses/FABRIC-JS-LICENSE.txt +22 -0
- data/licenses/ICONOIR-LICENSE +21 -0
- metadata +155 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5b593a4a8e3ebb6c820c3469cc0063acb4fdf129313e2ea2c68da33a15166be2
|
|
4
|
+
data.tar.gz: da8d50366c574f574289cf59046e98954fdf324ceff1f20d3ba2811349a5b35e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8262355aa351a042ea353f9d1340aee15d30e839f29bcdea12c3d3d728070d176deb0cb845db2b43e7c1f0cb05cd8321a8480e00286e665379b1b368640c0f31
|
|
7
|
+
data.tar.gz: a4db10469eaf6ee4f1d95afbcc7f02f826c37f0234187d02d925c79d73f4c65f86c9fe5dbb8e08d6e46929189c6e53d5695f81f05e185d2a01c73349e88b1776
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-08-30
|
|
4
|
+
|
|
5
|
+
- Generate a JSON-safe diagram schema from Rails ERD domain models.
|
|
6
|
+
- Start a loopback-only editor using the Rails application's Rack server.
|
|
7
|
+
- Arrange and select tables on a Fabric.js canvas, with editable notes, arrows, zoom, and pan controls.
|
|
8
|
+
- Refresh columns and relationships while preserving diagram edits.
|
|
9
|
+
- Save editable diagrams inside ordinary PNG files and reopen them from the CLI.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marc Heiligers, FASCINATION•works
|
|
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,86 @@
|
|
|
1
|
+
# CanvasERD
|
|
2
|
+
|
|
3
|
+
CanvasERD is a companion gem for editing diagrams generated from Rails ERD domain models in a local browser.
|
|
4
|
+
|
|
5
|
+
The editor renders Rails models and relationships on an interactive Fabric.js canvas.
|
|
6
|
+
New diagrams use ELK's layered graph layout to arrange tables from their Rails relationships.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Add CanvasERD to the development group in the Rails application's `Gemfile`:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
group :development do
|
|
14
|
+
gem "canvas_erd", require: false
|
|
15
|
+
end
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Then run `bundle install`. CanvasERD requires Ruby 3.1 or newer, Active Record 7.0 or newer, and a Rack server available in the Rails bundle.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
From a Rails application root, run:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
bundle exec canvas_erd
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
CanvasERD loads and eager-loads the Rails application, generates the schema, starts on a loopback-only address, and opens the editor in the default browser. Pass `--no-open` to print the URL without opening it.
|
|
29
|
+
|
|
30
|
+
CanvasERD uses Rackup with a server handler already present in the Rails bundle, normally Puma. It does not install a separate web server.
|
|
31
|
+
|
|
32
|
+
Diagrams are saved by the editor as `*.erd.png` files under `docs/erd/` in the Rails application. Use a different directory inside the application with `--diagrams-dir`, for example:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
bundle exec canvas_erd --diagrams-dir db/diagrams
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The editor's saved-diagram menu loads these files directly. You can also open a diagram when starting CanvasERD with `bundle exec canvas_erd path/to/domain.erd.png`.
|
|
39
|
+
|
|
40
|
+
## Editor controls
|
|
41
|
+
|
|
42
|
+
- Drag tables to reposition them.
|
|
43
|
+
- Use the table sidebar to add or remove tables without losing their positions.
|
|
44
|
+
- Use **Layout tables** to rearrange the tables currently on the canvas with ELK without moving notes or hidden tables.
|
|
45
|
+
- Pinch over the canvas or use the toolbar buttons to zoom.
|
|
46
|
+
- Use a two-finger trackpad gesture to pan. Space-drag remains available as a keyboard fallback, and **Fit diagram** resets the view.
|
|
47
|
+
- Add editable notes with **Add note**. Select a note and press Delete to remove it.
|
|
48
|
+
- Use **Refresh schema** after a database migration to update columns and relationships. Existing table positions, selections, notes, and the current view are preserved; new tables start unselected.
|
|
49
|
+
- Edit the diagram name, then use **Save** (or <kbd>⌘S</kbd>/<kbd>Ctrl-S</kbd>) to write the complete diagram into the Rails application as a normal `*.erd.png` image with its editable schema, layout, notes, selection, and viewport embedded inside it. Select a saved diagram from the menu to reopen it, or use **New** to start an “Untitled ERD.”
|
|
50
|
+
|
|
51
|
+
CanvasERD refreshes the model classes already loaded by the server. Restart CanvasERD after adding or renaming model classes or changing model code.
|
|
52
|
+
Image optimization tools may remove the CanvasERD metadata required to edit a diagram again.
|
|
53
|
+
|
|
54
|
+
The editor binds only to `127.0.0.1` and does not expose the Rails application over the network.
|
|
55
|
+
|
|
56
|
+
## Domain adapter
|
|
57
|
+
|
|
58
|
+
After loading the Rails application, generate a JSON-safe schema hash with:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
schema = CanvasERD::Diagram.create
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The schema contains entities, attributes, relationships, and specializations with stable identifiers. Relationship cardinality ranges use `nil` as an unbounded maximum.
|
|
65
|
+
|
|
66
|
+
## Development
|
|
67
|
+
|
|
68
|
+
Run the tests:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
bundle exec rake test
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Build the gem:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
bundle exec rake build
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
CanvasERD is available under the MIT License. Dependencies remain subject to their included licenses.
|
|
83
|
+
|
|
84
|
+
[Fabric.js](https://fabricjs.com/) 7.4.0 is vendored under `lib/canvas_erd/web/vendor`. Its license is included in `licenses/FABRIC-JS-LICENSE.txt`.
|
|
85
|
+
[elkjs](https://github.com/kieler/elkjs) 0.12.0 is vendored there as a browser API and Web Worker. Its [source is available from the elkjs project](https://github.com/kieler/elkjs/tree/0.12.0), and its license is included in `licenses/ELKJS-LICENSE.md`.
|
|
86
|
+
Icons from [iconoir](https://iconoir.com/) directly embedded in the HTML, and it's license is included in `licenses/ICONOIR-LICENSE`.
|
data/exe/canvas_erd
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CanvasERD
|
|
4
|
+
class ApplicationLoader
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
attr_reader :root
|
|
8
|
+
|
|
9
|
+
def initialize(root = Dir.pwd)
|
|
10
|
+
@root = File.expand_path(root)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def load
|
|
14
|
+
environment_path = File.join(root, "config", "environment.rb")
|
|
15
|
+
unless File.file?(environment_path)
|
|
16
|
+
raise Error, "Rails application environment not found at #{environment_path}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
require environment_path
|
|
20
|
+
eager_load_rails_application
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def eager_load_rails_application
|
|
27
|
+
return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
28
|
+
|
|
29
|
+
Rails.application.eager_load!
|
|
30
|
+
|
|
31
|
+
config = Rails.application.config
|
|
32
|
+
return unless config.respond_to?(:eager_load_namespaces)
|
|
33
|
+
|
|
34
|
+
config.eager_load_namespaces.each(&:eager_load!)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module CanvasERD
|
|
6
|
+
class Browser
|
|
7
|
+
def self.open(url)
|
|
8
|
+
new.open(url)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(host_os: RbConfig::CONFIG.fetch("host_os"), process_spawn: Process.method(:spawn))
|
|
12
|
+
@host_os = host_os
|
|
13
|
+
@process_spawn = process_spawn
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def open(url)
|
|
17
|
+
command = command_for(url)
|
|
18
|
+
return false unless command
|
|
19
|
+
|
|
20
|
+
process_id = @process_spawn.call(*command, out: File::NULL, err: File::NULL)
|
|
21
|
+
Process.detach(process_id) if process_id.is_a?(Integer)
|
|
22
|
+
true
|
|
23
|
+
rescue SystemCallError
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def command_for(url)
|
|
30
|
+
case @host_os
|
|
31
|
+
when /darwin/
|
|
32
|
+
["open", url]
|
|
33
|
+
when /linux|bsd/
|
|
34
|
+
["xdg-open", url]
|
|
35
|
+
when /mswin|mingw|cygwin/
|
|
36
|
+
["cmd", "/c", "start", "", url]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module CanvasERD
|
|
6
|
+
class CLI
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
Options = Struct.new(:action, :diagram_path, :diagrams_directory, :open_browser, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
def self.start(arguments = ARGV, out: $stdout, err: $stderr)
|
|
12
|
+
new(out: out, err: err).start(arguments)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(out: $stdout, err: $stderr, root: Dir.pwd)
|
|
16
|
+
@out = out
|
|
17
|
+
@err = err
|
|
18
|
+
@root = root
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def start(arguments)
|
|
22
|
+
options = parse(arguments)
|
|
23
|
+
|
|
24
|
+
case options.action
|
|
25
|
+
when :help
|
|
26
|
+
@out.puts parser
|
|
27
|
+
0
|
|
28
|
+
when :version
|
|
29
|
+
@out.puts "CanvasERD #{CanvasERD::VERSION}"
|
|
30
|
+
0
|
|
31
|
+
else
|
|
32
|
+
run(options)
|
|
33
|
+
end
|
|
34
|
+
rescue OptionParser::ParseError => error
|
|
35
|
+
@err.puts "Error: #{error.message}"
|
|
36
|
+
@err.puts parser
|
|
37
|
+
1
|
|
38
|
+
rescue Error, ApplicationLoader::Error, DiagramStore::Error, PngDocument::Error, Server::Error => error
|
|
39
|
+
@err.puts "Error: #{error.message}"
|
|
40
|
+
1
|
|
41
|
+
rescue StandardError => error
|
|
42
|
+
@err.puts "Failed: #{error.class}: #{error.message}"
|
|
43
|
+
1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse(arguments)
|
|
47
|
+
action = :run
|
|
48
|
+
diagrams_directory = DiagramStore::DEFAULT_DIRECTORY
|
|
49
|
+
open_browser = true
|
|
50
|
+
remaining = parser(
|
|
51
|
+
action_setter: ->(value) { action = value },
|
|
52
|
+
directory_setter: ->(value) { diagrams_directory = value },
|
|
53
|
+
open_setter: ->(value) { open_browser = value }
|
|
54
|
+
).parse(arguments.dup)
|
|
55
|
+
|
|
56
|
+
raise OptionParser::ParseError, "expected at most one diagram path" if remaining.length > 1
|
|
57
|
+
|
|
58
|
+
Options.new(
|
|
59
|
+
action: action,
|
|
60
|
+
diagram_path: remaining.first,
|
|
61
|
+
diagrams_directory: diagrams_directory,
|
|
62
|
+
open_browser: open_browser
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def run(options)
|
|
69
|
+
if options.diagram_path
|
|
70
|
+
@err.puts "Loading CanvasERD diagram from #{options.diagram_path}..."
|
|
71
|
+
document = PngDocument.load(File.expand_path(options.diagram_path, @root))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@err.puts "Loading Rails application from #{@root}..."
|
|
75
|
+
ApplicationLoader.new(@root).load
|
|
76
|
+
|
|
77
|
+
schema_provider = SchemaProvider.new
|
|
78
|
+
if document
|
|
79
|
+
schema = document.fetch("schema")
|
|
80
|
+
state = document.fetch("state")
|
|
81
|
+
else
|
|
82
|
+
@err.puts "Generating Rails ERD schema..."
|
|
83
|
+
schema = schema_provider.call
|
|
84
|
+
end
|
|
85
|
+
diagram_store = DiagramStore.new(root: @root, directory: options.diagrams_directory)
|
|
86
|
+
app = WebApplication.new(
|
|
87
|
+
schema: schema,
|
|
88
|
+
schema_provider: schema_provider,
|
|
89
|
+
state: state,
|
|
90
|
+
diagram_store: diagram_store
|
|
91
|
+
)
|
|
92
|
+
server = Server.new(app: app)
|
|
93
|
+
|
|
94
|
+
@out.puts "CanvasERD is running at #{server.url}"
|
|
95
|
+
unless !options.open_browser || Browser.open(server.url)
|
|
96
|
+
@err.puts "Could not open a browser. Visit #{server.url} manually."
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
server.start
|
|
100
|
+
0
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parser(action_setter: ->(_value) {}, directory_setter: ->(_value) {}, open_setter: ->(_value) {})
|
|
104
|
+
OptionParser.new do |options|
|
|
105
|
+
options.banner = "Usage: canvas_erd [options] [diagram.png]"
|
|
106
|
+
|
|
107
|
+
options.on("--no-open", "Do not open the editor in a browser") do
|
|
108
|
+
open_setter.call(false)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
options.on("--diagrams-dir DIRECTORY", "Store diagrams here (default: docs/erd)") do |directory|
|
|
112
|
+
directory_setter.call(directory)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
options.on("-v", "--version", "Print the CanvasERD version") do
|
|
116
|
+
action_setter.call(:version)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
options.on("-h", "--help", "Show this help") do
|
|
120
|
+
action_setter.call(:help)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails_erd/diagram"
|
|
4
|
+
|
|
5
|
+
module CanvasERD
|
|
6
|
+
class Diagram < RailsERD::Diagram
|
|
7
|
+
setup do
|
|
8
|
+
@entity_ids = {}
|
|
9
|
+
@schema = {
|
|
10
|
+
"name" => domain.name,
|
|
11
|
+
"entities" => [],
|
|
12
|
+
"relationships" => [],
|
|
13
|
+
"specializations" => []
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
each_entity do |entity, attributes|
|
|
18
|
+
@entity_ids[entity.name.to_s] = true
|
|
19
|
+
@schema["entities"] << entity_data(entity, attributes)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
each_specialization do |specialization|
|
|
23
|
+
next unless included_entity?(specialization.generalized) && included_entity?(specialization.specialized)
|
|
24
|
+
|
|
25
|
+
@schema["specializations"] << specialization_data(specialization)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
each_relationship do |relationship|
|
|
29
|
+
next unless included_entity?(relationship.source) && included_entity?(relationship.destination)
|
|
30
|
+
|
|
31
|
+
@schema["relationships"] << relationship_data(relationship)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
save do
|
|
35
|
+
@schema["entities"].sort_by! { |entity| entity.fetch("id") }
|
|
36
|
+
@schema["relationships"].sort_by! { |relationship| relationship.fetch("id") }
|
|
37
|
+
@schema["specializations"].sort_by! { |specialization| specialization.fetch("id") }
|
|
38
|
+
@schema
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def entity_data(entity, attributes)
|
|
44
|
+
{
|
|
45
|
+
"id" => entity.name.to_s,
|
|
46
|
+
"name" => entity.name.to_s,
|
|
47
|
+
"label" => entity_label(entity),
|
|
48
|
+
"table_name" => entity.model&.table_name,
|
|
49
|
+
"namespace" => entity.namespace,
|
|
50
|
+
"virtual" => entity.virtual?,
|
|
51
|
+
"generalized" => entity.generalized?,
|
|
52
|
+
"specialized" => entity.specialized?,
|
|
53
|
+
"attributes" => attributes.map { |attribute| attribute_data(entity, attribute) }
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def entity_label(entity)
|
|
58
|
+
if options[:table_names] && entity.model
|
|
59
|
+
entity.model.table_name
|
|
60
|
+
else
|
|
61
|
+
entity.name.to_s
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def attribute_data(entity, attribute)
|
|
66
|
+
{
|
|
67
|
+
"id" => "#{entity.name}.#{attribute.name}",
|
|
68
|
+
"name" => attribute.name.to_s,
|
|
69
|
+
"type" => attribute.type.to_s,
|
|
70
|
+
"type_description" => attribute.type_description,
|
|
71
|
+
"primary_key" => attribute.primary_key?,
|
|
72
|
+
"foreign_key" => attribute.foreign_key?,
|
|
73
|
+
"mandatory" => attribute.mandatory?,
|
|
74
|
+
"unique" => attribute.unique?,
|
|
75
|
+
"timestamp" => attribute.timestamp?,
|
|
76
|
+
"inheritance" => attribute.inheritance?
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def relationship_data(relationship)
|
|
81
|
+
source_id = relationship.source.name.to_s
|
|
82
|
+
destination_id = relationship.destination.name.to_s
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
"id" => endpoint_id("relationship", source_id, destination_id),
|
|
86
|
+
"source_id" => source_id,
|
|
87
|
+
"destination_id" => destination_id,
|
|
88
|
+
"cardinality" => cardinality_data(relationship.cardinality),
|
|
89
|
+
"indirect" => relationship.indirect?,
|
|
90
|
+
"mutual" => relationship.mutual?,
|
|
91
|
+
"recursive" => relationship.recursive?
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def cardinality_data(cardinality)
|
|
96
|
+
{
|
|
97
|
+
"name" => cardinality.name.to_s,
|
|
98
|
+
"source" => range_data(cardinality.source_range),
|
|
99
|
+
"destination" => range_data(cardinality.destination_range)
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def range_data(range)
|
|
104
|
+
{
|
|
105
|
+
"minimum" => range.begin,
|
|
106
|
+
"maximum" => range.end.finite? ? range.end : nil
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def specialization_data(specialization)
|
|
111
|
+
generalized_id = specialization.generalized.name.to_s
|
|
112
|
+
specialized_id = specialization.specialized.name.to_s
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
"id" => endpoint_id("specialization", generalized_id, specialized_id),
|
|
116
|
+
"generalized_id" => generalized_id,
|
|
117
|
+
"specialized_id" => specialized_id,
|
|
118
|
+
"type" => specialization.inheritance? ? "inheritance" : "polymorphic"
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def included_entity?(entity)
|
|
123
|
+
entity && @entity_ids.key?(entity.name.to_s)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def endpoint_id(kind, first_id, second_id)
|
|
127
|
+
"#{kind}:#{[first_id, second_id].sort.join('|')}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module CanvasERD
|
|
7
|
+
class DiagramStore
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
class NotFound < Error; end
|
|
10
|
+
|
|
11
|
+
DEFAULT_DIRECTORY = "docs/erd"
|
|
12
|
+
FILENAME_PATTERN = /\A[^\x00\/\\]+\.erd\.png\z/i
|
|
13
|
+
|
|
14
|
+
attr_reader :directory
|
|
15
|
+
|
|
16
|
+
def initialize(root:, directory: DEFAULT_DIRECTORY)
|
|
17
|
+
@root = File.expand_path(root)
|
|
18
|
+
@directory = File.expand_path(directory, @root)
|
|
19
|
+
return if @directory.start_with?("#{@root}#{File::SEPARATOR}")
|
|
20
|
+
|
|
21
|
+
raise Error, "Diagram directory must be inside the Rails application"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def relative_directory
|
|
25
|
+
directory.delete_prefix("#{@root}#{File::SEPARATOR}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def names
|
|
29
|
+
return [] unless Dir.exist?(directory)
|
|
30
|
+
|
|
31
|
+
Dir.children(directory).select { |name| valid_filename?(name) && File.file?(path_for(name)) }.sort
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def read(name)
|
|
35
|
+
bytes = File.binread(path_for(name))
|
|
36
|
+
PngDocument.read(StringIO.new(bytes))
|
|
37
|
+
bytes
|
|
38
|
+
rescue Errno::ENOENT
|
|
39
|
+
raise NotFound, "Diagram not found: #{name}"
|
|
40
|
+
rescue SystemCallError => error
|
|
41
|
+
raise Error, "Could not read diagram #{name}: #{error.message}"
|
|
42
|
+
rescue PngDocument::Error => error
|
|
43
|
+
raise Error, error.message
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def write(name, bytes)
|
|
47
|
+
path = path_for(name)
|
|
48
|
+
PngDocument.read(StringIO.new(bytes))
|
|
49
|
+
FileUtils.mkdir_p(directory)
|
|
50
|
+
File.binwrite(path, bytes)
|
|
51
|
+
name
|
|
52
|
+
rescue SystemCallError => error
|
|
53
|
+
raise Error, "Could not save diagram #{name}: #{error.message}"
|
|
54
|
+
rescue PngDocument::Error => error
|
|
55
|
+
raise Error, error.message
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def path_for(name)
|
|
61
|
+
raise Error, "Diagram filename must end in .erd.png" unless valid_filename?(name)
|
|
62
|
+
|
|
63
|
+
File.join(directory, name)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def valid_filename?(name)
|
|
67
|
+
name.is_a?(String) && FILENAME_PATTERN.match?(name)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
module CanvasERD
|
|
7
|
+
class PngDocument
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
SIGNATURE = "\x89PNG\r\n\x1a\n".b
|
|
11
|
+
KEYWORD = "CanvasERD"
|
|
12
|
+
ITXT_PREFIX = "#{KEYWORD}\0\0\0\0\0".b
|
|
13
|
+
MAX_CHUNK_SIZE = 64 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
def self.load(path)
|
|
16
|
+
File.open(path, "rb") { |file| read(file) }
|
|
17
|
+
rescue SystemCallError => error
|
|
18
|
+
raise Error, "Could not read diagram #{path}: #{error.message}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.read(input)
|
|
22
|
+
raise Error, "Not a PNG file" unless input.read(SIGNATURE.bytesize) == SIGNATURE
|
|
23
|
+
|
|
24
|
+
document_json = nil
|
|
25
|
+
found_end = false
|
|
26
|
+
|
|
27
|
+
until found_end
|
|
28
|
+
length_bytes = input.read(4)
|
|
29
|
+
raise Error, "PNG ended before its IEND chunk" unless length_bytes&.bytesize == 4
|
|
30
|
+
|
|
31
|
+
length = length_bytes.unpack1("N")
|
|
32
|
+
raise Error, "PNG chunk is too large" if length > MAX_CHUNK_SIZE
|
|
33
|
+
|
|
34
|
+
type = read_exact(input, 4)
|
|
35
|
+
data = read_exact(input, length)
|
|
36
|
+
checksum = read_exact(input, 4).unpack1("N")
|
|
37
|
+
raise Error, "PNG chunk checksum is invalid" unless Zlib.crc32(type + data) == checksum
|
|
38
|
+
|
|
39
|
+
if type == "iTXt" && data.start_with?(ITXT_PREFIX)
|
|
40
|
+
document_json = data.delete_prefix(ITXT_PREFIX).force_encoding(Encoding::UTF_8)
|
|
41
|
+
end
|
|
42
|
+
found_end = type == "IEND"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
raise Error, "PNG does not contain an editable CanvasERD diagram" unless document_json
|
|
46
|
+
|
|
47
|
+
validate(JSON.parse(document_json))
|
|
48
|
+
rescue JSON::ParserError => error
|
|
49
|
+
raise Error, "CanvasERD diagram metadata is invalid: #{error.message}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.read_exact(input, length)
|
|
53
|
+
value = input.read(length)
|
|
54
|
+
raise Error, "PNG chunk is truncated" unless value&.bytesize == length
|
|
55
|
+
|
|
56
|
+
value
|
|
57
|
+
end
|
|
58
|
+
private_class_method :read_exact
|
|
59
|
+
|
|
60
|
+
def self.validate(document)
|
|
61
|
+
schema = document["schema"] if document.is_a?(Hash)
|
|
62
|
+
state = document["state"] if document.is_a?(Hash)
|
|
63
|
+
valid = document.is_a?(Hash) &&
|
|
64
|
+
document["format"] == "canvas_erd" &&
|
|
65
|
+
document["version"] == 1 &&
|
|
66
|
+
schema.is_a?(Hash) &&
|
|
67
|
+
schema["entities"].is_a?(Array) &&
|
|
68
|
+
schema["relationships"].is_a?(Array) &&
|
|
69
|
+
schema["specializations"].is_a?(Array) &&
|
|
70
|
+
state.is_a?(Hash) &&
|
|
71
|
+
state["includedEntityIds"].is_a?(Array) &&
|
|
72
|
+
state["positions"].is_a?(Hash) &&
|
|
73
|
+
state["notes"].is_a?(Array)
|
|
74
|
+
raise Error, "CanvasERD diagram format is not supported" unless valid
|
|
75
|
+
|
|
76
|
+
document
|
|
77
|
+
end
|
|
78
|
+
private_class_method :validate
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CanvasERD
|
|
4
|
+
class SchemaProvider
|
|
5
|
+
def initialize(models: -> { ActiveRecord::Base.descendants }, diagram: -> { CanvasERD::Diagram.create })
|
|
6
|
+
@models = models
|
|
7
|
+
@diagram = diagram
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
@models.call.each(&:reset_column_information)
|
|
12
|
+
@diagram.call
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|