├── .github
├── dependabot.yml
└── workflows
│ └── ci.yml
├── .gitignore
├── .rubocop.yml
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── Gemfile
├── MIT-LICENSE
├── README.md
├── Rakefile
├── app
├── assets
│ ├── images
│ │ └── erd_map
│ │ │ └── .keep
│ └── stylesheets
│ │ └── erd_map
│ │ └── application.css
├── controllers
│ ├── concerns
│ │ └── .keep
│ └── erd_map
│ │ ├── application_controller.rb
│ │ └── erd_map_controller.rb
├── helpers
│ └── erd_map
│ │ └── application_helper.rb
├── models
│ ├── concerns
│ │ └── .keep
│ └── erd_map
│ │ └── application_record.rb
└── views
│ └── layouts
│ └── erd_map
│ └── application.html.erb
├── bin
├── rails
└── rubocop
├── config
├── initializers
│ └── erd_map.rb
└── routes.rb
├── erd_map.gemspec
├── lib
├── erd_map.rb
├── erd_map
│ ├── engine.rb
│ ├── graph.rb
│ ├── graph_manager.js
│ ├── graph_renderer.rb
│ ├── map_builder.rb
│ ├── plot.rb
│ ├── py_call_modules.rb
│ └── version.rb
└── tasks
│ └── erd_map_tasks.rake
├── sample
├── images
│ ├── mastdon.png
│ └── redmine.png
├── mastdon.html
├── redmine.html
└── sample.md
└── spec
├── erd_map_spec.rb
├── fake_app
├── .gitignore
├── Rakefile
├── app
│ └── models
│ │ ├── address.rb
│ │ ├── adjustment.rb
│ │ ├── admin_user.rb
│ │ ├── application_record.rb
│ │ ├── asset.rb
│ │ ├── calculator.rb
│ │ ├── classification.rb
│ │ ├── cms_page.rb
│ │ ├── cms_section.rb
│ │ ├── country.rb
│ │ ├── coupon_code.rb
│ │ ├── credit_card.rb
│ │ ├── customer_return.rb
│ │ ├── data_feed.rb
│ │ ├── digital.rb
│ │ ├── digital_link.rb
│ │ ├── export.rb
│ │ ├── inventory_unit.rb
│ │ ├── legacy_user.rb
│ │ ├── line_item.rb
│ │ ├── log_entry.rb
│ │ ├── menu.rb
│ │ ├── menu_item.rb
│ │ ├── oauth_access_grant.rb
│ │ ├── oauth_access_token.rb
│ │ ├── oauth_application.rb
│ │ ├── option_type.rb
│ │ ├── option_type_prototype.rb
│ │ ├── option_value.rb
│ │ ├── option_value_variant.rb
│ │ ├── order.rb
│ │ ├── order_promotion.rb
│ │ ├── payment.rb
│ │ ├── payment_capture_event.rb
│ │ ├── payment_method.rb
│ │ ├── payment_source.rb
│ │ ├── preference.rb
│ │ ├── price.rb
│ │ ├── product.rb
│ │ ├── product_option_type.rb
│ │ ├── product_promotion_rule.rb
│ │ ├── product_property.rb
│ │ ├── promotion.rb
│ │ ├── promotion_action.rb
│ │ ├── promotion_action_line_item.rb
│ │ ├── promotion_category.rb
│ │ ├── promotion_rule.rb
│ │ ├── promotion_rule_taxon.rb
│ │ ├── promotion_rule_user.rb
│ │ ├── property.rb
│ │ ├── property_prototype.rb
│ │ ├── prototype.rb
│ │ ├── prototype_taxon.rb
│ │ ├── refund.rb
│ │ ├── refund_reason.rb
│ │ ├── reimbursement.rb
│ │ ├── reimbursement_type.rb
│ │ ├── return_authorization.rb
│ │ ├── return_authorization_reason.rb
│ │ ├── return_item.rb
│ │ ├── role.rb
│ │ ├── role_user.rb
│ │ ├── shipment.rb
│ │ ├── shipping_category.rb
│ │ ├── shipping_method.rb
│ │ ├── shipping_method_category.rb
│ │ ├── shipping_method_zone.rb
│ │ ├── shipping_rate.rb
│ │ ├── state.rb
│ │ ├── state_change.rb
│ │ ├── stock_item.rb
│ │ ├── stock_location.rb
│ │ ├── stock_movement.rb
│ │ ├── stock_transfer.rb
│ │ ├── store.rb
│ │ ├── store_credit.rb
│ │ ├── store_credit_category.rb
│ │ ├── store_credit_event.rb
│ │ ├── store_credit_type.rb
│ │ ├── store_payment_method.rb
│ │ ├── store_product.rb
│ │ ├── store_promotion.rb
│ │ ├── tax_category.rb
│ │ ├── tax_rate.rb
│ │ ├── taxon.rb
│ │ ├── taxon_rule.rb
│ │ ├── taxonomy.rb
│ │ ├── user.rb
│ │ ├── variant.rb
│ │ ├── wished_item.rb
│ │ ├── wishlist.rb
│ │ ├── zone.rb
│ │ └── zone_member.rb
├── bin
│ ├── dev
│ ├── rails
│ ├── rake
│ └── setup
├── config.ru
├── config
│ ├── application.rb
│ ├── boot.rb
│ ├── database.yml
│ ├── environment.rb
│ ├── environments
│ │ ├── development.rb
│ │ └── test.rb
│ ├── puma.rb
│ └── routes.rb
├── db
│ └── 00000000000000_create_all_tables.rb
├── schema.rb
└── tmp
│ └── restart.txt
├── rails_helper.rb
└── spec_helper.rb
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: bundler
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | open-pull-requests-limit: 10
8 | - package-ecosystem: github-actions
9 | directory: "/"
10 | schedule:
11 | interval: daily
12 | open-pull-requests-limit: 10
13 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches: [ main ]
7 |
8 | jobs:
9 | lint:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout code
13 | uses: actions/checkout@v4
14 |
15 | - name: Set up Ruby
16 | uses: ruby/setup-ruby@v1
17 | with:
18 | ruby-version: 3.4
19 | bundler-cache: true
20 |
21 | - name: Lint code for consistent style
22 | run: bin/rubocop -f github
23 |
24 |
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /doc/
3 | /log/*.log
4 | /pkg/
5 | /tmp/
6 | Gemfile.lock
7 | .rspec_status
8 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | # Omakase Ruby styling for Rails
2 | inherit_gem: { rubocop-rails-omakase: rubocop.yml }
3 |
4 | Layout/SpaceInsideArrayLiteralBrackets:
5 | Enabled: false
6 | Style/TrailingCommaInHashLiteral:
7 | Enabled: false
8 | Style/TrailingCommaInArrayLiteral:
9 | Enabled: false
10 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 0.1.2 (2025-01-24)
2 |
3 | - Fix an error when `default_protect_from_forgery` is false
4 |
5 | ## 0.1.1 (2025-01-19)
6 |
7 | - Fix duplicated coordinate shift on hover
8 | - Skip verify_authenticity_token when Re-Compile ErdMap
9 |
10 | ## 0.1.0 (2025-01-15)
11 |
12 | - Initial Release
13 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, caste, color, religion, or sexual
10 | identity and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the overall
26 | community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or advances of
31 | any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email address,
35 | without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official email address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | [INSERT CONTACT METHOD].
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series of
86 | actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or permanent
93 | ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within the
113 | community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.1, available at
119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120 |
121 | Community Impact Guidelines were inspired by
122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123 |
124 | For answers to common questions about this code of conduct, see the FAQ at
125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126 | [https://www.contributor-covenant.org/translations][translations].
127 |
128 | [homepage]: https://www.contributor-covenant.org
129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130 | [Mozilla CoC]: https://github.com/mozilla/diversity
131 | [FAQ]: https://www.contributor-covenant.org/faq
132 | [translations]: https://www.contributor-covenant.org/translations
133 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gemspec
4 |
5 | gem "puma"
6 | gem "sqlite3", ">= 2.1" # for Rails v8.0.1
7 | # gem "sqlite3", "~> 1.4" # for Rails v7.0.1
8 | gem "rubocop-rails-omakase", require: false
9 | gem "debug", ">= 1.0.0"
10 | gem "rspec-rails"
11 | gem "base64" # for past Rails
12 | gem "bigdecimal" # for past Rails
13 | gem "mutex_m" # for past Rails
14 | gem "drb" # for past Rails
15 |
--------------------------------------------------------------------------------
/MIT-LICENSE:
--------------------------------------------------------------------------------
1 | Copyright makicamel
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ErdMap
2 |
3 | ErdMap is an ERD map viewer as a Rails engine.
4 |
5 | Ruby on Rails applications represent their concepts through models. However, since Rails applications often contain numerous models, understanding them can be difficult. ErdMap helps you comprehend your application by visualizing key models and their associations. It provides a clear starting point for understanding the architecture of your application.
6 | ErdMap initially displays the most "important" models. Then, You can "zoom in" to reveal the next important models interactively, much like navigating a map application.
7 |
8 | ### Try It Out
9 |
10 | Sample visualizations below, based on open-source Rails applications. To try it yourself, open the example HTML files in the `sample` directory.
11 |
12 | | [Redmine](https://github.com/redmine/redmine) | [Mastodon](https://github.com/mastodon/mastodon) |
13 | | ------- | -------- |
14 | |  |  |
15 |
16 | ## Dependencies
17 |
18 | ErdMap requires Python3 and the following packages: [`networkx`](https://github.com/networkx/networkx), [`bokeh`](https://github.com/bokeh/bokeh), and [`scipy`](https://github.com/scipy/scipy).
19 | For Python installation details, refer to the [pyenv installation guide](https://github.com/pyenv/pyenv#installation).
20 |
21 | An example for Mac users with Zsh using pyenv for installation
22 |
23 | ```bash
24 | # Install pyenv
25 | brew install pyenv
26 | echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.zshrc
27 | echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.zshrc
28 | echo 'eval "$(pyenv init - zsh)"' >> ~/.zshrc
29 |
30 | # Install latest version of python
31 | pyenv install $(pyenv install --list | grep -E '^\s*[0-9]+\.[0-9]+\.[0-9]+$' | tail -n 1)
32 | pyenv global $(pyenv install --list | grep -E '^\s*[0-9]+\.[0-9]+\.[0-9]+$' | tail -n 1)
33 | ```
34 |
35 |
36 |
37 | ```bash
38 | # Install packages, for example with pip
39 | pip install networkx bokeh scipy
40 | ```
41 |
42 | ## Installation
43 |
44 | Add this line to your application's Gemfile:
45 |
46 | ```ruby
47 | gem "erd_map", group: [:development]
48 | ```
49 |
50 | And then execute:
51 |
52 | ```bash
53 | $ bundle
54 | ```
55 |
56 | ## Usage
57 |
58 | Add the following to your `config/routes.rb` and access `/erd_map` in your browser:
59 |
60 | ```ruby
61 | Rails.application.routes.draw do
62 | mount ErdMap::Engine => "erd_map"
63 | end
64 | ```
65 |
66 | The initial computation might take several seconds. Once completed, the "ErdMap" visualization will be displayed. After the first generation, the map will be cached as an HTML file, so subsequent accesses will display the map instantly without regeneration. If you want to regenerate the map, click the "Re-Compute" button.
67 |
68 | The generated HTML file is saved at `/{rails_root}/tmp/erd_map/map.html`.
69 |
70 | ### Task
71 |
72 | You can also explicitly generate the HTML file using rails task.
73 |
74 | ```bash
75 | bundle exec rails erd_map
76 | ```
77 |
78 | ### Map Controls
79 |
80 | - Navigation
81 | - Wheel Mode: Toggle zooming with the mouse wheel
82 | - Zoom In: Reveal more models
83 | - Zoom Out: Display fewer models
84 |
85 | - Display Options
86 | - Tap Mode: Switch between showing associations or communities (see [Algorithm](https://github.com/makicamel/erd_map#Algorithm) section for more about communities)
87 | - Display Mode: Toggle between showing only model names or including foreign keys
88 |
89 | - Layout
90 | - Re-Layout: Randomly rearrange the displayed models
91 | - Re-Compute: Regenerate the map to reflect updates to the models
92 |
93 | ## Algorithm
94 |
95 | The initial display shows only the three most "important" models. These models are larger in size, while models displayed upon zooming in are slightly smaller. Importance here is determined by **eigenvector centrality**.
96 |
97 | **Eigenvector centrality** is an indicator of how well a model is connected to other highly connected and important models. It considers not just the number of connections a model has, but also the number of important nodes it is connected to.
98 |
99 | Additionally, models are organized into groups (communities) and assigned colors for each community. These communities are detected using the **Louvain method**, which discovers strongly connected communities in a network. The method moves and merges nodes iteratively to optimize communities, maximizing modularity (the density of connections), and dividing the network into natural clusters.
100 |
101 | Both eigenvector centrality and Louvain method implementations are provided by [NetworkX](https://github.com/networkx/networkx) library.
102 |
103 | ## Contributing
104 |
105 | Bug reports and pull requests are welcome on GitHub at https://github.com/makicamel/erd_map. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/makicamel/erd_map/blob/main/CODE_OF_CONDUCT.md).
106 |
107 | ## License
108 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
109 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/setup"
2 |
3 | # load "rails/tasks/statistics.rake"
4 |
5 | require "bundler/gem_tasks"
6 |
--------------------------------------------------------------------------------
/app/assets/images/erd_map/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makicamel/erd_map/cd1859bc694a0e74f035c6a03b133c9c25ccbe57/app/assets/images/erd_map/.keep
--------------------------------------------------------------------------------
/app/assets/stylesheets/erd_map/application.css:
--------------------------------------------------------------------------------
1 | /*
2 | * This is a manifest file that'll be compiled into application.css, which will include all the files
3 | * listed below.
4 | *
5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7 | *
8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10 | * files in this directory. Styles in this file should be added after the last require_* statement.
11 | * It is generally better to create a new file per style scope.
12 | *
13 | *= require_tree .
14 | *= require_self
15 | */
16 |
--------------------------------------------------------------------------------
/app/controllers/concerns/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makicamel/erd_map/cd1859bc694a0e74f035c6a03b133c9c25ccbe57/app/controllers/concerns/.keep
--------------------------------------------------------------------------------
/app/controllers/erd_map/application_controller.rb:
--------------------------------------------------------------------------------
1 | module ErdMap
2 | class ApplicationController < ActionController::Base
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/app/controllers/erd_map/erd_map_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ErdMap
4 | class ErdMapController < ApplicationController
5 | FILE_PATH = Rails.root.join("tmp", "erd_map", "map.html")
6 |
7 | skip_forgery_protection
8 |
9 | def index
10 | if File.exist?(FILE_PATH)
11 | render html: File.read(FILE_PATH).html_safe
12 | else
13 | _stdout, stderr, status = Open3.capture3("rails runner 'ErdMap::MapBuilder.build'")
14 | if status.success?
15 | render html: File.read(FILE_PATH).html_safe
16 | else
17 | render plain: "Error: #{stderr}", status: :unprocessable_entity
18 | end
19 | end
20 | end
21 |
22 | def update
23 | _stdout, stderr, status = Open3.capture3("rails runner 'ErdMap::MapBuilder.build'")
24 | if status.success?
25 | head :ok
26 | else
27 | render json: { message: "Error: \n#{stderr}" }, status: :unprocessable_entity
28 | end
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/app/helpers/erd_map/application_helper.rb:
--------------------------------------------------------------------------------
1 | module ErdMap
2 | module ApplicationHelper
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/app/models/concerns/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makicamel/erd_map/cd1859bc694a0e74f035c6a03b133c9c25ccbe57/app/models/concerns/.keep
--------------------------------------------------------------------------------
/app/models/erd_map/application_record.rb:
--------------------------------------------------------------------------------
1 | module ErdMap
2 | class ApplicationRecord < ActiveRecord::Base
3 | self.abstract_class = true
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/app/views/layouts/erd_map/application.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Erd map
5 | <%= csrf_meta_tags %>
6 | <%= csp_meta_tag %>
7 |
8 | <%= yield :head %>
9 |
10 | <%= stylesheet_link_tag "erd_map/application", media: "all" %>
11 |
12 |
13 |
14 | <%= yield %>
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # This command will automatically be run when you run "rails" with Rails gems
3 | # installed from the root of your application.
4 |
5 | ENGINE_ROOT = File.expand_path("..", __dir__)
6 | ENGINE_PATH = File.expand_path("../lib/erd_map/engine", __dir__)
7 |
8 | # Set up gems listed in the Gemfile.
9 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
10 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
11 |
12 | require "rails"
13 | require "active_model/railtie"
14 | require "active_job/railtie"
15 | require "active_record/railtie"
16 | require "action_controller/railtie"
17 | require "action_view/railtie"
18 | require "rails/engine/commands"
19 |
--------------------------------------------------------------------------------
/bin/rubocop:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require "rubygems"
3 | require "bundler/setup"
4 |
5 | # explicit rubocop config increases performance slightly while avoiding config confusion.
6 | ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
7 |
8 | load Gem.bin_path("rubocop", "rubocop")
9 |
--------------------------------------------------------------------------------
/config/initializers/erd_map.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | ErdMap.py_call_modules = ErdMap::PyCallModules.new
4 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | ErdMap::Engine.routes.draw do
4 | root to: "erd_map#index"
5 | put "/", to: "erd_map#update"
6 | end
7 |
--------------------------------------------------------------------------------
/erd_map.gemspec:
--------------------------------------------------------------------------------
1 | require_relative "lib/erd_map/version"
2 |
3 | Gem::Specification.new do |spec|
4 | spec.name = "erd_map"
5 | spec.version = ErdMap::VERSION
6 | spec.authors = [ "makicamel" ]
7 | spec.email = [ "unright@gmail.com" ]
8 | spec.homepage = "https://github.com/makicamel/erd_map"
9 | spec.summary = "An ERD map viewer as a Rails engine."
10 | spec.description = "ErdMap visualizes key models and associations in Rails applications, helping you understand their architecture by starting with the most important models and allowing zoom-in exploration."
11 | spec.license = "MIT"
12 |
13 | spec.metadata["homepage_uri"] = spec.homepage
14 | spec.metadata["source_code_uri"] = spec.homepage
15 | spec.metadata["changelog_uri"] = "https://github.com/makicamel/erd_map/blob/main/CHANGELOG.md"
16 |
17 | spec.files = Dir.chdir(File.expand_path(__dir__)) do
18 | Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"]
19 | end
20 |
21 | spec.add_dependency "rails"
22 | spec.add_dependency "pycall"
23 | spec.add_dependency "fiddle"
24 | end
25 |
--------------------------------------------------------------------------------
/lib/erd_map.rb:
--------------------------------------------------------------------------------
1 | require "erd_map/version"
2 | require "erd_map/engine"
3 | require "erd_map/py_call_modules"
4 | require "erd_map/graph"
5 | require "erd_map/graph_renderer"
6 | require "erd_map/map_builder"
7 | require "erd_map/plot"
8 |
9 | module ErdMap
10 | class << self
11 | def py_call_modules
12 | @py_call_modules
13 | end
14 |
15 | def py_call_modules=(py_call_modules)
16 | @py_call_modules = py_call_modules
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/erd_map/engine.rb:
--------------------------------------------------------------------------------
1 | module ErdMap
2 | class Engine < ::Rails::Engine
3 | isolate_namespace ErdMap
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/lib/erd_map/graph.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ErdMap
4 | class Graph
5 | CHUNK_SIZE = 3
6 | MAX_COMMUNITY_SIZE = 20
7 |
8 | # @return Array: [{ "NodeA" => [x, y] }, { "NodeA" => [x, y], "NodeB" => [x, y], "NodeC" => [x, y] }, ...]
9 | def layouts_by_chunk
10 | return @layouts_by_chunk if @layouts_by_chunk
11 |
12 | @layouts_by_chunk = []
13 |
14 | chunked_nodes.each_with_index do |_, i|
15 | display_nodes = chunked_nodes[0..i].flatten
16 | nodes_size = display_nodes.size
17 | k = 1.0 / Math.sqrt(nodes_size) * 3.0
18 |
19 | subgraph = whole_graph.subgraph(display_nodes)
20 | layout = networkx.spring_layout(subgraph, seed: 1, k: k)
21 |
22 | layout_hash = {}
23 | layout.each do |node, xy|
24 | layout_hash[node] = [xy[0].to_f, xy[1].to_f]
25 | end
26 |
27 | @layouts_by_chunk << layout_hash
28 | end
29 |
30 | @layouts_by_chunk
31 | end
32 |
33 | # [[nodeA, nodeB, nodeC], [nodeD, nodeE, nodeF, nodeG, ...], ...]
34 | def chunked_nodes
35 | return @chunked_nodes if @chunked_nodes
36 |
37 | centralities = networkx.eigenvector_centrality(whole_graph) # { node_name => centrality }
38 | sorted_nodes = centralities.sort_by { |_node, centrality| centrality }.reverse.map(&:first)
39 |
40 | chunk_sizes = []
41 | total_nodes = sorted_nodes.size
42 | while chunk_sizes.sum < total_nodes
43 | chunk_sizes << (CHUNK_SIZE ** (chunk_sizes.size + 1))
44 | end
45 |
46 | offset = 0
47 | @chunked_nodes = chunk_sizes.each_with_object([]) do |size, nodes|
48 | slice = sorted_nodes[offset, size]
49 | break nodes if slice.nil? || slice.empty?
50 | offset += size
51 | nodes << slice
52 | end
53 | end
54 |
55 | # @return Hash: { String: Integer }
56 | def node_with_community_index
57 | return @node_with_community_index if @node_with_community_index
58 |
59 | whole_communities = networkx_community.louvain_communities(whole_graph).map { |communities| PyCall::List.new(communities).to_a }
60 | communities = split_communities(whole_graph, whole_communities)
61 |
62 | @node_with_community_index = {}
63 | communities.each_with_index do |community, i|
64 | community.each do |node_name|
65 | @node_with_community_index[node_name] = i
66 | end
67 | end
68 | @node_with_community_index
69 | end
70 |
71 | def initial_nodes
72 | chunked_nodes.first
73 | end
74 |
75 | def initial_layout
76 | layouts_by_chunk.first
77 | end
78 |
79 | def whole_layout
80 | layouts_by_chunk.last
81 | end
82 |
83 | def node_names
84 | @node_names ||= PyCall::List.new(whole_graph.nodes)
85 | end
86 |
87 | def edges
88 | @edges ||= PyCall::List.new(whole_graph.edges)
89 | end
90 |
91 | def node_radius
92 | @node_radius ||= node_names.map { |node_name| nodes_with_radius_according_to_chunk_index[node_name] }
93 | end
94 |
95 | def connections
96 | @connections ||= edges.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(a, b), hash|
97 | hash[a] << b
98 | hash[b] << a
99 | end
100 | end
101 |
102 | def association_columns
103 | return @association_columns if @association_columns
104 |
105 | @association_columns = Hash.new { |hash, key| hash[key] = [] }
106 | whole_models.each do |model|
107 | model.reflect_on_all_associations(:belongs_to).select { |mod| !mod.options[:polymorphic] }.map do |target|
108 | if target.try(:foreign_key) && model.column_names.include?(target.foreign_key)
109 | @association_columns[model.name] << target.foreign_key
110 | end
111 | end
112 | end
113 | @association_columns
114 | end
115 |
116 | def node_colors
117 | return @node_colors if @node_colors
118 |
119 | palette = [
120 | "#a6cee3", "#1f78b4", "#b2df8a", "#33a02c", "#fb9a99",
121 | "#e74446", "#fdbf6f", "#ff7f00", "#cab2d6", "#7850a4",
122 | "#ffff99", "#b8693d", "#8dd3c7", "#ffffb3", "#bebada",
123 | "#fb8072", "#80b1d3", "#fdb462", "#b3de69", "#fccde5",
124 | "#d9d9d9", "#bc80bd", "#ccebc5", "#ffed6f", "#1b9e77",
125 | "#d95f02", "#7570b3", "#ef73b2", "#66a61e", "#e6ab02"
126 | ]
127 | community_map = node_with_community_index
128 | @node_colors = node_names.map do |node_name|
129 | community_index = community_map[node_name]
130 | palette[community_index % palette.size]
131 | end
132 | end
133 |
134 | private
135 |
136 | attr_reader :networkx, :networkx_community
137 | attr_reader :whole_graph
138 |
139 | def initialize
140 | import_modules = ErdMap.py_call_modules.imported_modules
141 | @networkx = import_modules[:networkx]
142 | @networkx_community = import_modules[:networkx_community]
143 | @whole_graph = build_whole_graph
144 | end
145 |
146 | def whole_models
147 | Rails.application.eager_load!
148 | @whole_models ||= ActiveRecord::Base.descendants
149 | .reject { |model| model.name.in?(%w[ActiveRecord::SchemaMigration ActiveRecord::InternalMetadata]) }
150 | .select(&:table_exists?)
151 | end
152 |
153 | def build_whole_graph
154 | whole_graph = networkx.Graph.new
155 |
156 | whole_models.each do |model|
157 | whole_graph.add_node(model.name)
158 | [:has_many, :has_one, :belongs_to].each do |association_type|
159 | model
160 | .reflect_on_all_associations(association_type)
161 | .select { |mod| !mod.options[:polymorphic] && !mod.options[:anonymous_class] }
162 | .map(&:class_name)
163 | .uniq
164 | .select { |target| target.constantize.respond_to?(:column_names) }
165 | .map do |target|
166 | if association_type == :belongs_to
167 | whole_graph.add_edge(target, model.name)
168 | else
169 | whole_graph.add_edge(model.name, target)
170 | end
171 | end
172 | end
173 | end
174 | whole_graph
175 | end
176 |
177 | # { "NodeA" => 0, "NodeB" => 0, "NodeC" => 1, ... }
178 | def nodes_with_chunk_index
179 | return @nodes_with_chunk_index if @nodes_with_chunk_index
180 | @nodes_with_chunk_index = {}
181 | chunked_nodes.each_with_index do |chunk, i|
182 | chunk.each { |node_name| @nodes_with_chunk_index[node_name] = i }
183 | end
184 | @nodes_with_chunk_index
185 | end
186 |
187 | def nodes_with_radius_according_to_chunk_index
188 | return @nodes_with_radius_according_to_chunk_index if @nodes_with_radius_according_to_chunk_index
189 |
190 | max_node_size = 60
191 | min_node_size = 20
192 | node_size_step = 10
193 |
194 | @nodes_with_radius_according_to_chunk_index = {}
195 | chunked_nodes.each_with_index do |chunk, chunk_index|
196 | chunk.each do |node_name|
197 | size = max_node_size - (chunk_index * node_size_step)
198 | @nodes_with_radius_according_to_chunk_index[node_name] = (size < min_node_size) ? min_node_size : size
199 | end
200 | end
201 | @nodes_with_radius_according_to_chunk_index
202 | end
203 |
204 | def split_communities(graph, communities)
205 | result = []
206 |
207 | communities.each do |community|
208 | if community.size <= MAX_COMMUNITY_SIZE
209 | result << community
210 | else
211 | subgraph = graph.subgraph(community)
212 | sub_communities = networkx_community.louvain_communities(subgraph).map { |comm| PyCall::List.new(comm).to_a }
213 | if sub_communities.size == 1 && (sub_communities[0] - community).empty?
214 | result << community
215 | else
216 | splitted_sub = split_communities(subgraph, sub_communities)
217 | result.concat(splitted_sub)
218 | end
219 | end
220 | end
221 |
222 | result
223 | end
224 | end
225 | end
226 |
--------------------------------------------------------------------------------
/lib/erd_map/graph_manager.js:
--------------------------------------------------------------------------------
1 | class GraphManager {
2 | constructor({
3 | graphRenderer,
4 | rectRenderer,
5 | circleRenderer,
6 | layoutProvider,
7 | connectionsData,
8 | layoutsByChunkData,
9 | chunkedNodesData,
10 | nodeWithCommunityIndexData,
11 | selectingNodeLabel,
12 | zoomModeToggle,
13 | tapModeToggle,
14 | displayTitleModeToggle,
15 | nodeLabels,
16 | plot,
17 | windowObj,
18 | }) {
19 | this.graphRenderer = graphRenderer
20 | this.rectRenderer = rectRenderer
21 | this.circleRenderer = circleRenderer
22 | this.nodeSource = this.graphRenderer.node_renderer.data_source
23 | this.edgeSource = this.graphRenderer.edge_renderer.data_source
24 | this.cardinalityDataSource = cardinalityDataSource
25 | this.layoutProvider = layoutProvider
26 | this.connections = JSON.parse(connectionsData)
27 | this.layoutsByChunk = JSON.parse(layoutsByChunkData)
28 | this.chunkedNodes = JSON.parse(chunkedNodesData)
29 | this.nodeWithCommunityIndex = JSON.parse(nodeWithCommunityIndexData)
30 | this.selectingNodeLabel = selectingNodeLabel
31 | this.zoomModeToggle = zoomModeToggle
32 | this.tapModeToggle = tapModeToggle
33 | this.displayTitleModeToggle = displayTitleModeToggle
34 | this.nodeLabels = nodeLabels
35 | this.plot = plot
36 | this.windowObj = windowObj
37 | this.cbObj = cb_obj
38 |
39 | this.#addNodeLabelsWithDelay()
40 | this.resetPlot()
41 | }
42 |
43 | toggleTapped() {
44 | this.#resetSearch()
45 | const tappedNode = this.#findTappedNode()
46 | this.#applyTap(tappedNode, { nodeTapped: true })
47 | }
48 |
49 | toggleHovered() {
50 | const { selectedLayout, showingNodes } = this.#getShowingNodesAndSelectedLayout()
51 | const { closestNodeName, minmumDistance } = this.#findClosestNodeWithMinmumDistance(selectedLayout, showingNodes)
52 |
53 | const originalColors = this.#showTitleMode ? this.nodeSource.data["circle_original_color"] : this.nodeSource.data["rect_original_color"]
54 | const originalRadius = this.nodeSource.data["original_radius"]
55 |
56 | if (closestNodeName && minmumDistance < 0.005) {
57 | // Emphasize nodes when find the closest node
58 | const connectedNodes = (this.connections[closestNodeName] || []).concat([closestNodeName])
59 | this.#nodesIndex.forEach((nodeName, i) => {
60 | const isConnectedNode = selectedLayout[nodeName] && connectedNodes.includes(nodeName)
61 | this.nodeSource.data["text_color"][i] = isConnectedNode ? HIGHLIGHT_TEXT_COLOR : BASIC_COLOR
62 | this.nodeSource.data["text_outline_color"][i] = isConnectedNode ? BASIC_COLOR : null
63 | })
64 | this.nodeSource.data["fill_color"] = this.#nodesIndex.map((nodeName, i) => {
65 | const isConnectedNode = selectedLayout[nodeName] && connectedNodes.includes(nodeName)
66 | return isConnectedNode ? HIGHLIGHT_NODE_COLOR : originalColors[i]
67 | })
68 | this.nodeSource.data["radius"] = this.#nodesIndex.map((nodeName, i) => {
69 | return nodeName === closestNodeName ? EMPHASIS_NODE_SIZE : originalRadius[i]
70 | })
71 | this.edgeSource.data["line_color"] = this.#sourceNodes.map((start, i) => {
72 | return [start, this.#targetNodes[i]].includes(closestNodeName) ? HIGHLIGHT_EDGE_COLOR : BASIC_COLOR
73 | })
74 | this.cardinalityDataSource.data["text_color"] = this.cardinalityDataSource.data["source"].map((sourceNodeName, i) => {
75 | const targetNodeName = this.cardinalityDataSource.data["target"][i]
76 | const isConnectedNode = (closestNodeName === sourceNodeName || closestNodeName === targetNodeName) &&
77 | (selectedLayout[sourceNodeName] && connectedNodes.includes(sourceNodeName)) &&
78 | (selectedLayout[targetNodeName] && connectedNodes.includes(targetNodeName))
79 | return isConnectedNode ? HIGHLIGHT_EDGE_COLOR : BASIC_COLOR
80 | })
81 | } else {
82 | // Revert to default states
83 | this.nodeSource.data["radius"] = originalRadius
84 | this.nodeSource.data["fill_color"] = originalColors
85 | this.nodeSource.data["text_color"] = this.#nodesIndex.map(() => BASIC_COLOR)
86 | this.nodeSource.data["text_outline_color"] = this.#nodesIndex.map(() => null)
87 | this.edgeSource.data["line_color"] = this.#sourceNodes.map(() => BASIC_COLOR)
88 | this.cardinalityDataSource.data["text_color"] = this.cardinalityDataSource.data["text_color"].map(() => BASIC_COLOR)
89 | }
90 |
91 | this.nodeSource.change.emit()
92 | this.edgeSource.change.emit()
93 | this.cardinalityDataSource.change.emit()
94 | }
95 |
96 | triggerZoom() {
97 | if (this.#fixingZoom) { return }
98 |
99 | if (this.windowObj.zoomTimeout !== undefined) { clearTimeout(this.windowObj.zoomTimeout) }
100 |
101 | this.windowObj.zoomTimeout = setTimeout(() => { this.#handleZoom() }, 200)
102 | }
103 |
104 | zoomIn() {
105 | const displayChunksCount = Math.min(this.#displayChunksCount + 1, this.chunkedNodes.length - 1)
106 | this.#setDisplayChunksCount(displayChunksCount)
107 | this.#executeZoom({ previousDisplayChunksCount: null })
108 | }
109 |
110 | zoomOut() {
111 | const displayChunksCount = Math.max(this.#displayChunksCount - 1, 0)
112 | this.#setDisplayChunksCount(displayChunksCount)
113 | this.#executeZoom({ previousDisplayChunksCount: null })
114 | }
115 |
116 | toggleZoomMode() {
117 | this.#setFixingZoom(!this.#fixingZoom)
118 |
119 | if (this.#fixingZoom) {
120 | this.zoomModeToggle.label = "Wheel mode: fix"
121 | this.zoomModeToggle.button_type = "default"
122 | } else {
123 | this.zoomModeToggle.label = "Wheel mode: zoom"
124 | this.zoomModeToggle.button_type = "warning"
125 | this.#setSelectingNodeLabel(null)
126 | this.selectingNodeLabel.change.emit()
127 | }
128 | }
129 |
130 | toggleTapMode() {
131 | if (this.#showingAssociation) {
132 | this.tapModeToggle.label = "Tap mode: community"
133 | this.tapModeToggle.button_type = "warning"
134 | } else {
135 | this.tapModeToggle.label = "Tap mode: association"
136 | this.tapModeToggle.button_type = "default"
137 | }
138 | this.#setShowingAssociation(!this.#showingAssociation)
139 | if (this.windowObj.selectingNode) {
140 | this.#applyTap(this.windowObj.selectingNode, { nodeTapped: false })
141 | }
142 | }
143 |
144 | toggleDisplayTitleMode() {
145 | this.#setShowTitleMode(!this.#showTitleMode)
146 | this.rectRenderer.visible = !this.#showTitleMode
147 | this.circleRenderer.visible = this.#showTitleMode
148 | this.#applyDisplayTitleMode()
149 | }
150 |
151 | reLayout() {
152 | const minMax = {
153 | minX: this.plot.x_range.start,
154 | maxX: this.plot.x_range.end,
155 | minY: this.plot.y_range.start,
156 | maxY: this.plot.y_range.end,
157 | isInsideDisplay(x, y) { return this.minX <= x && x <= this.maxX && this.minY <= y && y <= this.maxY },
158 | randXY() {
159 | return [
160 | this.minX + Math.random() * (this.maxX - this.minX), // randX
161 | this.minY + Math.random() * (this.maxY - this.minY), // randY
162 | ]
163 | },
164 | distances() {
165 | const averageRange = ((this.maxX - this.minX) + (this.maxY - this.minY)) / 2
166 | return [
167 | averageRange * 0.25, //minDistance
168 | averageRange * 0.5, // maxDistance
169 | ]
170 | }
171 | }
172 | const placedPositions = []
173 | const { showingNodes, selectedLayout } = this.#getShowingNodesAndSelectedLayout()
174 | const newLayout = { ...selectedLayout }
175 | showingNodes.forEach((nodeName) => {
176 | const [currentX, currentY] = newLayout[nodeName] || this.#wholeLayout[nodeName]
177 | if (minMax.isInsideDisplay(currentX, currentY)) {
178 | const [newX, newY] = this.#findReLayoutXY(placedPositions, minMax)
179 | newLayout[nodeName] = [newX, newY]
180 | placedPositions.push([newX, newY])
181 | }
182 | })
183 |
184 | this.#setShiftX(0)
185 | this.#setShiftY(0)
186 | this.#updateLayout(newLayout, null)
187 | this.layoutProvider.graph_layout = newLayout
188 | this.layoutProvider.change.emit()
189 | }
190 |
191 | searchNodes() {
192 | this.#setSearchingTerm(searchBox.value.trim().toLowerCase().replaceAll("_", ""))
193 | if (!this.#searchingTerm) { return this.#resetSearch() }
194 |
195 | const { showingNodes, selectedLayout } = this.#getShowingNodesAndSelectedLayout()
196 | this.#changeDisplayNodes(showingNodes)
197 |
198 | this.layoutProvider.graph_layout = selectedLayout
199 | this.layoutProvider.change.emit()
200 |
201 | this.#setSelectingNodeLabel(this.#searchingTerm)
202 | this.selectingNodeLabel.change.emit()
203 | }
204 |
205 | #applyTap(tappedNode, { nodeTapped }) {
206 | if (this.#showingAssociation) {
207 | if (this.windowObj.selectingNode === undefined && tappedNode === undefined) {
208 | // When tap non-node area with non-selecting mode
209 | // do nothing
210 | } else if (nodeTapped && this.windowObj.selectingNode && (tappedNode === this.windowObj.selectingNode || tappedNode === undefined)) {
211 | // When tap the same node or non-node area
212 | this.#setSelectingNode(null)
213 | this.#revertTapSelection()
214 | } else {
215 | // When tap new or another node
216 | this.#setSelectingNode(tappedNode)
217 | this.#setSelectedNode(tappedNode)
218 | this.#applyTapSelection(tappedNode)
219 | }
220 | } else {
221 | this.#setSelectingNode(tappedNode)
222 | this.#setSelectedNode(tappedNode)
223 | const { showingNodes } = this.#getShowingNodesAndSelectedLayout()
224 | this.#changeDisplayNodes(showingNodes)
225 | }
226 | this.#setSelectingNodeLabel(this.windowObj.selectingNode)
227 | this.selectingNodeLabel.change.emit()
228 | }
229 |
230 | resetPlot() {
231 | this.#setShiftX(0)
232 | this.#setShiftY(0)
233 | this.#setStableRange(undefined)
234 | this.#setDisplayChunksCount(0)
235 | this.#setSelectingNode(null)
236 | this.#setSelectedNode(null)
237 | this.#setFixingZoom(!true) // Set the opposite value of toggled value
238 | this.toggleZoomMode()
239 | this.#resetSearch()
240 |
241 | if (this.#showingAssociation === false) { this.toggleTapMode() }
242 | if (this.#showTitleMode === false) { this.toggleDisplayTitleMode() }
243 |
244 | const { showingNodes, selectedLayout } = this.#getShowingNodesAndSelectedLayout()
245 | this.#changeDisplayNodes(showingNodes)
246 |
247 | this.#updateLayout(this.layoutsByChunk[0])
248 | const newLayout = { ...selectedLayout }
249 | this.#nodesIndex.forEach((nodeName, i) => {
250 | if (newLayout[nodeName] === undefined) {
251 | newLayout[nodeName] = this.#wholeLayout[nodeName]
252 | }
253 | })
254 | this.layoutProvider.graph_layout = newLayout
255 | this.layoutProvider.change.emit()
256 | }
257 |
258 | #getShowingNodesAndSelectedLayout() {
259 | let selectedLayout = this.#selectedLayout
260 | let showingNodes = Object.keys(selectedLayout)
261 |
262 | if (this.windowObj.selectingNode && this.#showingAssociation) {
263 | selectedLayout = this.#wholeLayout
264 | showingNodes = this.connections[this.windowObj.selectingNode] || []
265 | showingNodes.push(this.windowObj.selectingNode)
266 | } else if (this.windowObj.selectingNode) {
267 | selectedLayout = this.#wholeLayout
268 | const communityIndex = this.nodeWithCommunityIndex[this.windowObj.selectingNode]
269 | showingNodes = Object.keys(this.nodeWithCommunityIndex).filter(node => this.nodeWithCommunityIndex[node] === communityIndex)
270 | showingNodes.push(this.windowObj.selectingNode)
271 | }
272 | if (this.#searchingTerm) {
273 | selectedLayout = this.#wholeLayout
274 | showingNodes.forEach(nodeName => {
275 | if (this.#selectedLayout[nodeName]) {
276 | selectedLayout[nodeName] = this.#selectedLayout[nodeName]
277 | }
278 | })
279 | const matchedNodes = this.#nodesIndex.filter(nodeName =>
280 | nodeName.toLowerCase().includes(this.#searchingTerm)
281 | )
282 | showingNodes = showingNodes.concat(matchedNodes)
283 | }
284 |
285 | return { selectedLayout, showingNodes }
286 | }
287 |
288 | #findClosestNodeWithMinmumDistance(layout, candidateNodes) {
289 | let closestNodeName
290 | let minmumDistance = Infinity
291 |
292 | this.#nodesIndex.forEach((nodeName, i) => {
293 | if (!candidateNodes.includes(nodeName)) { return }
294 |
295 | const xy = layout[nodeName] || this.#wholeLayout[nodeName]
296 | const dx = xy[0] - this.#mouseX
297 | const dy = xy[1] - this.#mouseY
298 | const distance = dx * dx + dy * dy
299 | if (distance < minmumDistance) {
300 | minmumDistance = distance
301 | closestNodeName = this.#nodesIndex[i]
302 | }
303 | })
304 | return { closestNodeName, minmumDistance }
305 | }
306 |
307 | #changeDisplayNodes(showingNodes) {
308 | this.nodeSource.data["alpha"] = this.#nodesIndex.map((nodeName) =>
309 | showingNodes.includes(nodeName) ? VISIBLE : TRANSLUCENT
310 | )
311 | this.edgeSource.data["alpha"] = this.#sourceEdges.map((source, i) =>
312 | showingNodes.includes(source) && showingNodes.includes(this.edgeSource.data["end"][i]) ? VISIBLE : TRANSLUCENT
313 | )
314 | this.cardinalityDataSource.data["alpha"] = this.cardinalityDataSource.data["source"].map((sourceNodeName, i) => {
315 | const targetNodeName = this.cardinalityDataSource.data["target"][i]
316 |
317 | if (showingNodes.includes(sourceNodeName) && showingNodes.includes(targetNodeName) && sourceNodeName !== targetNodeName) {
318 | return VISIBLE
319 | } else {
320 | return 0
321 | }
322 | })
323 |
324 | this.nodeSource.change.emit()
325 | this.edgeSource.change.emit()
326 | this.cardinalityDataSource.change.emit()
327 | this.#applyDisplayTitleMode()
328 | }
329 |
330 | // @returns [nodesX[Array], nodesY[Array]]
331 | #updateLayout(layout, centerNodeName) {
332 | const nodesX = this.nodeSource.data["x"]
333 | const nodesY = this.nodeSource.data["y"]
334 | const applyShift = !!layout[centerNodeName]
335 | if (applyShift) {
336 | this.#setShiftX(this.#mouseX - layout[centerNodeName][0])
337 | this.#setShiftY(this.#mouseY - layout[centerNodeName][1])
338 | }
339 | const shiftX = this.#shiftX
340 | const shiftY = this.#shiftY
341 | this.#nodesIndex.forEach((nodeName, i) => {
342 | const [newX, newY] = layout[nodeName] || this.#wholeLayout[nodeName]
343 | nodesX[i] = newX + shiftX
344 | nodesY[i] = newY + shiftY
345 | })
346 |
347 | const cardinalityOffsetX = 0.2
348 | const cardinalityOffsetY = 0.3
349 | this.cardinalityDataSource.data["x"].forEach((_, i) => {
350 | const sourceNodeName = this.cardinalityDataSource.data["source"][i]
351 | const targetNodeName = this.cardinalityDataSource.data["target"][i]
352 |
353 | const sourceLayout = layout[sourceNodeName] || this.#wholeLayout[sourceNodeName]
354 | const targetLayout = layout[targetNodeName] || this.#wholeLayout[targetNodeName]
355 |
356 | const sourceX = sourceLayout[0] + shiftX
357 | const sourceY = sourceLayout[1] + shiftY
358 | const targetX = targetLayout[0] + shiftX
359 | const targetY = targetLayout[1] + shiftY
360 | const vectorX = targetX - sourceX
361 | const vectorY = targetY - sourceY
362 | const length = Math.sqrt(vectorX * vectorX + vectorY * vectorY)
363 |
364 | const isSourceNode = i % 2 === 0
365 | this.cardinalityDataSource.data["x"][i] = isSourceNode ?
366 | sourceX + (vectorX / length) * cardinalityOffsetX
367 | : targetX - (vectorX / length) * cardinalityOffsetX
368 | this.cardinalityDataSource.data["y"][i] = isSourceNode ?
369 | sourceY + (vectorY / length) * cardinalityOffsetY
370 | : targetY - (vectorY / length) * cardinalityOffsetY
371 | })
372 |
373 | this.nodeSource.change.emit()
374 | this.edgeSource.change.emit()
375 | this.cardinalityDataSource.change.emit()
376 |
377 | return [nodesX, nodesY]
378 | }
379 |
380 | // @returns {string | undefined}
381 | #findTappedNode() {
382 | const { showingNodes } = this.#getShowingNodesAndSelectedLayout()
383 | const selectedNodesIndices = this.cbObj.indices
384 | const tappedNodeIndex = selectedNodesIndices.find((id) => showingNodes.includes(this.#nodesIndex[id]))
385 | return tappedNodeIndex ? this.#nodesIndex[tappedNodeIndex] : undefined
386 | }
387 |
388 | #revertTapSelection() {
389 | const { showingNodes, selectedLayout } = this.#getShowingNodesAndSelectedLayout()
390 | this.#changeDisplayNodes(showingNodes)
391 |
392 | const [nodesX, nodesY] = this.#updateLayout(selectedLayout, this.windowObj.selectedNode)
393 | const newGraphLayout = {}
394 | this.#nodesIndex.forEach((nodeName, i) => {
395 | newGraphLayout[nodeName] = [nodesX[i], nodesY[i]]
396 | })
397 | this.layoutProvider.graph_layout = newGraphLayout
398 | this.layoutProvider.change.emit()
399 | }
400 |
401 | #applyTapSelection(tappedNode) {
402 | if (tappedNode === undefined) { return }
403 |
404 | const connectedNodes = [...(this.connections[tappedNode] || []), tappedNode]
405 | this.#changeDisplayNodes(connectedNodes)
406 |
407 | const { selectedLayout } = this.#getShowingNodesAndSelectedLayout()
408 | const [nodesX, nodesY] = this.#updateLayout(selectedLayout, tappedNode)
409 | this.#nodesIndex.forEach((nodeName, i) => {
410 | selectedLayout[nodeName][0] = nodesX[i]
411 | selectedLayout[nodeName][1] = nodesY[i]
412 | })
413 | this.layoutProvider.graph_layout = selectedLayout
414 | this.layoutProvider.change.emit()
415 | }
416 |
417 | #addNodeLabelsWithDelay() {
418 | // Wait a sec because errors occur if we call add_layout multitime once
419 | this.plot.add_layout(this.nodeLabels.titleModelLabel)
420 | setTimeout(() => {
421 | this.plot.add_layout(this.nodeLabels.foreignModelLabel)
422 | setTimeout(() => {
423 | this.plot.add_layout(this.nodeLabels.foreignColumnsLabel)
424 | }, 100)
425 | }, 100)
426 | }
427 |
428 | #applyDisplayTitleMode() {
429 | this.nodeLabels.foreignModelLabel.visible = !this.#showTitleMode
430 | this.nodeLabels.foreignColumnsLabel.visible = !this.#showTitleMode
431 | this.nodeLabels.titleModelLabel.visible = this.#showTitleMode
432 |
433 | if (this.#showTitleMode) {
434 | this.displayTitleModeToggle.label = "Display mode: title"
435 | this.circleRenderer.node_renderer.data_source.data["fill_color"] = this.nodeSource.data["circle_original_color"]
436 | this.circleRenderer.node_renderer.data_source.data["alpha"] = this.nodeSource.data["alpha"]
437 | this.circleRenderer.edge_renderer.data_source.data["alpha"] = this.edgeSource.data["alpha"]
438 | this.circleRenderer.node_renderer.data_source.change.emit()
439 | this.circleRenderer.edge_renderer.data_source.change.emit()
440 | } else {
441 | this.displayTitleModeToggle.label = "Display mode: foreign key"
442 | this.rectRenderer.node_renderer.data_source.data["fill_color"] = this.nodeSource.data["rect_original_color"]
443 | this.rectRenderer.node_renderer.data_source.data["alpha"] = this.nodeSource.data["alpha"]
444 | this.rectRenderer.edge_renderer.data_source.data["alpha"] = this.edgeSource.data["alpha"]
445 | this.rectRenderer.node_renderer.data_source.change.emit()
446 | this.rectRenderer.edge_renderer.data_source.change.emit()
447 | }
448 | }
449 |
450 | #findReLayoutXY(placedPositions, minMax) {
451 | const maxAttemptCount = 100
452 | const [minDistance, maxDistance] = minMax.distances()
453 | for (let t = 0; t < maxAttemptCount; t++) {
454 | const [randX, randY] = minMax.randXY()
455 | const ngPosition = placedPositions.some(([existX, existY]) => {
456 | const distanceX = existX - randX
457 | const distanceY = existY - randY
458 | const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY)
459 | return distance < minDistance || distance > maxDistance
460 | })
461 | if (!ngPosition) { return [randX, randY] }
462 | }
463 | return minMax.randXY()
464 | }
465 |
466 | #handleZoom() {
467 | const currentRange = this.cbObj.end - this.cbObj.start
468 | if (this.windowObj.stableRange === undefined) { this.#setStableRange(currentRange) }
469 | const previousDisplayChunksCount = this.#displayChunksCount
470 | const stableRange = this.windowObj.stableRange
471 |
472 | let displayChunksCount = this.#displayChunksCount
473 |
474 | // distance < 0: Zoom in
475 | // 0 < distance: Zoom out
476 | let distance = currentRange - stableRange
477 | const threshold = stableRange * 0.1
478 | if (Math.abs(distance) >= Math.abs(threshold)) {
479 | if (distance < 0) { // Zoom in
480 | displayChunksCount = Math.min(displayChunksCount + 1, this.chunkedNodes.length - 1)
481 | } else { // Zoom out
482 | displayChunksCount = Math.max(displayChunksCount - 1, 0)
483 | }
484 | }
485 | this.#setDisplayChunksCount(displayChunksCount)
486 | this.#setStableRange(currentRange)
487 | this.#executeZoom({ previousDisplayChunksCount })
488 | }
489 |
490 | #executeZoom({ previousDisplayChunksCount }) {
491 | if (this.#displayChunksCount === previousDisplayChunksCount) { return }
492 |
493 | this.#resetSearch()
494 | this.#setSelectingNode(null)
495 | this.#setSelectingNodeLabel(null)
496 | this.selectingNodeLabel.change.emit()
497 |
498 | const { showingNodes, selectedLayout } = this.#getShowingNodesAndSelectedLayout()
499 | this.#changeDisplayNodes(showingNodes)
500 |
501 | let closestNodeName
502 | if (previousDisplayChunksCount === null) {
503 | closestNodeName = null
504 | } else {
505 | // Find the closest node (to shift XY with updateLayout) only when have previousDisplayChunksCount
506 | const showingNodes = [...Object.keys(this.layoutsByChunk[previousDisplayChunksCount]), this.windowObj.selectedNode].filter(node => node)
507 | const closestNodeWithMinmumDistance = this.#findClosestNodeWithMinmumDistance(this.layoutsByChunk[previousDisplayChunksCount], showingNodes)
508 | closestNodeName = closestNodeWithMinmumDistance['closestNodeName']
509 | }
510 |
511 | const [nodesX, nodesY] = this.#updateLayout(selectedLayout, closestNodeName)
512 | const shiftedLayout = {}
513 | this.#nodesIndex.forEach((nodeName, i) => {
514 | shiftedLayout[nodeName] = [nodesX[i], nodesY[i]]
515 | })
516 | this.layoutProvider.graph_layout = shiftedLayout
517 | this.layoutProvider.change.emit()
518 | }
519 |
520 | #resetSearch() {
521 | this.#setSearchingTerm(null)
522 |
523 | this.#setSelectingNodeLabel(null)
524 | this.selectingNodeLabel.change.emit()
525 |
526 | searchBox.value = ""
527 | }
528 |
529 | get #mouseX() { return this.cbObj.x || this.windowObj.lastMouseX || 0 }
530 | get #mouseY() { return this.cbObj.y || this.windowObj.lastMouseY || 0 }
531 | get #shiftX() { return this.windowObj.previousShiftX || 0 }
532 | get #shiftY() { return this.windowObj.previousShiftY || 0 }
533 | get #nodesIndex() { return this.nodeSource.data["index"] }
534 | get #sourceNodes() { return this.edgeSource.data["start"] }
535 | get #targetNodes() { return this.edgeSource.data["end"] }
536 | get #sourceEdges() { return this.edgeSource.data["start"] }
537 | get #displayChunksCount() {
538 | if (this.windowObj.displayChunksCount === undefined) { this.#setDisplayChunksCount(0) }
539 | return this.windowObj.displayChunksCount || 0
540 | }
541 | get #selectedLayout() {
542 | const layout = this.layoutsByChunk[this.#displayChunksCount]
543 | if (this.windowObj.selectedNode) {
544 | layout[this.windowObj.selectedNode] = this.#wholeLayout[this.windowObj.selectedNode]
545 | }
546 | return layout
547 | }
548 | get #wholeLayout() { return this.layoutsByChunk.slice(-1)[0] }
549 | get #fixingZoom() {
550 | if (this.windowObj.fixingZoom === undefined) { this.#setFixingZoom(true) }
551 | return this.windowObj.fixingZoom
552 | }
553 | get #showingAssociation() {
554 | if (this.windowObj.showingAssociation === undefined) { this.#setShowingAssociation(true) }
555 | return this.windowObj.showingAssociation
556 | }
557 | get #searchingTerm() { return this.windowObj.searchingTerm }
558 | get #showTitleMode() {
559 | if (this.windowObj.showTitleMode === undefined) { this.#setShowTitleMode(true) }
560 | return this.windowObj.showTitleMode
561 | }
562 |
563 | // @param {Integer} value
564 | #setDisplayChunksCount(value) { this.windowObj.displayChunksCount = value }
565 | // @param {Float} value
566 | #setStableRange(value) { this.windowObj.stableRange = value }
567 | // @param {Float} value
568 | #setShiftX(value) { this.windowObj.previousShiftX = value }
569 | // @param {Float} value
570 | #setShiftY(value) { this.windowObj.previousShiftY = value }
571 | // @param { String } value
572 | #setSelectingNode(value) { this.windowObj.selectingNode = value }
573 | // @param {String} nodeName
574 | #setSelectedNode(nodeName) { this.windowObj.selectedNode = nodeName }
575 | // @param {Boolean} value
576 | #setFixingZoom(value) { this.windowObj.fixingZoom = value }
577 | // @param {String | null} value
578 | #setSelectingNodeLabel(value) {
579 | if (value) {
580 | const mode = this.#showingAssociation ? "associations" : "community"
581 | this.selectingNodeLabel.text = this.#searchingTerm ? `Searching: ${value}` : `Showing: ${value}'s ${mode}`
582 | } else {
583 | this.selectingNodeLabel.text = ""
584 | }
585 | }
586 | // @param {String} value
587 | #setSearchingTerm(value) { this.windowObj.searchingTerm = value }
588 | // @param {Boolean} value
589 | #setShowingAssociation(value) { this.windowObj.showingAssociation = value }
590 | // @param {Boolean} value
591 | #setShowTitleMode(value) { this.windowObj.showTitleMode = value }
592 | }
593 |
--------------------------------------------------------------------------------
/lib/erd_map/graph_renderer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ErdMap
4 | class GraphRenderer
5 | extend Forwardable
6 | def_delegators :@graph_renderer, :node_renderer
7 |
8 | attr_reader :graph_renderer
9 |
10 | VISIBLE = 1.0
11 | TRANSLUCENT = 0.01
12 | HIGHLIGHT_NODE_COLOR = "black"
13 | HIGHLIGHT_EDGE_COLOR = "orange"
14 | HIGHLIGHT_TEXT_COLOR = "white"
15 | BASIC_COLOR = "darkslategray"
16 | EMPHASIS_NODE_SIZE = 80
17 |
18 | def renderers
19 | [circle_renderer, rect_renderer]
20 | end
21 |
22 | def cardinality_label
23 | bokeh_models.LabelSet.new(
24 | x: "x",
25 | y: "y",
26 | text: "text",
27 | source: cardinality_data_source,
28 | text_font_size: "12pt",
29 | text_color: "text_color",
30 | text_alpha: { field: "alpha" },
31 | )
32 | end
33 |
34 | def js_args(plot)
35 | {
36 | graphRenderer: graph_renderer,
37 | rectRenderer: rect_renderer,
38 | circleRenderer: circle_renderer,
39 | layoutProvider: layout_provider,
40 | cardinalityDataSource: cardinality_data_source,
41 | connectionsData: graph.connections.to_json,
42 | layoutsByChunkData: graph.layouts_by_chunk.to_json,
43 | chunkedNodesData: graph.chunked_nodes.to_json,
44 | nodeWithCommunityIndexData: graph.node_with_community_index.to_json,
45 | searchBox: plot.button_set[:search_box],
46 | selectingNodeLabel: plot.button_set[:selecting_node_label],
47 | zoomModeToggle: plot.button_set[:zoom_mode_toggle],
48 | tapModeToggle: plot.button_set[:tap_mode_toggle],
49 | displayTitleModeToggle: plot.button_set[:display_title_mode_toggle],
50 | nodeLabels: {
51 | titleModelLabel: title_model_label,
52 | foreignModelLabel: foreign_model_label,
53 | foreignColumnsLabel: foreign_columns_label,
54 | },
55 | plot: plot.plot,
56 | VISIBLE: VISIBLE,
57 | TRANSLUCENT: TRANSLUCENT,
58 | HIGHLIGHT_NODE_COLOR: HIGHLIGHT_NODE_COLOR,
59 | HIGHLIGHT_EDGE_COLOR: HIGHLIGHT_EDGE_COLOR,
60 | HIGHLIGHT_TEXT_COLOR: HIGHLIGHT_TEXT_COLOR,
61 | BASIC_COLOR: BASIC_COLOR,
62 | EMPHASIS_NODE_SIZE: EMPHASIS_NODE_SIZE,
63 | }
64 | end
65 |
66 | private
67 |
68 | attr_reader :bokeh_models
69 | attr_reader :graph
70 |
71 | def initialize(graph)
72 | import_modules = ErdMap.py_call_modules.imported_modules
73 | @bokeh_models = import_modules[:bokeh_models]
74 | @graph = graph
75 | @graph_renderer = circle_renderer
76 | end
77 |
78 | def node_data_source
79 | nodes_x, nodes_y = graph.node_names.map { |node| graph.initial_layout[node] ? graph.initial_layout[node] : graph.whole_layout[node] }.transpose
80 | nodes_alpha = graph.node_names.map { |node| graph.initial_layout[node] ? VISIBLE : TRANSLUCENT }
81 |
82 | columns_label = []
83 | title_label = []
84 | rect_heights = []
85 | graph.node_names.map do |node_name|
86 | title_text = format_text([node_name], title: true)
87 | columns_text = [*title_text.scan("\n"), "\n", format_text(graph.association_columns[node_name])].join
88 | columns_label << columns_text
89 | title_label << [title_text, "\n", *columns_text.scan("\n")].join
90 |
91 | padding = 36
92 | line_count = columns_text.scan("\n").size + 1
93 | rect_heights << line_count * 20 + padding
94 | end
95 |
96 | bokeh_models.ColumnDataSource.new(
97 | data: {
98 | index: graph.node_names,
99 | alpha: nodes_alpha,
100 | x: nodes_x,
101 | y: nodes_y,
102 | radius: graph.node_radius,
103 | original_radius: graph.node_radius,
104 | rect_height: rect_heights,
105 | title_label: title_label,
106 | columns_label: columns_label,
107 | fill_color: graph.node_colors,
108 | circle_original_color: graph.node_colors,
109 | rect_original_color: graph.node_names.map { "white" },
110 | text_color: graph.node_names.map { BASIC_COLOR },
111 | text_outline_color: graph.node_names.map { nil },
112 | }
113 | )
114 | end
115 |
116 | def format_text(columns, title: false)
117 | max_chars_size = title ? 18 : 20
118 | columns.flat_map { |column| column.scan(/(\w{1,#{max_chars_size}})/) }.join("\n")
119 | end
120 |
121 | def circle_renderer
122 | @circle_renderer ||= bokeh_models.GraphRenderer.new(
123 | layout_provider: layout_provider,
124 | visible: true,
125 | ).tap do |renderer|
126 | renderer.node_renderer.data_source = node_data_source
127 | renderer.node_renderer.glyph = circle_glyph
128 | renderer.node_renderer.selection_glyph = renderer.node_renderer.glyph
129 | renderer.node_renderer.nonselection_glyph = renderer.node_renderer.glyph
130 | renderer.edge_renderer.data_source = edge_data_source
131 | renderer.edge_renderer.glyph = bokeh_models.MultiLine.new(
132 | line_color: { field: "line_color" },
133 | line_alpha: { field: "alpha" },
134 | line_width: 1,
135 | )
136 | end
137 | end
138 |
139 | def rect_renderer
140 | @rect_renderer ||= bokeh_models.GraphRenderer.new(
141 | layout_provider: layout_provider,
142 | visible: false,
143 | ).tap do |renderer|
144 | renderer.node_renderer.data_source = node_data_source
145 | renderer.node_renderer.glyph = rect_glyph
146 | renderer.node_renderer.selection_glyph = renderer.node_renderer.glyph
147 | renderer.node_renderer.nonselection_glyph = renderer.node_renderer.glyph
148 | renderer.edge_renderer.data_source = edge_data_source
149 | renderer.edge_renderer.glyph = bokeh_models.MultiLine.new(
150 | line_color: { field: "line_color" },
151 | line_alpha: { field: "alpha" },
152 | line_width: 1,
153 | )
154 | end
155 | end
156 |
157 | def circle_glyph
158 | bokeh_models.Circle.new(
159 | radius: "radius",
160 | radius_units: "screen",
161 | fill_color: { field: "fill_color" },
162 | fill_alpha: { field: "alpha" },
163 | line_alpha: { field: "alpha" },
164 | )
165 | end
166 |
167 | def rect_glyph
168 | bokeh_models.Rect.new(
169 | width: 150,
170 | height: { field: "rect_height" },
171 | width_units: "screen",
172 | height_units: "screen",
173 | fill_color: { field: "fill_color" },
174 | fill_alpha: { field: "alpha" },
175 | line_color: BASIC_COLOR,
176 | line_alpha: { field: "alpha" },
177 | )
178 | end
179 |
180 | def title_model_label
181 | bokeh_models.LabelSet.new(
182 | x: "x",
183 | y: "y",
184 | text: "index",
185 | source: graph_renderer.node_renderer.data_source,
186 | text_font_size: "12pt",
187 | text_color: { field: "text_color" },
188 | text_outline_color: { field: "text_outline_color" },
189 | text_align: "center",
190 | text_baseline: "middle",
191 | text_alpha: { field: "alpha" },
192 | visible: true,
193 | )
194 | end
195 |
196 | def foreign_model_label
197 | bokeh_models.LabelSet.new(
198 | x: "x",
199 | y: "y",
200 | text: { field: "title_label" },
201 | source: graph_renderer.node_renderer.data_source,
202 | text_font_size: "10pt",
203 | text_font_style: "bold",
204 | text_color: { field: "text_color" },
205 | text_outline_color: { field: "text_outline_color" },
206 | text_align: "center",
207 | text_baseline: "middle",
208 | text_alpha: { field: "alpha" },
209 | # visible: false,
210 | )
211 | end
212 |
213 | def foreign_columns_label
214 | bokeh_models.LabelSet.new(
215 | x: "x",
216 | y: "y",
217 | text: { field: "columns_label" },
218 | source: graph_renderer.node_renderer.data_source,
219 | text_font_size: "10pt",
220 | text_font_style: "normal",
221 | text_color: { field: "text_color" },
222 | text_outline_color: { field: "text_outline_color" },
223 | text_align: "center",
224 | text_baseline: "middle",
225 | text_alpha: { field: "alpha" },
226 | # visible: false,
227 | )
228 | end
229 |
230 | def edge_data_source
231 | edge_start, edge_end = graph.edges.map { |edge| [edge[0], edge[1]] }.transpose
232 | edges_alpha = graph.edges.map { |edge| graph.initial_nodes.include?(edge[0]) && graph.initial_nodes.include?(edge[1]) ? VISIBLE : TRANSLUCENT }
233 | bokeh_models.ColumnDataSource.new(
234 | data: {
235 | start: edge_start,
236 | end: edge_end,
237 | alpha: edges_alpha,
238 | line_color: graph.edges.map { BASIC_COLOR },
239 | }
240 | )
241 | end
242 |
243 | def cardinality_data_source
244 | return @cardinality_data_source if @cardinality_data_source
245 |
246 | @cardinality_data_source = bokeh_models.ColumnDataSource.new(
247 | data: {
248 | x: [],
249 | y: [],
250 | source: [],
251 | target: [],
252 | text: [],
253 | alpha: [],
254 | text_color: [],
255 | }
256 | )
257 |
258 | graph.edges.each do |(source_node, target_node)|
259 | next if source_node == target_node
260 |
261 | label_alpha = graph.initial_nodes.include?(source_node) && graph.initial_nodes.include?(target_node) ?
262 | VISIBLE : 0
263 |
264 | x_offset = 0.2
265 | y_offset = 0.3
266 | source_x, source_y = graph.initial_layout[source_node] || graph.whole_layout[source_node]
267 | target_x, target_y = graph.initial_layout[target_node] || graph.whole_layout[target_node]
268 | vector_x = target_x - source_x
269 | vector_y = target_y - source_y
270 | length = Math.sqrt(vector_x**2 + vector_y**2)
271 |
272 | @cardinality_data_source.data[:x] << source_x + (vector_x / length) * x_offset
273 | @cardinality_data_source.data[:y] << source_y + (vector_y / length) * y_offset
274 | @cardinality_data_source.data[:source] << source_node
275 | @cardinality_data_source.data[:target] << target_node
276 | @cardinality_data_source.data[:text] << "1"
277 | @cardinality_data_source.data[:alpha] << label_alpha
278 |
279 | @cardinality_data_source.data[:x] << target_x - (vector_x / length) * x_offset
280 | @cardinality_data_source.data[:y] << target_y - (vector_y / length) * y_offset
281 | @cardinality_data_source.data[:source] << source_node
282 | @cardinality_data_source.data[:target] << target_node
283 | @cardinality_data_source.data[:text] << "n" # FIXME: Show "1" when has_one association
284 | @cardinality_data_source.data[:alpha] << label_alpha
285 | end
286 | @cardinality_data_source.data[:text_color] = Array.new(@cardinality_data_source.data[:x].to_a.size) { BASIC_COLOR }
287 | @cardinality_data_source
288 | end
289 |
290 | def layout_provider
291 | return @layout_provider if @layout_provider
292 |
293 | nodes_x, nodes_y = graph.node_names.map { |node| graph.initial_layout[node] ? graph.initial_layout[node] : graph.whole_layout[node] }.transpose
294 | graph_layout = graph.node_names.zip(nodes_x, nodes_y).map { |node, x, y| [node, [x, y]] }.to_h
295 | @layout_provider = bokeh_models.StaticLayoutProvider.new(graph_layout: graph_layout)
296 | end
297 | end
298 | end
299 |
--------------------------------------------------------------------------------
/lib/erd_map/map_builder.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ErdMap
4 | class MapBuilder
5 | def execute
6 | import_modules
7 | @graph = ErdMap::Graph.new
8 | @graph_renderer = ErdMap::GraphRenderer.new(@graph)
9 | save(build_layout)
10 | end
11 |
12 | private
13 |
14 | attr_reader :bokeh_io, :bokeh_models, :bokeh_plotting
15 | attr_reader :graph, :graph_renderer
16 |
17 | def import_modules
18 | import_modules = ErdMap.py_call_modules.imported_modules
19 | @bokeh_io = import_modules[:bokeh_io]
20 | @bokeh_models = import_modules[:bokeh_models]
21 | @bokeh_plotting = import_modules[:bokeh_plotting]
22 | end
23 |
24 | def build_layout
25 | plot = Plot.new(graph)
26 | graph_renderer.renderers.each do |renderer|
27 | plot.renderers.append(renderer)
28 | renderer.node_renderer.data_source.selected.js_on_change("indices", toggle_tapped)
29 | end
30 | plot.add_layout(graph_renderer.cardinality_label)
31 | bokeh_io.curdoc.js_on_event("document_ready", setup_graph_manager(plot))
32 |
33 | bokeh_models.Column.new(
34 | children: [
35 | bokeh_models.Row.new(
36 | children: [
37 | plot.button_set[:left_spacer],
38 | plot.button_set[:selecting_node_label],
39 | plot.button_set[:search_box],
40 | plot.button_set[:zoom_mode_toggle],
41 | plot.button_set[:tap_mode_toggle],
42 | plot.button_set[:display_title_mode_toggle],
43 | plot.button_set[:re_layout_button],
44 | plot.button_set[:zoom_in_button],
45 | plot.button_set[:zoom_out_button],
46 | plot.button_set[:re_compute_button],
47 | plot.button_set[:right_spacer],
48 | ],
49 | sizing_mode: "stretch_width",
50 | ),
51 | plot.plot,
52 | ],
53 | sizing_mode: "stretch_both",
54 | )
55 | end
56 |
57 | def save(layout)
58 | tmp_dir = Rails.root.join("tmp", "erd_map")
59 | FileUtils.makedirs(tmp_dir) unless Dir.exist?(tmp_dir)
60 | output_path = File.join(tmp_dir, "map.html")
61 |
62 | bokeh_io.output_file(output_path)
63 | bokeh_io.save(layout)
64 | puts output_path
65 | end
66 |
67 | def setup_graph_manager(plot)
68 | bokeh_models.CustomJS.new(
69 | args: graph_renderer.js_args(plot),
70 | code: <<~JS
71 | #{graph_manager}
72 | window.graphManager = new GraphManager({
73 | graphRenderer,
74 | rectRenderer,
75 | circleRenderer,
76 | layoutProvider,
77 | connectionsData,
78 | layoutsByChunkData,
79 | chunkedNodesData,
80 | nodeWithCommunityIndexData,
81 | selectingNodeLabel,
82 | searchBox,
83 | zoomModeToggle,
84 | tapModeToggle,
85 | displayTitleModeToggle,
86 | nodeLabels,
87 | plot,
88 | windowObj: window,
89 | })
90 | JS
91 | )
92 | end
93 |
94 | def toggle_tapped
95 | bokeh_models.CustomJS.new(
96 | code: <<~JS
97 | window.graphManager.cbObj = cb_obj
98 | window.graphManager.toggleTapped()
99 | JS
100 | )
101 | end
102 |
103 | def graph_manager
104 | @graph_manager ||= File.read(__dir__ + "/graph_manager.js")
105 | end
106 |
107 | class << self
108 | def build
109 | Rails.logger.info "build start"
110 | new.execute
111 | Rails.logger.info "build completed"
112 | end
113 | end
114 | end
115 | end
116 |
--------------------------------------------------------------------------------
/lib/erd_map/plot.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ErdMap
4 | class Plot
5 | extend Forwardable
6 | def_delegators :@plot, :renderers, :add_layout
7 |
8 | def plot
9 | return @plot if @plot
10 |
11 | padding_ratio = 0.1
12 | x_min, x_max, y_min, y_max = graph.initial_layout.values.transpose.map(&:minmax).flatten
13 | x_padding, y_padding = [(x_max - x_min) * padding_ratio, (y_max - y_min) * padding_ratio]
14 | @plot = bokeh_models.Plot.new(
15 | sizing_mode: "stretch_both",
16 | x_range: bokeh_models.Range1d.new(start: x_min - x_padding, end: x_max + x_padding),
17 | y_range: bokeh_models.Range1d.new(start: y_min - y_padding, end: y_max + y_padding),
18 | tools: [
19 | wheel_zoom_tool = bokeh_models.WheelZoomTool.new,
20 | bokeh_models.ResetTool.new,
21 | bokeh_models.PanTool.new,
22 | bokeh_models.TapTool.new,
23 | ],
24 | ).tap do |plot|
25 | plot.toolbar.active_scroll = wheel_zoom_tool
26 | end
27 | end
28 |
29 | def button_set
30 | @button_set ||= ButtonSet.new
31 | end
32 |
33 | private
34 |
35 | attr_reader :graph
36 |
37 | def initialize(graph)
38 | @graph = graph
39 | register_callback
40 | end
41 |
42 | def register_callback
43 | plot.x_range.js_on_change("start", custom_js("triggerZoom"))
44 | plot.x_range.js_on_change("end", custom_js("triggerZoom"))
45 | plot.js_on_event("mousemove", custom_js("toggleHovered"))
46 | plot.js_on_event("mousemove", bokeh_models.CustomJS.new(code: save_mouse_position))
47 | plot.js_on_event("reset", custom_js("resetPlot"))
48 | end
49 |
50 | def bokeh_models
51 | @bokeh_models ||= ErdMap.py_call_modules.imported_modules[:bokeh_models]
52 | end
53 |
54 | def custom_js(function_name)
55 | bokeh_models.CustomJS.new(
56 | code: <<~JS
57 | window.graphManager.cbObj = cb_obj
58 | window.graphManager.#{function_name}()
59 | JS
60 | )
61 | end
62 |
63 | def save_mouse_position
64 | <<~JS
65 | if (window.saveMousePosition !== undefined) { clearTimeout(window.saveMousePosition) }
66 | window.saveMousePosition = setTimeout(function() {
67 | window.lastMouseX = cb_obj.x
68 | window.lastMouseY = cb_obj.y
69 | }, 100)
70 | JS
71 | end
72 |
73 | class ButtonSet
74 | extend Forwardable
75 | def_delegators :@button_set, :[]
76 |
77 | private
78 |
79 | def initialize
80 | @button_set = {
81 | left_spacer: left_spacer,
82 | selecting_node_label: selecting_node_label,
83 | search_box: search_box,
84 | zoom_mode_toggle: zoom_mode_toggle,
85 | tap_mode_toggle: tap_mode_toggle,
86 | display_title_mode_toggle: display_title_mode_toggle,
87 | re_layout_button: re_layout_button,
88 | zoom_in_button: zoom_in_button,
89 | zoom_out_button: zoom_out_button,
90 | re_compute_button: re_compute_button,
91 | right_spacer: right_spacer,
92 | }
93 | end
94 |
95 | def left_spacer
96 | bokeh_models.Spacer.new(width: 0, sizing_mode: "stretch_width")
97 | end
98 |
99 | def right_spacer
100 | bokeh_models.Spacer.new(width: 30, sizing_mode: "fixed")
101 | end
102 |
103 | def selecting_node_label
104 | bokeh_models.Div.new(
105 | text: "",
106 | height: 28,
107 | styles: { display: :flex, align_items: :center },
108 | )
109 | end
110 |
111 | def search_box
112 | bokeh_models.TextInput.new(placeholder: "🔍 Search model", width: 200).tap do |input|
113 | input.js_on_change("value", custom_js("searchNodes"))
114 | end
115 | end
116 |
117 | def zoom_mode_toggle
118 | bokeh_models.Button.new(label: "Wheel mode: fix", button_type: "default").tap do |button|
119 | button.js_on_click(custom_js("toggleZoomMode"))
120 | end
121 | end
122 |
123 | def tap_mode_toggle
124 | bokeh_models.Button.new(label: "Tap mode: association", button_type: "default").tap do |button|
125 | button.js_on_click(custom_js("toggleTapMode"))
126 | end
127 | end
128 |
129 | def display_title_mode_toggle
130 | bokeh_models.Button.new(label: "Display mode: title", button_type: "default").tap do |button|
131 | button.js_on_click(custom_js("toggleDisplayTitleMode"))
132 | end
133 | end
134 |
135 | def re_layout_button
136 | bokeh_models.Button.new(label: "Re-Layout", button_type: "default").tap do |button|
137 | button.js_on_click(custom_js("reLayout"))
138 | end
139 | end
140 |
141 | def zoom_in_button
142 | bokeh_models.Button.new(label: "Zoom In", button_type: "primary").tap do |button|
143 | button.js_on_click(custom_js("zoomIn",))
144 | end
145 | end
146 |
147 | def zoom_out_button
148 | bokeh_models.Button.new(label: "Zoom Out", button_type: "success").tap do |button|
149 | button.js_on_click(custom_js("zoomOut"))
150 | end
151 | end
152 |
153 | def re_compute_button
154 | bokeh_models.Button.new(label: "Re-Compute", button_type: "default").tap do |button|
155 | button.js_on_click(
156 | bokeh_models.CustomJS.new(
157 | args: { button: button },
158 | code: <<~JS
159 | button.disabled = true
160 | button.label = "Computing ..."
161 |
162 | fetch("/erd_map", { method: "PUT" })
163 | .then(response => {
164 | if (response.ok) { return response.text() }
165 | else { return response.json().then(json => { throw new Error(json.message) }) }
166 | })
167 | .then(data => { window.location.reload() })
168 | .catch(error => {
169 | alert(error.message)
170 | console.error(error)
171 |
172 | button.disabled = false
173 | button.label = "Re-Compute"
174 | })
175 | JS
176 | )
177 | )
178 | end
179 | end
180 |
181 | def bokeh_models
182 | @bokeh_models ||= ErdMap.py_call_modules.imported_modules[:bokeh_models]
183 | end
184 |
185 | def custom_js(function_name)
186 | bokeh_models.CustomJS.new(
187 | code: <<~JS
188 | window.graphManager.cbObj = cb_obj
189 | window.graphManager.#{function_name}()
190 | JS
191 | )
192 | end
193 | end
194 | end
195 | end
196 |
--------------------------------------------------------------------------------
/lib/erd_map/py_call_modules.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "pycall"
4 |
5 | module ErdMap
6 | class PyCallModules
7 | def initialize
8 | @networkx = PyCall.import_module("networkx")
9 | @bokeh_io = PyCall.import_module("bokeh.io")
10 | @bokeh_models = PyCall.import_module("bokeh.models")
11 | @bokeh_plotting = PyCall.import_module("bokeh.plotting")
12 | @networkx_community = PyCall.import_module("networkx.algorithms.community")
13 | end
14 |
15 | def imported_modules
16 | {
17 | networkx: @networkx,
18 | bokeh_io: @bokeh_io,
19 | bokeh_models: @bokeh_models,
20 | bokeh_plotting: @bokeh_plotting,
21 | networkx_community: @networkx_community,
22 | }
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/erd_map/version.rb:
--------------------------------------------------------------------------------
1 | module ErdMap
2 | VERSION = "0.1.2"
3 | end
4 |
--------------------------------------------------------------------------------
/lib/tasks/erd_map_tasks.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | desc "Compute erd_map"
4 | task erd_map: :environment do
5 | puts "Map computing start."
6 | ErdMap::MapBuilder.build
7 | puts "Map computing completed."
8 | end
9 |
--------------------------------------------------------------------------------
/sample/images/mastdon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makicamel/erd_map/cd1859bc694a0e74f035c6a03b133c9c25ccbe57/sample/images/mastdon.png
--------------------------------------------------------------------------------
/sample/images/redmine.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makicamel/erd_map/cd1859bc694a0e74f035c6a03b133c9c25ccbe57/sample/images/redmine.png
--------------------------------------------------------------------------------
/sample/sample.md:
--------------------------------------------------------------------------------
1 | # Sample
2 |
3 | This directory contains ErdMap files generated from open-source Rails applications. Open the HTML files in your browser to explore.
4 |
5 | - Redmine
6 | - https://github.com/redmine/redmine
7 | - Mastodon
8 | - https://github.com/mastodon/mastodon
9 |
--------------------------------------------------------------------------------
/spec/erd_map_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "rails_helper"
4 |
5 | RSpec.describe ErdMap do
6 | it "has a version number" do
7 | expect(ErdMap::VERSION).to be_present
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/spec/fake_app/.gitignore:
--------------------------------------------------------------------------------
1 | /db/**.sqlite3*
2 | /log/
3 | /tmp/pids/server.pid
4 |
--------------------------------------------------------------------------------
/spec/fake_app/Rakefile:
--------------------------------------------------------------------------------
1 | # Add your own tasks in files placed in lib/tasks ending in .rake,
2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
3 |
4 | require_relative "config/application"
5 |
6 | Rails.application.load_tasks
7 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/address.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Address < ApplicationRecord
6 | belongs_to :country
7 | belongs_to :state, optional: true
8 | belongs_to :user, optional: true, touch: true
9 |
10 | has_many :shipments, inverse_of: :address
11 | end
12 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/adjustment.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Adjustment < ApplicationRecord
6 | with_options polymorphic: true do
7 | belongs_to :adjustable, touch: true
8 | belongs_to :source
9 | end
10 | belongs_to :order, inverse_of: :all_adjustments
11 | belongs_to :promotion_action, foreign_key: :source_id, optional: true # created only for has_free_shipping?
12 | end
13 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/admin_user.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree (Spree.admin_user_class)
4 | # https://github.com/spree/spree
5 | class AdminUser < ApplicationRecord
6 | end
7 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/application_record.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal
2 |
3 | class ApplicationRecord < ActiveRecord::Base
4 | self.abstract_class = true
5 | end
6 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/asset.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Asset < ApplicationRecord
6 | belongs_to :viewable, polymorphic: true, touch: true
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/calculator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Calculator < ApplicationRecord
6 | belongs_to :calculable, polymorphic: true, optional: true, inverse_of: :calculator
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/classification.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Classification < ApplicationRecord
6 | with_options inverse_of: :classifications, touch: true do
7 | belongs_to :product
8 | belongs_to :taxon
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/cms_page.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class CmsPage < ApplicationRecord
6 | belongs_to :store, touch: true
7 |
8 | has_many :cms_sections
9 | has_many :menu_items, as: :linked_resource
10 | end
11 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/cms_section.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class CmsSection < ApplicationRecord
6 | belongs_to :cms_page, touch: true
7 | has_one :image_one, dependent: :destroy, as: :viewable
8 | has_one :image_two, dependent: :destroy, as: :viewable
9 | has_one :image_three, dependent: :destroy, as: :viewable
10 | end
11 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/country.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Country < ApplicationRecord
6 | has_many :addresses, dependent: :restrict_with_error
7 | has_many :states,
8 | -> { order name: :asc },
9 | inverse_of: :country,
10 | dependent: :destroy
11 | has_many :zone_members,
12 | -> { where(zoneable_type: 'Country') },
13 | dependent: :destroy,
14 | foreign_key: :zoneable_id
15 | has_many :zones, through: :zone_members
16 | end
17 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/coupon_code.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class CouponCode < ApplicationRecord
6 | belongs_to :promotion, touch: true
7 | belongs_to :order
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/credit_card.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class CreditCard < ApplicationRecord
6 | belongs_to :payment_method
7 | belongs_to :user, foreign_key: 'user_id',
8 | optional: true
9 | has_many :payments, as: :source
10 | end
11 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/customer_return.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class CustomerReturn < ApplicationRecord
6 | belongs_to :stock_location
7 | belongs_to :store, inverse_of: :customer_returns
8 |
9 | has_many :reimbursements, inverse_of: :customer_return
10 | has_many :return_items, inverse_of: :customer_return
11 | has_many :return_authorizations, through: :return_items
12 | end
13 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/data_feed.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class DataFeed < ApplicationRecord
6 | belongs_to :store, foreign_key: 'store_id'
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/digital.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Digital < ApplicationRecord
6 | belongs_to :variant
7 | has_many :digital_links, dependent: :destroy
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/digital_link.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class DigitalLink < ApplicationRecord
6 | belongs_to :digital
7 | belongs_to :line_item
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/export.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Export < ApplicationRecord
6 | belongs_to :store
7 | belongs_to :user, class_name: 'AdminUser'
8 | belongs_to :vendor, -> { with_deleted }, optional: true
9 | end
10 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/inventory_unit.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class InventoryUnit < ApplicationRecord
6 | with_options inverse_of: :inventory_units do
7 | belongs_to :variant, -> { with_deleted }
8 | belongs_to :order
9 | belongs_to :shipment, touch: true, optional: true
10 | has_many :return_items, inverse_of: :inventory_unit
11 | has_many :return_authorizations, through: :return_items
12 | belongs_to :line_item
13 | end
14 |
15 | belongs_to :original_return_item
16 | end
17 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/legacy_user.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class LegacyUser < ApplicationRecord
6 | end
7 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/line_item.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class LineItem < ApplicationRecord
6 | with_options inverse_of: :line_items do
7 | belongs_to :order, touch: true
8 | belongs_to :variant, -> { with_deleted }
9 | end
10 | belongs_to :tax_category, -> { with_deleted }
11 |
12 | has_one :product, -> { with_deleted }, through: :variant
13 |
14 | has_many :adjustments, as: :adjustable, dependent: :destroy
15 | has_many :inventory_units, inverse_of: :line_item
16 | has_many :digital_links, dependent: :destroy
17 | end
18 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/log_entry.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class LogEntry < ApplicationRecord
6 | belongs_to :source, polymorphic: true
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/menu.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Menu < ApplicationRecord
6 | has_many :menu_items, dependent: :destroy
7 | belongs_to :store, touch: true
8 | has_one :root, -> { where(parent_id: nil) }, dependent: :destroy
9 | end
10 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/menu_item.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class MenuItem < ApplicationRecord
6 | belongs_to :menu, touch: true
7 | has_one :icon, as: :viewable, dependent: :destroy
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/oauth_access_grant.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from doorkeeper
4 | # https://github.com/doorkeeper-gem/doorkeeper
5 | class OauthAccessGrant < ApplicationRecord
6 | belongs_to :application, optional: true,
7 | inverse_of: :access_grants
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/oauth_access_token.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from doorkeeper
4 | # https://github.com/doorkeeper-gem/doorkeeper
5 | class OauthAccessToken < ApplicationRecord
6 | belongs_to :application, inverse_of: :access_tokens,
7 | optional: true
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/oauth_application.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from doorkeeper
4 | # https://github.com/doorkeeper-gem/doorkeeper
5 | class OauthApplication < ApplicationRecord
6 | has_many :access_grants,
7 | foreign_key: :application_id,
8 | dependent: :delete_all
9 |
10 | has_many :access_tokens,
11 | foreign_key: :application_id,
12 | dependent: :delete_all,
13 | class_name: "OauthAccessToken"
14 | has_many :authorized_tokens,
15 | -> { where(revoked_at: nil) },
16 | foreign_key: :application_id,
17 | class_name: "OauthAccessToken"
18 |
19 | has_many :authorized_applications,
20 | through: :authorized_tokens,
21 | source: :application
22 | end
23 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/option_type.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class OptionType < ApplicationRecord
6 | with_options dependent: :destroy, inverse_of: :option_type do
7 | has_many :option_values, -> { order(:position) }
8 | has_many :product_option_types
9 | end
10 | has_many :products, through: :product_option_types
11 | has_many :option_type_prototypes
12 | has_many :prototypes, through: :option_type_prototypes
13 | end
14 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/option_type_prototype.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class OptionTypePrototype < ApplicationRecord
6 | belongs_to :option_type
7 | belongs_to :prototype
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/option_value.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class OptionValue < ApplicationRecord
6 | belongs_to :option_type, touch: true, inverse_of: :option_values
7 | has_many :option_value_variants
8 | has_many :variants, through: :option_value_variants
9 | has_many :products, through: :variants
10 | end
11 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/option_value_variant.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class OptionValueVariant < ApplicationRecord
6 | belongs_to :option_value
7 | belongs_to :variant, touch: true
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/order.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Order < ApplicationRecord
6 | belongs_to :user, class_name: "::User", optional: true
7 | belongs_to :created_by, class_name: "::AdminUser", optional: true
8 | belongs_to :approver, class_name: "::AdminUser", optional: true
9 | belongs_to :canceler, class_name: "::AdminUser", optional: true
10 |
11 | belongs_to :bill_address, foreign_key: :bill_address_id,
12 | optional: true, dependent: :destroy
13 | belongs_to :ship_address, foreign_key: :ship_address_id,
14 | optional: true, dependent: :destroy
15 | belongs_to :store
16 |
17 | with_options dependent: :destroy do
18 | has_many :state_changes, as: :stateful
19 | has_many :line_items, -> { order(:created_at) }, inverse_of: :order
20 | has_many :payments
21 | has_many :return_authorizations, inverse_of: :order
22 | has_many :adjustments, -> { order(:created_at) }, as: :adjustable
23 | end
24 | has_many :reimbursements, inverse_of: :order
25 | has_many :line_item_adjustments, through: :line_items, source: :adjustments
26 | has_many :inventory_units, inverse_of: :order
27 | has_many :return_items, through: :inventory_units
28 | has_many :variants, through: :line_items
29 | has_many :products, through: :variants
30 | has_many :refunds, through: :payments
31 | has_many :all_adjustments,
32 | foreign_key: :order_id,
33 | dependent: :destroy,
34 | inverse_of: :order
35 |
36 | has_many :order_promotions
37 | has_many :promotions, through: :order_promotions
38 |
39 | has_many :shipments, dependent: :destroy, inverse_of: :order
40 | has_many :shipment_adjustments, through: :shipments, source: :adjustments
41 | end
42 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/order_promotion.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class OrderPromotion < ApplicationRecord
6 | belongs_to :order
7 | belongs_to :promotion
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/payment.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Payment < ApplicationRecord
6 | with_options inverse_of: :payments do
7 | belongs_to :order, touch: true
8 | belongs_to :payment_method, -> { with_deleted }
9 | end
10 | belongs_to :source, polymorphic: true
11 |
12 | has_many :offsets, -> { offset_payment }, foreign_key: :source_id
13 | has_many :log_entries, as: :source
14 | has_many :state_changes, as: :stateful
15 | has_many :capture_events
16 | has_many :refunds, inverse_of: :payment
17 | end
18 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/payment_capture_event.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class PaymentCaptureEvent < ApplicationRecord
6 | belongs_to :payment
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/payment_method.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class PaymentMethod < ApplicationRecord
6 | has_many :store_payment_methods
7 | has_many :stores, through: :store_payment_methods
8 |
9 | with_options dependent: :restrict_with_error do
10 | has_many :payments, inverse_of: :payment_method
11 | has_many :credit_cards
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/payment_source.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class PaymentSource < ApplicationRecord
6 | belongs_to :payment_method
7 | belongs_to :user, optional: true
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/preference.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Preference < ApplicationRecord
6 | end
7 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/price.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Price < ApplicationRecord
6 | belongs_to :variant, -> { with_deleted }, inverse_of: :prices, touch: true
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/product.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Product < ApplicationRecord
6 | has_many :product_option_types, -> { order(:position) }, dependent: :destroy, inverse_of: :product
7 | has_many :option_types, through: :product_option_types
8 | has_many :product_properties, dependent: :destroy, inverse_of: :product
9 | has_many :properties, through: :product_properties
10 |
11 | has_many :menu_items, as: :linked_resource
12 |
13 | has_many :classifications, -> { order(created_at: :asc) }, dependent: :delete_all, inverse_of: :product
14 | has_many :taxons, through: :classifications, before_remove: :remove_taxon
15 | has_many :taxonomies, through: :taxons
16 |
17 | has_many :product_promotion_rules
18 | has_many :promotion_rules, through: :product_promotion_rules
19 |
20 | has_many :promotions, through: :promotion_rules
21 |
22 | has_many :possible_promotions, -> { advertised.active }, through: :promotion_rules,
23 | source: :promotion
24 |
25 | belongs_to :tax_category
26 | belongs_to :shipping_category, inverse_of: :products
27 |
28 | has_one :master,
29 | -> { where is_master: true },
30 | inverse_of: :product,
31 | class_name: 'Variant'
32 |
33 | has_many :variants,
34 | -> { where(is_master: false).order(:position) },
35 | inverse_of: :product,
36 | class_name: 'Variant'
37 |
38 | has_many :variants_including_master,
39 | -> { order(:position) },
40 | inverse_of: :product,
41 | class_name: 'Variant',
42 | dependent: :destroy
43 |
44 | has_many :prices, -> { order('spree_variants.position, spree_variants.id, currency') }, through: :variants
45 |
46 | has_many :stock_items, through: :variants_including_master
47 |
48 | has_many :line_items, through: :variants_including_master
49 | has_many :orders, through: :line_items
50 |
51 | has_many :variant_images, -> { order(:position) }, source: :images, through: :variants_including_master
52 | has_many :variant_images_without_master, -> { order(:position) }, source: :images, through: :variants
53 |
54 | has_many :option_value_variants, through: :variants
55 | has_many :option_values, through: :variants
56 |
57 | has_many :prices_including_master, -> { non_zero }, through: :variants_including_master, source: :prices
58 |
59 | has_many :store_products
60 | has_many :stores, through: :store_products
61 | has_many :digitals, through: :variants_including_master
62 | end
63 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/product_option_type.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class ProductOptionType < ApplicationRecord
6 | with_options inverse_of: :product_option_types do
7 | belongs_to :product
8 | belongs_to :option_type
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/product_promotion_rule.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class ProductPromotionRule < ApplicationRecord
6 | belongs_to :product
7 | belongs_to :promotion_rule
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/product_property.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class ProductProperty < ApplicationRecord
6 | with_options inverse_of: :product_properties do
7 | belongs_to :product, touch: true
8 | belongs_to :property, touch: true
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/promotion.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Promotion < ApplicationRecord
6 | belongs_to :promotion_category, optional: true
7 | has_many :promotion_rules, autosave: true, dependent: :destroy
8 | has_many :promotion_actions, autosave: true, dependent: :destroy
9 | has_many :coupon_codes, -> { order(created_at: :asc) }, dependent: :destroy
10 | has_many :order_promotions
11 | has_many :orders, through: :order_promotions
12 | has_many :store_promotions
13 | has_many :stores, through: :store_promotions
14 | end
15 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/promotion_action.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class PromotionAction < ApplicationRecord
6 | belongs_to :promotion
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/promotion_action_line_item.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class PromotionActionLineItem < ApplicationRecord
6 | belongs_to :promotion_action
7 | belongs_to :variant
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/promotion_category.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class PromotionCategory < ApplicationRecord
6 | has_many :promotions
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/promotion_rule.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class PromotionRule < ApplicationRecord
6 | belongs_to :promotion, inverse_of: :promotion_rule_user
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/promotion_rule_taxon.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class PromotionRuleTaxon < ApplicationRecord
6 | belongs_to :promotion_rule
7 | belongs_to :taxon
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/promotion_rule_user.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class PromotionRuleUser < ApplicationRecord
6 | belongs_to :promotion_rule
7 | belongs_to :user, class_name: "::User"
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/property.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Property < ApplicationRecord
6 | has_many :property_prototypes
7 | has_many :prototypes, through: :property_prototypes
8 |
9 | has_many :product_properties, dependent: :delete_all, inverse_of: :property
10 | has_many :products, through: :product_properties
11 | end
12 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/property_prototype.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class PropertyPrototype < ApplicationRecord
6 | belongs_to :prototype
7 | belongs_to :property
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/prototype.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Prototype < ApplicationRecord
6 | has_many :property_prototypes
7 | has_many :properties, through: :property_prototypes
8 |
9 | has_many :option_type_prototypes
10 | has_many :option_types, through: :option_type_prototypes
11 |
12 | has_many :prototype_taxons
13 | has_many :taxons, through: :prototype_taxons
14 | end
15 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/prototype_taxon.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class PrototypeTaxon < ApplicationRecord
6 | belongs_to :taxon
7 | belongs_to :prototype
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/refund.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Refund < ApplicationRecord
6 | with_options inverse_of: :refunds do
7 | belongs_to :payment
8 | belongs_to :reimbursement, optional: true
9 | end
10 | belongs_to :reason, foreign_key: :refund_reason_id
11 | belongs_to :refunder, class_name: "::AdminUser", optional: true
12 |
13 | has_many :log_entries, as: :source
14 | end
15 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/refund_reason.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class RefundReason < ApplicationRecord
6 | has_many :refunds, dependent: :restrict_with_error
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/reimbursement.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Reimbursement < ApplicationRecord
6 | with_options inverse_of: :reimbursements do
7 | belongs_to :order
8 | belongs_to :customer_return, touch: true, optional: true
9 | end
10 |
11 | with_options inverse_of: :reimbursement do
12 | has_many :refunds
13 | has_many :credits
14 | has_many :return_items
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/reimbursement_type.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class ReimbursementType < ApplicationRecord
6 | has_many :return_items
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/return_authorization.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class ReturnAuthorization < ApplicationRecord
6 | belongs_to :order, inverse_of: :return_authorizations
7 |
8 | has_many :return_items, inverse_of: :return_authorization, dependent: :destroy
9 | with_options through: :return_items do
10 | has_many :inventory_units
11 | has_many :customer_returns
12 | end
13 |
14 | belongs_to :stock_location
15 | belongs_to :reason, foreign_key: :return_authorization_reason_id
16 | end
17 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/return_authorization_reason.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class ReturnAuthorizationReason < ApplicationRecord
6 | has_many :return_authorizations, dependent: :restrict_with_error
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/return_item.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class ReturnItem < ApplicationRecord
6 | with_options inverse_of: :return_items do
7 | belongs_to :return_authorization
8 | belongs_to :inventory_unit
9 | belongs_to :customer_return
10 | belongs_to :reimbursement
11 | end
12 | has_many :exchange_inventory_units,
13 | foreign_key: :original_return_item_id,
14 | inverse_of: :original_return_item
15 | belongs_to :exchange_variant
16 | belongs_to :preferred_reimbursement_type
17 | belongs_to :override_reimbursement_type
18 | end
19 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/role.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Role < ApplicationRecord
6 | has_many :role_users, dependent: :destroy
7 | has_many :users, through: :role_users, class_name: "::User"
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/role_user.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class RoleUser < ApplicationRecord
6 | belongs_to :role
7 | belongs_to :user, class_name: '::AdminUser'
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/shipment.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Shipment < ApplicationRecord
6 | with_options inverse_of: :shipments do
7 | belongs_to :address
8 | belongs_to :order, touch: true
9 | end
10 | belongs_to :stock_location, -> { with_deleted }
11 |
12 | with_options dependent: :delete_all do
13 | has_many :adjustments, as: :adjustable
14 | has_many :inventory_units, inverse_of: :shipment
15 | has_many :shipping_rates, -> { order(:cost) }
16 | has_many :state_changes, as: :stateful
17 | end
18 | has_many :shipping_methods, through: :shipping_rates
19 | has_many :variants, through: :inventory_units
20 | has_one :selected_shipping_rate, -> { where(selected: true).order(:cost) }, class_name: "ShippingRate"
21 | end
22 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/shipping_category.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class ShippingCategory < ApplicationRecord
6 | with_options inverse_of: :shipping_category do
7 | has_many :products
8 | has_many :shipping_method_categories
9 | end
10 | has_many :shipping_methods, through: :shipping_method_categories
11 | end
12 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/shipping_method.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class ShippingMethod < ApplicationRecord
6 | has_many :shipping_method_categories, dependent: :destroy
7 | has_many :shipping_categories, through: :shipping_method_categories
8 | has_many :shipping_rates, inverse_of: :shipping_method
9 | has_many :shipments, through: :shipping_rates
10 |
11 | has_many :shipping_method_zones,
12 | foreign_key: 'shipping_method_id'
13 | has_many :zones, through: :shipping_method_zones
14 |
15 | belongs_to :tax_category, -> { with_deleted }, optional: true
16 | end
17 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/shipping_method_category.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class ShippingMethodCategory < ApplicationRecord
6 | belongs_to :shipping_method
7 | belongs_to :shipping_category, inverse_of: :shipping_method_categories
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/shipping_method_zone.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class ShippingMethodZone < ApplicationRecord
6 | belongs_to :shipping_method, -> { with_deleted }, inverse_of: :shipping_method_zones
7 | belongs_to :zone, inverse_of: :shipping_method_zones
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/shipping_rate.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class ShippingRate < ApplicationRecord
6 | belongs_to :shipment
7 | belongs_to :tax_rate, -> { with_deleted }
8 | belongs_to :shipping_method, -> { with_deleted }, inverse_of: :shipping_rates
9 | end
10 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/state.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class State < ApplicationRecord
6 | belongs_to :country
7 | has_many :addresses, dependent: :restrict_with_error
8 |
9 | has_many :zone_members,
10 | -> { where(zoneable_type: 'State') },
11 | dependent: :destroy,
12 | foreign_key: :zoneable_id
13 |
14 | has_many :zones, through: :zone_members
15 | end
16 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/state_change.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class StateChange < ApplicationRecord
6 | belongs_to :user, class_name: "::User", optional: true
7 | belongs_to :stateful, polymorphic: true
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/stock_item.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class StockItem < ApplicationRecord
6 | with_options inverse_of: :stock_items do
7 | belongs_to :stock_location
8 | belongs_to :variant, -> { with_deleted }
9 | end
10 | has_many :stock_movements, inverse_of: :stock_item
11 | end
12 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/stock_location.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class StockLocation < ApplicationRecord
6 | has_many :shipments
7 | has_many :stock_items, dependent: :delete_all, inverse_of: :stock_location
8 | has_many :variants, through: :stock_items
9 | has_many :stock_movements, through: :stock_items
10 |
11 | belongs_to :state, optional: true
12 | belongs_to :country
13 | end
14 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/stock_movement.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class StockMovement < ApplicationRecord
6 | belongs_to :stock_item, inverse_of: :stock_movements
7 | belongs_to :originator, polymorphic: true
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/stock_transfer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class StockTransfer < ApplicationRecord
6 | has_many :stock_movements, as: :originator
7 | belongs_to :source_location, class_name: 'StockLocation', optional: true
8 | belongs_to :destination_location, class_name: 'StockLocation'
9 | end
10 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/store.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Store < ApplicationRecord
6 | has_many :orders
7 | has_many :line_items, through: :orders
8 | has_many :shipments, through: :orders
9 | has_many :payments, through: :orders
10 | has_many :return_authorizations, through: :orders
11 |
12 | has_many :store_payment_methods
13 | has_many :payment_methods, through: :store_payment_methods
14 |
15 | has_many :cms_pages
16 | has_many :cms_sections, through: :cms_pages
17 |
18 | has_many :menus
19 | has_many :menu_items, through: :menus
20 |
21 | has_many :store_products
22 | has_many :products, through: :store_products
23 | has_many :product_properties, through: :products
24 | has_many :variants, through: :products, source: :variants_including_master
25 | has_many :stock_items, through: :variants
26 | has_many :inventory_units, through: :variants
27 | has_many :option_value_variants, through: :variants
28 | has_many :customer_returns, inverse_of: :store
29 |
30 | has_many :store_credits
31 | has_many :store_credit_events, through: :store_credits
32 |
33 | has_many :taxonomies
34 | has_many :taxons, through: :taxonomies
35 |
36 | has_many :store_promotions
37 | has_many :promotions, through: :store_promotions
38 |
39 | has_many :wishlists
40 |
41 | has_many :data_feeds
42 |
43 | belongs_to :default_country
44 | belongs_to :checkout_zone
45 |
46 | has_one :logo, dependent: :destroy, as: :viewable
47 | has_one :mailer_logo, dependent: :destroy, as: :viewable
48 | has_one :favicon_image, dependent: :destroy, as: :viewable
49 | end
50 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/store_credit.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class StoreCredit < ApplicationRecord
6 | belongs_to :user, class_name: "::User", foreign_key: 'user_id'
7 | belongs_to :category, optional: true
8 | belongs_to :created_by, class_name: "::AdminUser", foreign_key: 'created_by_id', optional: true
9 | belongs_to :credit_type, foreign_key: 'type_id', optional: true
10 | belongs_to :store
11 |
12 | has_many :store_credit_events
13 | has_many :payments, as: :source
14 | has_many :orders, through: :payments
15 | end
16 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/store_credit_category.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class StoreCreditCategory < ApplicationRecord
6 | end
7 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/store_credit_event.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class StoreCreditEvent < ApplicationRecord
6 | belongs_to :store_credit
7 | belongs_to :originator, polymorphic: true
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/store_credit_type.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class StoreCreditType < ApplicationRecord
6 | has_many :store_credits, foreign_key: 'type_id'
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/store_payment_method.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class StorePaymentMethod < ApplicationRecord
6 | belongs_to :store, touch: true
7 | belongs_to :payment_method, touch: true
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/store_product.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class StoreProduct < ApplicationRecord
6 | belongs_to :store, touch: true
7 | belongs_to :product, touch: true
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/store_promotion.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class StorePromotion < ApplicationRecord
6 | belongs_to :store, touch: true
7 | belongs_to :promotion, touch: true
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/tax_category.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class TaxCategory < ApplicationRecord
6 | has_many :tax_rates, dependent: :destroy, inverse_of: :tax_category
7 | has_many :products, dependent: :nullify
8 | has_many :variants, dependent: :nullify
9 | end
10 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/tax_rate.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class TaxRate < ApplicationRecord
6 | with_options inverse_of: :tax_rates do
7 | belongs_to :zone, optional: true
8 | belongs_to :tax_category
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/taxon.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Taxon < ApplicationRecord
6 | belongs_to :taxonomy, inverse_of: :taxons
7 | has_one :store, through: :taxonomy
8 | has_many :classifications, -> { order(:position) }, inverse_of: :taxon
9 | has_many :products, through: :classifications
10 | has_one :icon, as: :viewable, dependent: :destroy # TODO: remove this as this is deprecated
11 |
12 | has_many :menu_items, as: :linked_resource
13 | has_many :cms_sections, as: :linked_resource
14 |
15 | has_many :prototype_taxons, dependent: :destroy
16 | has_many :prototypes, through: :prototype_taxons
17 |
18 | has_many :promotion_rule_taxons, dependent: :destroy
19 | has_many :promotion_rules, through: :promotion_rule_taxons
20 | end
21 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/taxon_rule.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class TaxonRule < ApplicationRecord
6 | belongs_to :taxon, inverse_of: :taxon_rules, touch: true
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/taxonomy.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Taxonomy < ApplicationRecord
6 | has_many :taxons, inverse_of: :taxonomy
7 | has_one :root, -> { where parent_id: nil }, dependent: :destroy
8 | belongs_to :store
9 | end
10 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/user.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree (Spree.user_class)
4 | # https://github.com/spree/spree
5 | class User < ApplicationRecord
6 | end
7 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/variant.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Variant < ApplicationRecord
6 | with_options inverse_of: :variant do
7 | has_many :inventory_units
8 | has_many :line_items
9 | has_many :stock_items, dependent: :destroy
10 | end
11 |
12 | has_many :orders, through: :line_items
13 | with_options through: :stock_items do
14 | has_many :stock_locations
15 | has_many :stock_movements
16 | end
17 |
18 | has_many :option_value_variants
19 | has_many :option_values, through: :option_value_variants, dependent: :destroy
20 |
21 | has_many :images, -> { order(:position) }, as: :viewable, dependent: :destroy
22 |
23 | has_many :prices,
24 | dependent: :destroy,
25 | inverse_of: :variant
26 |
27 | has_many :wished_items, dependent: :destroy
28 |
29 | has_many :digitals
30 | end
31 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/wished_item.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class WishedItem < ApplicationRecord
6 | belongs_to :variant
7 | belongs_to :wishlist
8 |
9 | has_one :product, through: :variant
10 | end
11 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/wishlist.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Wishlist < ApplicationRecord
6 | belongs_to :user, class_name: "::User", touch: true
7 | belongs_to :store
8 |
9 | has_many :wished_items, dependent: :destroy
10 | has_many :variants, through: :wished_items, source: :variant
11 | has_many :products, -> { distinct }, through: :variants, source: :product
12 | end
13 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/zone.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class Zone < ApplicationRecord
6 | with_options dependent: :destroy, inverse_of: :zone do
7 | has_many :zone_members
8 | has_many :tax_rates
9 | end
10 | with_options through: :zone_members, source: :zoneable do
11 | has_many :countries, source_type: 'Country'
12 | has_many :states, source_type: 'State'
13 | end
14 |
15 | has_many :shipping_method_zones
16 | has_many :shipping_methods, through: :shipping_method_zones
17 | end
18 |
--------------------------------------------------------------------------------
/spec/fake_app/app/models/zone_member.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Models from spree
4 | # https://github.com/spree/spree
5 | class ZoneMember < ApplicationRecord
6 | belongs_to :zone, counter_cache: true, inverse_of: :zone_members
7 | belongs_to :zoneable, polymorphic: true
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fake_app/bin/dev:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | exec "./bin/rails", "server", *ARGV
3 |
--------------------------------------------------------------------------------
/spec/fake_app/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | APP_PATH = File.expand_path("../config/application", __dir__)
3 | require_relative "../config/boot"
4 | require "rails/commands"
5 |
--------------------------------------------------------------------------------
/spec/fake_app/bin/rake:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require_relative "../config/boot"
3 | require "rake"
4 | Rake.application.run
5 |
--------------------------------------------------------------------------------
/spec/fake_app/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require "fileutils"
3 |
4 | APP_ROOT = File.expand_path("..", __dir__)
5 |
6 | def system!(*args)
7 | system(*args, exception: true)
8 | end
9 |
10 | FileUtils.chdir APP_ROOT do
11 | # This script is a way to set up or update your development environment automatically.
12 | # This script is idempotent, so that you can run it at any time and get an expectable outcome.
13 | # Add necessary setup steps to this file.
14 |
15 | puts "== Installing dependencies =="
16 | system("bundle check") || system!("bundle install")
17 |
18 | # puts "\n== Copying sample files =="
19 | # unless File.exist?("config/database.yml")
20 | # FileUtils.cp "config/database.yml.sample", "config/database.yml"
21 | # end
22 |
23 | puts "\n== Preparing database =="
24 | system! "bin/rails db:prepare"
25 |
26 | puts "\n== Removing old logs and tempfiles =="
27 | system! "bin/rails log:clear tmp:clear"
28 |
29 | unless ARGV.include?("--skip-server")
30 | puts "\n== Starting development server =="
31 | STDOUT.flush # flush the output before exec(2) so that it displays
32 | exec "bin/dev"
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/spec/fake_app/config.ru:
--------------------------------------------------------------------------------
1 | # This file is used by Rack-based servers to start the application.
2 |
3 | require_relative "config/environment"
4 |
5 | run Rails.application
6 | Rails.application.load_server
7 |
--------------------------------------------------------------------------------
/spec/fake_app/config/application.rb:
--------------------------------------------------------------------------------
1 |
2 | require "rails"
3 | require "active_model/railtie"
4 | require "active_record/railtie"
5 | require "action_controller/railtie"
6 | require "action_view/railtie"
7 |
8 | Bundler.require(*Rails.groups)
9 |
10 | module ErdMapTestApp
11 | class Application < Rails::Application
12 | config.load_defaults Rails::VERSION::STRING.to_f
13 | config.eager_load = false
14 | config.secret_key_base = "erdmap"
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/spec/fake_app/config/boot.rb:
--------------------------------------------------------------------------------
1 | # Set up gems listed in the Gemfile.
2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__)
3 |
4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__)
6 |
--------------------------------------------------------------------------------
/spec/fake_app/config/database.yml:
--------------------------------------------------------------------------------
1 | default: &default
2 | adapter: sqlite3
3 | pool: 5
4 | timeout: 5000
5 |
6 | development:
7 | <<: *default
8 | database: db/development.sqlite3
9 |
10 | test:
11 | <<: *default
12 | database: db/test.sqlite3
13 |
--------------------------------------------------------------------------------
/spec/fake_app/config/environment.rb:
--------------------------------------------------------------------------------
1 | # Load the Rails application.
2 | require_relative "application"
3 |
4 | # Initialize the Rails application.
5 | Rails.application.initialize!
6 |
--------------------------------------------------------------------------------
/spec/fake_app/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | require "active_support/core_ext/integer/time"
2 |
3 | Rails.application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb.
5 |
6 | # Make code changes take effect immediately without server restart.
7 | config.enable_reloading = true
8 |
9 | # Do not eager load code on boot.
10 | config.eager_load = false
11 |
12 | # Show full error reports.
13 | config.consider_all_requests_local = true
14 |
15 | # Enable server timing.
16 | config.server_timing = true
17 |
18 | # Enable/disable Action Controller caching. By default Action Controller caching is disabled.
19 | # Run rails dev:cache to toggle Action Controller caching.
20 | if Rails.root.join("tmp/caching-dev.txt").exist?
21 | config.action_controller.perform_caching = true
22 | config.action_controller.enable_fragment_cache_logging = true
23 | config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
24 | else
25 | config.action_controller.perform_caching = false
26 | end
27 |
28 | # Change to :null_store to avoid any caching.
29 | config.cache_store = :memory_store
30 |
31 | # Print deprecation notices to the Rails logger.
32 | config.active_support.deprecation = :log
33 |
34 | # Raise an error on page load if there are pending migrations.
35 | config.active_record.migration_error = :page_load
36 |
37 | # Highlight code that triggered database queries in logs.
38 | config.active_record.verbose_query_logs = true
39 |
40 | # Append comments with runtime information tags to SQL queries in logs.
41 | config.active_record.query_log_tags_enabled = true
42 |
43 | # Raises error for missing translations.
44 | # config.i18n.raise_on_missing_translations = true
45 |
46 | # Annotate rendered view with file names.
47 | config.action_view.annotate_rendered_view_with_filenames = true
48 |
49 | # Uncomment if you wish to allow Action Cable access from any origin.
50 | # config.action_cable.disable_request_forgery_protection = true
51 |
52 | # Raise error when a before_action's only/except options reference missing actions.
53 | config.action_controller.raise_on_missing_callback_actions = true
54 | end
55 |
--------------------------------------------------------------------------------
/spec/fake_app/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | # The test environment is used exclusively to run your application's
2 | # test suite. You never need to work with it otherwise. Remember that
3 | # your test database is "scratch space" for the test suite and is wiped
4 | # and recreated between test runs. Don't rely on the data there!
5 |
6 | Rails.application.configure do
7 | # Settings specified here will take precedence over those in config/application.rb.
8 |
9 | # While tests run files are not watched, reloading is not necessary.
10 | config.enable_reloading = false
11 |
12 | # Eager loading loads your entire application. When running a single test locally,
13 | # this is usually not necessary, and can slow down your test suite. However, it's
14 | # recommended that you enable it in continuous integration systems to ensure eager
15 | # loading is working properly before deploying your code.
16 | config.eager_load = ENV["CI"].present?
17 |
18 | # Configure public file server for tests with cache-control for performance.
19 | config.public_file_server.headers = { "cache-control" => "public, max-age=3600" }
20 |
21 | # Show full error reports.
22 | config.consider_all_requests_local = true
23 | config.cache_store = :null_store
24 |
25 | # Render exception templates for rescuable exceptions and raise for other exceptions.
26 | config.action_dispatch.show_exceptions = :rescuable
27 |
28 | # Disable request forgery protection in test environment.
29 | config.action_controller.allow_forgery_protection = false
30 |
31 | # Print deprecation notices to the stderr.
32 | config.active_support.deprecation = :stderr
33 |
34 | # Raises error for missing translations.
35 | # config.i18n.raise_on_missing_translations = true
36 |
37 | # Annotate rendered view with file names.
38 | # config.action_view.annotate_rendered_view_with_filenames = true
39 |
40 | # Raise error when a before_action's only/except options reference missing actions.
41 | config.action_controller.raise_on_missing_callback_actions = true
42 | end
43 |
--------------------------------------------------------------------------------
/spec/fake_app/config/puma.rb:
--------------------------------------------------------------------------------
1 | # This configuration file will be evaluated by Puma. The top-level methods that
2 | # are invoked here are part of Puma's configuration DSL. For more information
3 | # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
4 | #
5 | # Puma starts a configurable number of processes (workers) and each process
6 | # serves each request in a thread from an internal thread pool.
7 | #
8 | # You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
9 | # should only set this value when you want to run 2 or more workers. The
10 | # default is already 1.
11 | #
12 | # The ideal number of threads per worker depends both on how much time the
13 | # application spends waiting for IO operations and on how much you wish to
14 | # prioritize throughput over latency.
15 | #
16 | # As a rule of thumb, increasing the number of threads will increase how much
17 | # traffic a given process can handle (throughput), but due to CRuby's
18 | # Global VM Lock (GVL) it has diminishing returns and will degrade the
19 | # response time (latency) of the application.
20 | #
21 | # The default is set to 3 threads as it's deemed a decent compromise between
22 | # throughput and latency for the average Rails application.
23 | #
24 | # Any libraries that use a connection pool or another resource pool should
25 | # be configured to provide at least as many connections as the number of
26 | # threads. This includes Active Record's `pool` parameter in `database.yml`.
27 | threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
28 | threads threads_count, threads_count
29 |
30 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000.
31 | port ENV.fetch("PORT", 3000)
32 |
33 | # Allow puma to be restarted by `bin/rails restart` command.
34 | plugin :tmp_restart
35 |
36 | # Specify the PID file. Defaults to tmp/pids/server.pid in development.
37 | # In other environments, only set the PID file if requested.
38 | pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
39 |
--------------------------------------------------------------------------------
/spec/fake_app/config/routes.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | ErdMapTestApp::Application.routes.draw do
4 | mount ErdMap::Engine => "/erd_map"
5 | end
6 |
--------------------------------------------------------------------------------
/spec/fake_app/db/00000000000000_create_all_tables.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateAllTables < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table 'addresses' do |t|
6 | end
7 |
8 | create_table 'adjustments' do |t|
9 | end
10 |
11 | create_table 'admin_users' do |t|
12 | end
13 |
14 | create_table 'assets' do |t|
15 | end
16 |
17 | create_table 'calculators' do |t|
18 | end
19 |
20 | create_table 'classifications' do |t|
21 | end
22 |
23 | create_table 'cms_pages' do |t|
24 | end
25 |
26 | create_table 'cms_sections' do |t|
27 | end
28 |
29 | create_table 'countries' do |t|
30 | end
31 |
32 | create_table 'coupon_codes' do |t|
33 | end
34 |
35 | create_table 'credit_cards' do |t|
36 | end
37 |
38 | create_table 'customer_returns' do |t|
39 | end
40 |
41 | create_table 'data_feeds' do |t|
42 | end
43 |
44 | create_table 'digital_links' do |t|
45 | end
46 |
47 | create_table 'digitals' do |t|
48 | end
49 |
50 | create_table 'exports' do |t|
51 | end
52 |
53 | create_table 'inventory_units' do |t|
54 | end
55 |
56 | create_table 'legacy_users' do |t|
57 | end
58 |
59 | create_table 'line_items' do |t|
60 | end
61 |
62 | create_table 'log_entries' do |t|
63 | end
64 |
65 | create_table 'menu_items' do |t|
66 | end
67 |
68 | create_table 'menus' do |t|
69 | end
70 |
71 | create_table 'oauth_access_grants' do |t|
72 | end
73 |
74 | create_table 'oauth_access_tokens' do |t|
75 | end
76 |
77 | create_table 'oauth_applications' do |t|
78 | end
79 |
80 | create_table 'option_type_prototypes' do |t|
81 | end
82 |
83 | create_table 'option_types' do |t|
84 | end
85 |
86 | create_table 'option_value_variants' do |t|
87 | end
88 |
89 | create_table 'option_values' do |t|
90 | end
91 |
92 | create_table 'order_promotions' do |t|
93 | end
94 |
95 | create_table 'orders' do |t|
96 | end
97 |
98 | create_table 'payment_capture_events' do |t|
99 | end
100 |
101 | create_table 'payment_methods' do |t|
102 | end
103 |
104 | create_table 'payment_sources' do |t|
105 | end
106 |
107 | create_table 'payments' do |t|
108 | end
109 |
110 | create_table 'preferences' do |t|
111 | end
112 |
113 | create_table 'prices' do |t|
114 | end
115 |
116 | create_table 'product_option_types' do |t|
117 | end
118 |
119 | create_table 'product_promotion_rules' do |t|
120 | end
121 |
122 | create_table 'product_properties' do |t|
123 | end
124 |
125 | create_table 'products' do |t|
126 | end
127 |
128 | create_table 'promotion_action_line_items' do |t|
129 | end
130 |
131 | create_table 'promotion_actions' do |t|
132 | end
133 |
134 | create_table 'promotion_categories' do |t|
135 | end
136 |
137 | create_table 'promotion_rule_taxons' do |t|
138 | end
139 |
140 | create_table 'promotion_rule_users' do |t|
141 | end
142 |
143 | create_table 'promotion_rules' do |t|
144 | end
145 |
146 | create_table 'promotions' do |t|
147 | end
148 |
149 | create_table 'properties' do |t|
150 | end
151 |
152 | create_table 'property_prototypes' do |t|
153 | end
154 |
155 | create_table 'prototype_taxons' do |t|
156 | end
157 |
158 | create_table 'prototypes' do |t|
159 | end
160 |
161 | create_table 'refund_reasons' do |t|
162 | end
163 |
164 | create_table 'refunds' do |t|
165 | end
166 |
167 | create_table 'reimbursement_types' do |t|
168 | end
169 |
170 | create_table 'reimbursements' do |t|
171 | end
172 |
173 | create_table 'return_authorization_reasons' do |t|
174 | end
175 |
176 | create_table 'return_authorizations' do |t|
177 | end
178 |
179 | create_table 'return_items' do |t|
180 | end
181 |
182 | create_table 'role_users' do |t|
183 | end
184 |
185 | create_table 'roles' do |t|
186 | end
187 |
188 | create_table 'shipments' do |t|
189 | end
190 |
191 | create_table 'shipping_categories' do |t|
192 | end
193 |
194 | create_table 'shipping_method_categories' do |t|
195 | end
196 |
197 | create_table 'shipping_method_zones' do |t|
198 | end
199 |
200 | create_table 'shipping_methods' do |t|
201 | end
202 |
203 | create_table 'shipping_rates' do |t|
204 | end
205 |
206 | create_table 'state_changes' do |t|
207 | end
208 |
209 | create_table 'states' do |t|
210 | end
211 |
212 | create_table 'stock_items' do |t|
213 | end
214 |
215 | create_table 'stock_locations' do |t|
216 | end
217 |
218 | create_table 'stock_movements' do |t|
219 | end
220 |
221 | create_table 'stock_transfers' do |t|
222 | end
223 |
224 | create_table 'store_credit_categories' do |t|
225 | end
226 |
227 | create_table 'store_credit_events' do |t|
228 | end
229 |
230 | create_table 'store_credit_types' do |t|
231 | end
232 |
233 | create_table 'store_credits' do |t|
234 | end
235 |
236 | create_table 'store_payment_methods' do |t|
237 | end
238 |
239 | create_table 'store_products' do |t|
240 | end
241 |
242 | create_table 'store_promotions' do |t|
243 | end
244 |
245 | create_table 'stores' do |t|
246 | end
247 |
248 | create_table 'tax_categories' do |t|
249 | end
250 |
251 | create_table 'tax_rates' do |t|
252 | end
253 |
254 | create_table 'taxon_rules' do |t|
255 | end
256 |
257 | create_table 'taxonomies' do |t|
258 | end
259 |
260 | create_table 'taxons' do |t|
261 | end
262 |
263 | create_table 'users' do |t|
264 | end
265 |
266 | create_table 'variants' do |t|
267 | end
268 |
269 | create_table 'wished_items' do |t|
270 | end
271 |
272 | create_table 'wishlists' do |t|
273 | end
274 |
275 | create_table 'zone_members' do |t|
276 | end
277 |
278 | create_table 'zones' do |t|
279 | end
280 | end
281 |
282 | def self.change
283 | new.change
284 | end
285 | end
286 |
--------------------------------------------------------------------------------
/spec/fake_app/schema.rb:
--------------------------------------------------------------------------------
1 | # This file is auto-generated from the current state of the database. Instead
2 | # of editing this file, please use the migrations feature of Active Record to
3 | # incrementally modify your database, and then regenerate this schema definition.
4 | #
5 | # This file is the source Rails uses to define your schema when running `bin/rails
6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
7 | # be faster and is potentially less error prone than running all of your
8 | # migrations from scratch. Old migrations may fail to apply correctly if those
9 | # migrations use external dependencies or application code.
10 | #
11 | # It's strongly recommended that you check this file into your version control system.
12 |
13 | ActiveRecord::Schema[8.0].define(version: 0) do
14 | create_table "addresses", force: :cascade do |t|
15 | end
16 |
17 | create_table "adjustments", force: :cascade do |t|
18 | end
19 |
20 | create_table "admin_users", force: :cascade do |t|
21 | end
22 |
23 | create_table "assets", force: :cascade do |t|
24 | end
25 |
26 | create_table "calculators", force: :cascade do |t|
27 | end
28 |
29 | create_table "classifications", force: :cascade do |t|
30 | end
31 |
32 | create_table "cms_pages", force: :cascade do |t|
33 | end
34 |
35 | create_table "cms_sections", force: :cascade do |t|
36 | end
37 |
38 | create_table "countries", force: :cascade do |t|
39 | end
40 |
41 | create_table "coupon_codes", force: :cascade do |t|
42 | end
43 |
44 | create_table "credit_cards", force: :cascade do |t|
45 | end
46 |
47 | create_table "customer_returns", force: :cascade do |t|
48 | end
49 |
50 | create_table "data_feeds", force: :cascade do |t|
51 | end
52 |
53 | create_table "digital_links", force: :cascade do |t|
54 | end
55 |
56 | create_table "digitals", force: :cascade do |t|
57 | end
58 |
59 | create_table "exports", force: :cascade do |t|
60 | end
61 |
62 | create_table "inventory_units", force: :cascade do |t|
63 | end
64 |
65 | create_table "legacy_users", force: :cascade do |t|
66 | end
67 |
68 | create_table "line_items", force: :cascade do |t|
69 | end
70 |
71 | create_table "log_entries", force: :cascade do |t|
72 | end
73 |
74 | create_table "menu_items", force: :cascade do |t|
75 | end
76 |
77 | create_table "menus", force: :cascade do |t|
78 | end
79 |
80 | create_table "oauth_access_grants", force: :cascade do |t|
81 | end
82 |
83 | create_table "oauth_access_tokens", force: :cascade do |t|
84 | end
85 |
86 | create_table "oauth_applications", force: :cascade do |t|
87 | end
88 |
89 | create_table "option_type_prototypes", force: :cascade do |t|
90 | end
91 |
92 | create_table "option_types", force: :cascade do |t|
93 | end
94 |
95 | create_table "option_value_variants", force: :cascade do |t|
96 | end
97 |
98 | create_table "option_values", force: :cascade do |t|
99 | end
100 |
101 | create_table "order_promotions", force: :cascade do |t|
102 | end
103 |
104 | create_table "orders", force: :cascade do |t|
105 | end
106 |
107 | create_table "payment_capture_events", force: :cascade do |t|
108 | end
109 |
110 | create_table "payment_methods", force: :cascade do |t|
111 | end
112 |
113 | create_table "payment_sources", force: :cascade do |t|
114 | end
115 |
116 | create_table "payments", force: :cascade do |t|
117 | end
118 |
119 | create_table "preferences", force: :cascade do |t|
120 | end
121 |
122 | create_table "prices", force: :cascade do |t|
123 | end
124 |
125 | create_table "product_option_types", force: :cascade do |t|
126 | end
127 |
128 | create_table "product_promotion_rules", force: :cascade do |t|
129 | end
130 |
131 | create_table "product_properties", force: :cascade do |t|
132 | end
133 |
134 | create_table "products", force: :cascade do |t|
135 | end
136 |
137 | create_table "promotion_action_line_items", force: :cascade do |t|
138 | end
139 |
140 | create_table "promotion_actions", force: :cascade do |t|
141 | end
142 |
143 | create_table "promotion_categories", force: :cascade do |t|
144 | end
145 |
146 | create_table "promotion_rule_taxons", force: :cascade do |t|
147 | end
148 |
149 | create_table "promotion_rule_users", force: :cascade do |t|
150 | end
151 |
152 | create_table "promotion_rules", force: :cascade do |t|
153 | end
154 |
155 | create_table "promotions", force: :cascade do |t|
156 | end
157 |
158 | create_table "properties", force: :cascade do |t|
159 | end
160 |
161 | create_table "property_prototypes", force: :cascade do |t|
162 | end
163 |
164 | create_table "prototype_taxons", force: :cascade do |t|
165 | end
166 |
167 | create_table "prototypes", force: :cascade do |t|
168 | end
169 |
170 | create_table "refund_reasons", force: :cascade do |t|
171 | end
172 |
173 | create_table "refunds", force: :cascade do |t|
174 | end
175 |
176 | create_table "reimbursement_types", force: :cascade do |t|
177 | end
178 |
179 | create_table "reimbursements", force: :cascade do |t|
180 | end
181 |
182 | create_table "return_authorization_reasons", force: :cascade do |t|
183 | end
184 |
185 | create_table "return_authorizations", force: :cascade do |t|
186 | end
187 |
188 | create_table "return_items", force: :cascade do |t|
189 | end
190 |
191 | create_table "role_users", force: :cascade do |t|
192 | end
193 |
194 | create_table "roles", force: :cascade do |t|
195 | end
196 |
197 | create_table "shipments", force: :cascade do |t|
198 | end
199 |
200 | create_table "shipping_categories", force: :cascade do |t|
201 | end
202 |
203 | create_table "shipping_method_categories", force: :cascade do |t|
204 | end
205 |
206 | create_table "shipping_method_zones", force: :cascade do |t|
207 | end
208 |
209 | create_table "shipping_methods", force: :cascade do |t|
210 | end
211 |
212 | create_table "shipping_rates", force: :cascade do |t|
213 | end
214 |
215 | create_table "state_changes", force: :cascade do |t|
216 | end
217 |
218 | create_table "states", force: :cascade do |t|
219 | end
220 |
221 | create_table "stock_items", force: :cascade do |t|
222 | end
223 |
224 | create_table "stock_locations", force: :cascade do |t|
225 | end
226 |
227 | create_table "stock_movements", force: :cascade do |t|
228 | end
229 |
230 | create_table "stock_transfers", force: :cascade do |t|
231 | end
232 |
233 | create_table "store_credit_categories", force: :cascade do |t|
234 | end
235 |
236 | create_table "store_credit_events", force: :cascade do |t|
237 | end
238 |
239 | create_table "store_credit_types", force: :cascade do |t|
240 | end
241 |
242 | create_table "store_credits", force: :cascade do |t|
243 | end
244 |
245 | create_table "store_payment_methods", force: :cascade do |t|
246 | end
247 |
248 | create_table "store_products", force: :cascade do |t|
249 | end
250 |
251 | create_table "store_promotions", force: :cascade do |t|
252 | end
253 |
254 | create_table "stores", force: :cascade do |t|
255 | end
256 |
257 | create_table "tax_categories", force: :cascade do |t|
258 | end
259 |
260 | create_table "tax_rates", force: :cascade do |t|
261 | end
262 |
263 | create_table "taxon_rules", force: :cascade do |t|
264 | end
265 |
266 | create_table "taxonomies", force: :cascade do |t|
267 | end
268 |
269 | create_table "taxons", force: :cascade do |t|
270 | end
271 |
272 | create_table "users", force: :cascade do |t|
273 | end
274 |
275 | create_table "variants", force: :cascade do |t|
276 | end
277 |
278 | create_table "wished_items", force: :cascade do |t|
279 | end
280 |
281 | create_table "wishlists", force: :cascade do |t|
282 | end
283 |
284 | create_table "zone_members", force: :cascade do |t|
285 | end
286 |
287 | create_table "zones", force: :cascade do |t|
288 | end
289 | end
290 |
--------------------------------------------------------------------------------
/spec/fake_app/tmp/restart.txt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makicamel/erd_map/cd1859bc694a0e74f035c6a03b133c9c25ccbe57/spec/fake_app/tmp/restart.txt
--------------------------------------------------------------------------------
/spec/rails_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spec_helper"
4 | ENV["RAILS_ENV"] ||= "test"
5 | require "rails"
6 | abort("The Rails environment is running in production mode!") if Rails.env.production?
7 | require "rspec/rails"
8 |
9 | begin
10 | ActiveRecord::Migration.maintain_test_schema!
11 | rescue ActiveRecord::PendingMigrationError => e
12 | puts e.to_s.strip
13 | exit 1
14 | end
15 |
16 | RSpec.configure do |config|
17 | config.fixture_path = "#{Rails.root}/spec/fixtures"
18 | config.infer_spec_type_from_file_location!
19 | config.filter_rails_from_backtrace!
20 | end
21 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_record/railtie"
4 | require "erd_map"
5 |
6 | require_relative "fake_app/config/environment"
7 | require_relative "fake_app/db/00000000000000_create_all_tables"
8 | ActiveRecord::Tasks::DatabaseTasks.drop_current "test"
9 | ActiveRecord::Tasks::DatabaseTasks.create_current "test"
10 | CreateAllTables.change
11 |
12 | RSpec.configure do |config|
13 | config.example_status_persistence_file_path = ".rspec_status"
14 | config.disable_monkey_patching!
15 | config.expect_with :rspec do |c|
16 | c.syntax = :expect
17 | end
18 | end
19 |
--------------------------------------------------------------------------------