├── .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 | | ![](sample/images/redmine.png) | ![](sample/images/mastdon.png) | 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 | --------------------------------------------------------------------------------