pferd 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4cfbec7f22022f6fde8257a419ae16a322a33b1a5a3b808066615c9e5a36c49a
4
+ data.tar.gz: 725ae61787aa419a0e97f5fa3b82e285cf116b440884f94fbcd72300c70a3d01
5
+ SHA512:
6
+ metadata.gz: 0b39ddbc987ef910722df3b0514b3a66a1402c0e9cba2cd516a74ad4992b55cdcfe9a89868fa1a42b5453cc3fed97aaf408e547554ddfc546fd2ee1f87482094
7
+ data.tar.gz: 945a120e05fa4107328ea6bdc1d5629b6e975d3a1fe387b190535ca7e8794e9e1c7d00ffbcf28b05641bf4311df49131896c324060d40be952decd76c74d1a54
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,8 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.0
3
+
4
+ Style/StringLiterals:
5
+ EnforcedStyle: double_quotes
6
+
7
+ Style/StringLiteralsInInterpolation:
8
+ EnforcedStyle: double_quotes
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-02-03
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Bodacious
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # Pferd
2
+
3
+ Pferd is a Ruby gem that generates an **Entity Relationship Diagram (ERD)** of your Ruby on Rails models, automatically **grouping models by their domain**. This is useful when you are in the process of modularising your codebase, and want to visualise various domain configurations without having to move code around.
4
+
5
+ The diagram (optionally) highlights **domain boundary violations**, giving you insights into possible cross-domain coupling issues.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'pferd'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ Or install it directly using:
22
+
23
+ ```bash
24
+ gem install pferd
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ To generate an ERD, simply run:
30
+
31
+ ```bash
32
+ rake pferd:generate
33
+ ```
34
+
35
+ ## Defining Domains
36
+
37
+ Pferd groups your models by domain using the `@domain` YARD tag. To specify the domain of a model, add a YARD comment above the model class definition. For example:
38
+
39
+ ```ruby
40
+ # @domain payments
41
+ class Transaction < ApplicationRecord
42
+ # Model code
43
+ end
44
+ ```
45
+
46
+ If a model does not have a `@domain` tag, it will be placed in the default domain (`Global` by default).
47
+
48
+ ## Configuration
49
+
50
+ Pferd comes with configurable options that you can customise. Below is the default configuration:
51
+
52
+ ```ruby
53
+ # Establish some default configs
54
+ Pferd.configure do |config|
55
+ # Classes without an explicit domain tag should be in this domain
56
+ config.default_domain_name = 'Global'
57
+
58
+ # Exclude these classes
59
+ config.ignored_classes = []
60
+
61
+ # Exclude classes nested in these modules
62
+ config.ignored_modules = ['ActiveStorage']
63
+
64
+ # Load models matching these glob-paths
65
+ config.model_dirs = ['app/models']
66
+
67
+ # The name of the generated output file
68
+ config.output_file_name = 'pferd.png'
69
+ end
70
+ ```
71
+
72
+ ### **How to customise:**
73
+ You can modify the configuration within an initializer (`config/initializers/pferd.rb`):
74
+
75
+ ```ruby
76
+ Pferd.configure do |config|
77
+ config.default_domain_name = 'Core'
78
+ config.ignored_classes = ['SomeTemporaryModel']
79
+ config.ignored_modules = ['ActiveStorage', 'ActionMailbox']
80
+ config.model_dirs = ['app/models', 'engines/*/app/models']
81
+ config.output_file_name = 'custom_erd.png'
82
+ end
83
+ ```
84
+
85
+ ## Domain Boundary Violations
86
+
87
+ Pferd will analyse your model associations and **highlight where models reference entities from another domain**. Violations will be clearly marked in the ERD using special edge styles (e.g., dashed or highlighted lines).
88
+
89
+ This feature helps you identify and address cases where models may be overstepping domain boundaries, promoting better separation of concerns within your codebase.
90
+
91
+ ## Contributing
92
+
93
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/your-github/pferd). This project is intended to be a safe, welcoming space for collaboration.
94
+
95
+ ## License
96
+
97
+ Pferd is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'entity_relationship'
3
+ module Pferd
4
+ class Entity
5
+ attr_accessor :klass_name, :associations, :domain
6
+ alias name klass_name
7
+
8
+ def initialize(klass_name, associations, domain)
9
+ @klass_name = klass_name
10
+ @associations = Set.new
11
+ @domain = domain
12
+ end
13
+
14
+ def add_relationship(name: , domain: )
15
+ boundary_violation = self.domain != domain
16
+ associations.add(
17
+ EntityRelationship.new(name:, domain:, boundary_violation: boundary_violation)
18
+ )
19
+ end
20
+ def to_h
21
+ { entity: klass_name, domain: domain, associations: associations.map(&:to_h) }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "entity_relationship"
4
+ module Pferd
5
+ EntityRelationship = Data.define(:name, :domain, :boundary_violation)
6
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pferd
4
+ VERSION = "0.1.0"
5
+ end
data/lib/pferd.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pferd/version"
4
+ require_relative "pferd/entity"
5
+ require "ostruct"
6
+ module Pferd
7
+ class Error < StandardError; end
8
+
9
+ module_function
10
+
11
+ # just use an OpenStruct for flexible config until we know what configs we need
12
+ def configuration
13
+ @configuration ||= OpenStruct.new
14
+ end
15
+
16
+ def configure
17
+ yield(configuration)
18
+ end
19
+
20
+ # Establish some default configs
21
+ configure do |config|
22
+ # Classes without an explicit domain tag should be in this domain
23
+ config.default_domain_name = "Global"
24
+ # Exclude these classes
25
+ config.ignored_classes = []
26
+ # Exclude classes nested in these modules
27
+ config.ignored_modules = ["ActiveStorage"]
28
+ # Load models from these directories
29
+ config.model_dirs = ["app/models"]
30
+ # The name of the generated output file
31
+ config.output_file_name = "pferd.png"
32
+ # Highlight boundary violations
33
+ config.highlight_boundary_violations = true
34
+ end
35
+ end
36
+ # Load this last so that the Rake file has access to the above config.
37
+ load "pferd/lib/tasks/pferd.rake"
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+ require "yard"
5
+ require "pferd"
6
+ require "graphviz"
7
+
8
+ namespace :pferd do
9
+ # Load the models specified in config and generate their Docs
10
+ # (this is required to get the domain tag from the doc metadata)
11
+ YARD::Rake::YardocTask.new do |t|
12
+ t.files = Pferd.configuration.model_dirs.map { |dir| File.join(dir, "**", "*.rb") }
13
+ t.options = ["--tag", 'domain:"App domain"']
14
+ end
15
+
16
+ desc "Generate an ERD that shows models grouped by domain"
17
+ task draw_relationships: %i[environment pferd:yard] do
18
+ # Load the Yard registry
19
+ YARD::Registry.load!
20
+ MODEL_DOMAINS = Hash.new(Pferd.configuration.default_domain_name)
21
+
22
+ # Populate MODEL_DOMAINS with model names and their domains
23
+ YARD::Registry.all(:class).find_each do |klass_info|
24
+ domain_tag = klass_info.tags.find { |tag| tag.tag_name == "domain" }
25
+ MODEL_DOMAINS[klass_info.name.to_s] = domain_tag.text if domain_tag
26
+ end
27
+
28
+ # Load all models
29
+ Rails.application.eager_load!
30
+
31
+ entities = ApplicationRecord.descendants.map do |model|
32
+ next unless MODEL_DOMAINS.key?(model.name)
33
+
34
+ entity = Pferd::Entity.new(model.name, Set.new, MODEL_DOMAINS[model.name])
35
+ associations = (model.reflect_on_all_associations(:has_many) |
36
+ model.reflect_on_all_associations(:has_one))
37
+ associations.each do |assoc|
38
+ next if Pferd.configuration.ignored_classes.include?(assoc.klass.name)
39
+ next if Pferd.configuration.ignored_modules.any? do |module_name|
40
+ assoc.klass.name.start_with?(module_name)
41
+ end
42
+ # TODO: Figure out what to do with polymorphic relations
43
+ next if assoc.polymorphic?
44
+
45
+ entity.add_relationship(name: assoc.klass.name, domain: MODEL_DOMAINS[assoc.klass.name])
46
+ end.compact
47
+ entity
48
+ end.compact
49
+
50
+ g = GraphViz.new(:G, type: :digraph)
51
+ node_map = Hash.new { |hash, key| hash[key] = g.add_graph("cluster_#{key}", label: key) }
52
+
53
+ # Create subgraphs and nodes by domains
54
+ # Note: This has to be done as a complete loop before the next step, because we'll
55
+ # get an exception if we try to define edges before all of the nodes are defined.
56
+ entities.each do |entity|
57
+ subgraph = node_map[entity.domain]
58
+ subgraph.add_nodes(entity.name)
59
+ end
60
+ ##
61
+ # See above comment
62
+ # rubocop:disable Style/CombinableLoops
63
+ entities.each do |entity|
64
+ entity.associations.each do |relationship|
65
+ color = if Pferd.configuration.highlight_boundary_violations && relationship.boundary_violation
66
+ "red"
67
+ else
68
+ "black"
69
+ end
70
+ g.add_edges(entity.name, relationship.name, color: color, style: "dashed")
71
+ end
72
+ end
73
+ # rubocop:enable Style/CombinableLoops
74
+
75
+ # Generate output as PNG file
76
+ g.output(png: Pferd.configuration.output_file_name)
77
+ end
78
+ task default: :draw_relationships
79
+ end
data/sig/pferd.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Pferd
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pferd
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Bodacious
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-02-03 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ruby-graphviz
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: yard
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: Generate domain-based ERDs for Rails. Useful for modelling potential
41
+ conformations before committing to them
42
+ email:
43
+ - gavin@gavinmorrice.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".gitignore"
49
+ - ".rspec"
50
+ - ".rubocop.yml"
51
+ - CHANGELOG.md
52
+ - LICENSE.txt
53
+ - README.md
54
+ - Rakefile
55
+ - lib/pferd.rb
56
+ - lib/pferd/entity.rb
57
+ - lib/pferd/entity_relationship.rb
58
+ - lib/pferd/version.rb
59
+ - lib/tasks/pferd.rake
60
+ - sig/pferd.rbs
61
+ homepage: https://github.com/bodacious/pferd
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ homepage_uri: https://github.com/bodacious/pferd
66
+ source_code_uri: https://github.com/bodacious/pferd
67
+ changelog_uri: https://github.com/bodacious/pferd
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 3.0.0
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.6.3
83
+ specification_version: 4
84
+ summary: Generate domain-based ERDs for Rails
85
+ test_files: []