model_explorer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/.simplecov +17 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +139 -0
- data/Rakefile +12 -0
- data/app/assets/config/model_explorer/manifest.js +2 -0
- data/app/assets/javascripts/model_explorer/application.js +5 -0
- data/app/assets/javascripts/model_explorer/association_manager.js +192 -0
- data/app/assets/javascripts/model_explorer/association_select.js +23 -0
- data/app/assets/javascripts/model_explorer/copy_button.js +25 -0
- data/app/assets/javascripts/model_explorer/model_form.js +61 -0
- data/app/assets/javascripts/model_explorer/models_controller.js +43 -0
- data/app/assets/stylesheets/model_explorer/application.css +7 -0
- data/app/assets/stylesheets/model_explorer/models.css +45 -0
- data/app/controllers/model_explorer/application_controller.rb +41 -0
- data/app/controllers/model_explorer/exports_controller.rb +53 -0
- data/app/controllers/model_explorer/models_controller.rb +23 -0
- data/app/serializers/application_serializer.rb +11 -0
- data/app/serializers/association_serializer.rb +21 -0
- data/app/serializers/model_serializer.rb +39 -0
- data/app/views/layouts/model_explorer/application.html.erb +28 -0
- data/app/views/model_explorer/models/index.html.erb +14 -0
- data/app/views/model_explorer/models/partials/_form.html.erb +49 -0
- data/app/views/model_explorer/models/partials/_record_details.html.erb +43 -0
- data/app/views/model_explorer/models/partials/_select_template.html.erb +53 -0
- data/config/locales/en.yml +3 -0
- data/config/locales/fr.yml +3 -0
- data/config/locales/views/layouts/model_explorer/application.en.yml +5 -0
- data/config/locales/views/layouts/model_explorer/application.fr.yml +5 -0
- data/config/locales/views/model_explorer/models/partials/form.en.yml +10 -0
- data/config/locales/views/model_explorer/models/partials/form.fr.yml +10 -0
- data/config/locales/views/model_explorer/models/partials/record_details.en.yml +9 -0
- data/config/locales/views/model_explorer/models/partials/record_details.fr.yml +9 -0
- data/config/locales/views/model_explorer/models/partials/select_template.en.yml +8 -0
- data/config/locales/views/model_explorer/models/partials/select_template.fr.yml +8 -0
- data/config/routes.rb +8 -0
- data/docs/example.png +0 -0
- data/lib/generators/model_explorer/USAGE +7 -0
- data/lib/generators/model_explorer/install_generator.rb +24 -0
- data/lib/generators/model_explorer/templates/model_explorer.rb.tt +24 -0
- data/lib/model_explorer/associations/base.rb +74 -0
- data/lib/model_explorer/associations/many.rb +43 -0
- data/lib/model_explorer/associations/singular.rb +19 -0
- data/lib/model_explorer/associations.rb +33 -0
- data/lib/model_explorer/engine.rb +39 -0
- data/lib/model_explorer/export.rb +53 -0
- data/lib/model_explorer/import.rb +64 -0
- data/lib/model_explorer/record.rb +15 -0
- data/lib/model_explorer/scopes.rb +23 -0
- data/lib/model_explorer/select.rb +28 -0
- data/lib/model_explorer/version.rb +5 -0
- data/lib/model_explorer.rb +77 -0
- data/sig/model_explorer.rbs +4 -0
- data/vendor/assets/javascripts/bootstrap.min.js +7 -0
- data/vendor/assets/javascripts/prism.min.js +4 -0
- data/vendor/assets/javascripts/tom_select.min.js +440 -0
- data/vendor/assets/stylesheets/bootstrap.min.css +6 -0
- data/vendor/assets/stylesheets/prism.min.css +3 -0
- data/vendor/assets/stylesheets/tom_select.min.css +15 -0
- metadata +128 -0
| @@ -0,0 +1,45 @@ | |
| 1 | 
            +
            pre#json-pre {
         | 
| 2 | 
            +
              border: 1px solid #d0d0d0;
         | 
| 3 | 
            +
              border-radius: 3px;
         | 
| 4 | 
            +
              margin-bottom: 0px;
         | 
| 5 | 
            +
              margin-top: 0px;
         | 
| 6 | 
            +
              padding: 10px;
         | 
| 7 | 
            +
              height: 80vh;
         | 
| 8 | 
            +
            }
         | 
| 9 | 
            +
             | 
| 10 | 
            +
            .overflow-unset {
         | 
| 11 | 
            +
              overflow: unset !important;
         | 
| 12 | 
            +
            }
         | 
| 13 | 
            +
             | 
| 14 | 
            +
            .was-validated .ts-wrapper.invalid .ts-control {
         | 
| 15 | 
            +
              border: 1px solid #dc3545;
         | 
| 16 | 
            +
              padding-right: calc(1.5em + .75rem)!important;
         | 
| 17 | 
            +
              background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");
         | 
| 18 | 
            +
              background-repeat: no-repeat;
         | 
| 19 | 
            +
              background-position: right calc(.375em + .1875rem) center;
         | 
| 20 | 
            +
              background-size: calc(.75em + .375rem) calc(.75em + .375rem)
         | 
| 21 | 
            +
            }
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            .was-validated .form-control:valid {
         | 
| 24 | 
            +
              border: 1px solid #dee2e6;
         | 
| 25 | 
            +
              padding-right: calc(1.5em + .75rem)!important;
         | 
| 26 | 
            +
              background-image: none;
         | 
| 27 | 
            +
              background-repeat: no-repeat;
         | 
| 28 | 
            +
              background-position: auto auto;
         | 
| 29 | 
            +
              background-size: auto auto
         | 
| 30 | 
            +
            }
         | 
| 31 | 
            +
             | 
| 32 | 
            +
            .was-validated .form-control:valid:focus {
         | 
| 33 | 
            +
              border-color: #86b7fe;
         | 
| 34 | 
            +
              box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25)
         | 
| 35 | 
            +
            }
         | 
| 36 | 
            +
             | 
| 37 | 
            +
            .ts-control input {
         | 
| 38 | 
            +
              line-height: 1.5 !important;
         | 
| 39 | 
            +
              padding: 0.01rem .75rem !important;
         | 
| 40 | 
            +
            }
         | 
| 41 | 
            +
             | 
| 42 | 
            +
            .ts-control {
         | 
| 43 | 
            +
              border: var(--bs-border-width) solid var(--bs-border-color);
         | 
| 44 | 
            +
              border-radius: var(--bs-border-radius);
         | 
| 45 | 
            +
            }
         | 
| @@ -0,0 +1,41 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module ModelExplorer
         | 
| 4 | 
            +
              class ApplicationController < ActionController::Base
         | 
| 5 | 
            +
                layout "model_explorer/application"
         | 
| 6 | 
            +
             | 
| 7 | 
            +
                before_action :verify_access
         | 
| 8 | 
            +
             | 
| 9 | 
            +
                http_basic_authenticate_with(
         | 
| 10 | 
            +
                  name: ModelExplorer.basic_auth_username.to_s,
         | 
| 11 | 
            +
                  password: ModelExplorer.basic_auth_password.to_s,
         | 
| 12 | 
            +
                  if: -> { ModelExplorer.basic_auth_enabled }
         | 
| 13 | 
            +
                )
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                protected
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                def render_not_found(error)
         | 
| 18 | 
            +
                  render json: {error: error.message}, status: :not_found
         | 
| 19 | 
            +
                end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                def render_bad_request(error)
         | 
| 22 | 
            +
                  render json: {error: error.message}, status: :bad_request
         | 
| 23 | 
            +
                end
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                def ensure_valid_model_name(model_name = nil)
         | 
| 26 | 
            +
                  return if ModelExplorer.models.map(&:name).include?(model_name)
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                  raise "Model '#{model_name}' not found"
         | 
| 29 | 
            +
                end
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                private
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                def verify_access
         | 
| 34 | 
            +
                  return if ModelExplorer.verify_access_proc.call(self)
         | 
| 35 | 
            +
             | 
| 36 | 
            +
                  flash[:error] = t("unauthorized", scope: "model_explorer")
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                  redirect_to "/"
         | 
| 39 | 
            +
                end
         | 
| 40 | 
            +
              end
         | 
| 41 | 
            +
            end
         | 
| @@ -0,0 +1,53 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module ModelExplorer
         | 
| 4 | 
            +
              class ExportsController < ApplicationController
         | 
| 5 | 
            +
                # Warning: all parameters are permitted.
         | 
| 6 | 
            +
                # Associations must not be called directly on the record.
         | 
| 7 | 
            +
                def show
         | 
| 8 | 
            +
                  ensure_valid_model_name(params[:model])
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                  render_export
         | 
| 11 | 
            +
                rescue => error
         | 
| 12 | 
            +
                  render_bad_request(error)
         | 
| 13 | 
            +
                end
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                private
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                def render_export
         | 
| 18 | 
            +
                  render json: {
         | 
| 19 | 
            +
                    export: build_export,
         | 
| 20 | 
            +
                    path: exports_path(params.except(:controller, :action))
         | 
| 21 | 
            +
                  }.to_json
         | 
| 22 | 
            +
                rescue ActiveRecord::RecordNotFound => error
         | 
| 23 | 
            +
                  render_not_found(error)
         | 
| 24 | 
            +
                end
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                def build_export
         | 
| 27 | 
            +
                  export = ModelExplorer::Export.new(
         | 
| 28 | 
            +
                    record: build_record(params[:model].constantize),
         | 
| 29 | 
            +
                    associations: ModelExplorer::Associations.build_from_params(params.permit!.to_h)
         | 
| 30 | 
            +
                  )
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                  export.data
         | 
| 33 | 
            +
                end
         | 
| 34 | 
            +
             | 
| 35 | 
            +
                def build_record(model)
         | 
| 36 | 
            +
                  select = ModelExplorer::Select.new(model, params[:columns]).to_a
         | 
| 37 | 
            +
                  record = model.select(select).find(params[:record_id])
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                  attributes =
         | 
| 40 | 
            +
                    if params[:columns].present?
         | 
| 41 | 
            +
                      record.attributes.slice(*params[:columns])
         | 
| 42 | 
            +
                    else
         | 
| 43 | 
            +
                      record.attributes
         | 
| 44 | 
            +
                    end
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                  ModelExplorer::Record.new(
         | 
| 47 | 
            +
                    record.attributes[model.primary_key],
         | 
| 48 | 
            +
                    attributes,
         | 
| 49 | 
            +
                    model
         | 
| 50 | 
            +
                  )
         | 
| 51 | 
            +
                end
         | 
| 52 | 
            +
              end
         | 
| 53 | 
            +
            end
         | 
| @@ -0,0 +1,23 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module ModelExplorer
         | 
| 4 | 
            +
              class ModelsController < ApplicationController
         | 
| 5 | 
            +
                def index
         | 
| 6 | 
            +
                  @models = ModelExplorer.models.map(&:name).sort
         | 
| 7 | 
            +
                end
         | 
| 8 | 
            +
             | 
| 9 | 
            +
                def show
         | 
| 10 | 
            +
                  model_name = params[:id]
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                  ensure_valid_model_name(model_name)
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                  render json: ModelSerializer.new(
         | 
| 15 | 
            +
                    model: model_name.constantize,
         | 
| 16 | 
            +
                    macro: params[:macro],
         | 
| 17 | 
            +
                    parent: params[:parent]
         | 
| 18 | 
            +
                  ).to_json
         | 
| 19 | 
            +
                rescue => error
         | 
| 20 | 
            +
                  render_bad_request(error)
         | 
| 21 | 
            +
                end
         | 
| 22 | 
            +
              end
         | 
| 23 | 
            +
            end
         | 
| @@ -0,0 +1,21 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            class AssociationSerializer < ApplicationSerializer
         | 
| 4 | 
            +
              attr_reader :association
         | 
| 5 | 
            +
             | 
| 6 | 
            +
              def initialize(association)
         | 
| 7 | 
            +
                @association = association
         | 
| 8 | 
            +
              end
         | 
| 9 | 
            +
             | 
| 10 | 
            +
              def to_h
         | 
| 11 | 
            +
                {
         | 
| 12 | 
            +
                  name: association_name,
         | 
| 13 | 
            +
                  macro: association.macro,
         | 
| 14 | 
            +
                  model: association.class_name || association_name.classify
         | 
| 15 | 
            +
                }
         | 
| 16 | 
            +
              end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
              private
         | 
| 19 | 
            +
             | 
| 20 | 
            +
              delegate :name, to: :association, prefix: true
         | 
| 21 | 
            +
            end
         | 
| @@ -0,0 +1,39 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            class ModelSerializer < ApplicationSerializer
         | 
| 4 | 
            +
              attr_reader :model, :macro, :parent
         | 
| 5 | 
            +
             | 
| 6 | 
            +
              def initialize(model:, macro:, parent: nil)
         | 
| 7 | 
            +
                @model = model
         | 
| 8 | 
            +
                @macro = macro
         | 
| 9 | 
            +
                @parent = parent
         | 
| 10 | 
            +
              end
         | 
| 11 | 
            +
             | 
| 12 | 
            +
              def to_h
         | 
| 13 | 
            +
                {
         | 
| 14 | 
            +
                  model: model.name,
         | 
| 15 | 
            +
                  columns: model.column_names,
         | 
| 16 | 
            +
                  scopes: build_scopes,
         | 
| 17 | 
            +
                  associations: build_associations
         | 
| 18 | 
            +
                }
         | 
| 19 | 
            +
              end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
              private
         | 
| 22 | 
            +
             | 
| 23 | 
            +
              def build_scopes
         | 
| 24 | 
            +
                case macro
         | 
| 25 | 
            +
                when "has_many"
         | 
| 26 | 
            +
                  model.model_explorer_scopes.map(&:to_s)
         | 
| 27 | 
            +
                else
         | 
| 28 | 
            +
                  []
         | 
| 29 | 
            +
                end
         | 
| 30 | 
            +
              end
         | 
| 31 | 
            +
             | 
| 32 | 
            +
              def build_associations
         | 
| 33 | 
            +
                model.reflect_on_all_associations.filter_map do |association|
         | 
| 34 | 
            +
                  next if association.options[:through] || association.name.to_s == parent
         | 
| 35 | 
            +
             | 
| 36 | 
            +
                  AssociationSerializer.new(association).to_h
         | 
| 37 | 
            +
                end
         | 
| 38 | 
            +
              end
         | 
| 39 | 
            +
            end
         | 
| @@ -0,0 +1,28 @@ | |
| 1 | 
            +
            <!DOCTYPE html>
         | 
| 2 | 
            +
            <html lang="<%= I18n.locale %>">
         | 
| 3 | 
            +
              <head>
         | 
| 4 | 
            +
                <title>
         | 
| 5 | 
            +
                  <%= t(".title") %>
         | 
| 6 | 
            +
                </title>
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                <%= csrf_meta_tags %>
         | 
| 9 | 
            +
                <%= csp_meta_tag %>
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                <meta name="viewport" content="width=device-width, initial-scale=1">
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                <%= stylesheet_link_tag("model_explorer/application", media: "all") %>
         | 
| 14 | 
            +
                <%= javascript_include_tag("model_explorer/application") %>
         | 
| 15 | 
            +
              </head>
         | 
| 16 | 
            +
             | 
| 17 | 
            +
              <body>
         | 
| 18 | 
            +
                <nav class="navbar navbar-expand-lg bg-primary navbar-dark">
         | 
| 19 | 
            +
                  <div class="container">
         | 
| 20 | 
            +
                    <a class="navbar-brand" href="<%= model_explorer.models_path %>">
         | 
| 21 | 
            +
                      <%= t(".title") %>
         | 
| 22 | 
            +
                    </a>
         | 
| 23 | 
            +
                  </div>
         | 
| 24 | 
            +
                </nav>
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                <%= yield %>
         | 
| 27 | 
            +
              </body>
         | 
| 28 | 
            +
            </html>
         | 
| @@ -0,0 +1,14 @@ | |
| 1 | 
            +
            <div class="container">
         | 
| 2 | 
            +
              <div class="row g-4">
         | 
| 3 | 
            +
                <div class="col-12 col-md-6">
         | 
| 4 | 
            +
                  <%= render(
         | 
| 5 | 
            +
                    partial: "model_explorer/models/partials/form",
         | 
| 6 | 
            +
                    locals: {models: @models}
         | 
| 7 | 
            +
                  ) %>
         | 
| 8 | 
            +
                </div>
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                <div class="col-12 col-md-6">
         | 
| 11 | 
            +
                  <%= render(partial: "model_explorer/models/partials/record_details") %>
         | 
| 12 | 
            +
                </div>
         | 
| 13 | 
            +
              </div>
         | 
| 14 | 
            +
            </div>
         | 
| @@ -0,0 +1,49 @@ | |
| 1 | 
            +
            <span class="h4 my-3 d-block">
         | 
| 2 | 
            +
              <%= t(".select_model") %>
         | 
| 3 | 
            +
            </span>
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            <%= form_with(
         | 
| 6 | 
            +
              url: exports_path,
         | 
| 7 | 
            +
              method: :get,
         | 
| 8 | 
            +
              local: false,
         | 
| 9 | 
            +
              html: {
         | 
| 10 | 
            +
                id: "export-form",
         | 
| 11 | 
            +
                class: "needs-validation",
         | 
| 12 | 
            +
                novalidate: true
         | 
| 13 | 
            +
              }
         | 
| 14 | 
            +
            ) do |f| %>
         | 
| 15 | 
            +
              <div class="row">
         | 
| 16 | 
            +
                <div class="col-9">
         | 
| 17 | 
            +
                  <%= f.select(
         | 
| 18 | 
            +
                    :model,
         | 
| 19 | 
            +
                    models,
         | 
| 20 | 
            +
                    {prompt: t(".select_prompt"), include_blank: true},
         | 
| 21 | 
            +
                    {id: "association-select", class: "p-0", required: true}
         | 
| 22 | 
            +
                  ) %>
         | 
| 23 | 
            +
                  <div class="invalid-feedback">
         | 
| 24 | 
            +
                    <%= t(".required_field") %>
         | 
| 25 | 
            +
                  </div>
         | 
| 26 | 
            +
                </div>
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                <div class="col-3 pl-0">
         | 
| 29 | 
            +
                  <%= f.text_field(
         | 
| 30 | 
            +
                    :record_id,
         | 
| 31 | 
            +
                    placeholder: t(".record_id_placeholder"),
         | 
| 32 | 
            +
                    class: "form-control",
         | 
| 33 | 
            +
                    required: true
         | 
| 34 | 
            +
                  ) %>
         | 
| 35 | 
            +
             | 
| 36 | 
            +
                  <div class="invalid-feedback">
         | 
| 37 | 
            +
                    <%= t(".required_field") %>
         | 
| 38 | 
            +
                  </div>
         | 
| 39 | 
            +
                </div>
         | 
| 40 | 
            +
              </div>
         | 
| 41 | 
            +
             | 
| 42 | 
            +
              <div id="associations-container" class="my-3"></div>
         | 
| 43 | 
            +
             | 
| 44 | 
            +
              <%= render(partial: "model_explorer/models/partials/select_template") %>
         | 
| 45 | 
            +
             | 
| 46 | 
            +
              <div class="w-100 my-3">
         | 
| 47 | 
            +
                <%= f.submit(t(".submit"), class: "btn btn-primary w-100") %>
         | 
| 48 | 
            +
              </div>
         | 
| 49 | 
            +
            <% end %>
         | 
| @@ -0,0 +1,43 @@ | |
| 1 | 
            +
            <div id="record-details">
         | 
| 2 | 
            +
              <span class="h4 my-3 d-block">
         | 
| 3 | 
            +
                <%= t(".title") %>
         | 
| 4 | 
            +
              </span>
         | 
| 5 | 
            +
             | 
| 6 | 
            +
              <div class="vh-50 overflow-auto shadow-sm">
         | 
| 7 | 
            +
                <div class="card" id="no-record">
         | 
| 8 | 
            +
                  <span class="d-block text-center w-100 m-4">
         | 
| 9 | 
            +
                    <%= t(".no_record") %>
         | 
| 10 | 
            +
                  </span>
         | 
| 11 | 
            +
                </div>
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                <div id="json-data" class="position-relative d-none">
         | 
| 14 | 
            +
                  <div class="d-flex position-absolute top-0 end-0 m-2 gap-2">
         | 
| 15 | 
            +
                    <button
         | 
| 16 | 
            +
                      id="copy-record-details"
         | 
| 17 | 
            +
                      class="btn btn-sm btn-primary"
         | 
| 18 | 
            +
                      data-text="<%= t(".copy_text") %>"
         | 
| 19 | 
            +
                    >
         | 
| 20 | 
            +
                      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
         | 
| 21 | 
            +
                        <path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path>
         | 
| 22 | 
            +
                        <path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path>
         | 
| 23 | 
            +
                      </svg>
         | 
| 24 | 
            +
                    </button>
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                    <a id="view-record-details" class="btn btn-sm btn-primary" target="_blank" rel="noopener noreferrer nofollow">
         | 
| 27 | 
            +
                      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
         | 
| 28 | 
            +
                        <path d="M4 1.75C4 .784 4.784 0 5.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0 1 14.25 15h-9a.75.75 0 0 1 0-1.5h9a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 10 4.25V1.5H5.75a.25.25 0 0 0-.25.25v2.5a.75.75 0 0 1-1.5 0Zm1.72 4.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.47-1.47-1.47-1.47a.75.75 0 0 1 0-1.06ZM3.28 7.78 1.81 9.25l1.47 1.47a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Zm8.22-6.218V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"></path>
         | 
| 29 | 
            +
                      </svg>
         | 
| 30 | 
            +
                    </a>
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                    <a id="download-record-details" class="btn btn-sm btn-primary" download>
         | 
| 33 | 
            +
                      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
         | 
| 34 | 
            +
                        <path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"></path>
         | 
| 35 | 
            +
                        <path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"></path>
         | 
| 36 | 
            +
                      </svg>
         | 
| 37 | 
            +
                    </a>
         | 
| 38 | 
            +
                  </div>
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                  <pre id="json-pre" class="language-json border-none"></pre>
         | 
| 41 | 
            +
                </div>
         | 
| 42 | 
            +
              </div>
         | 
| 43 | 
            +
            </div>
         | 
| @@ -0,0 +1,53 @@ | |
| 1 | 
            +
            <template
         | 
| 2 | 
            +
              id="associations-select-template"
         | 
| 3 | 
            +
              data-max-items="<%= ModelExplorer.max_items_per_association %>"
         | 
| 4 | 
            +
              data-max-scopes="<%= ModelExplorer.max_scopes_per_association %>"
         | 
| 5 | 
            +
            >
         | 
| 6 | 
            +
              <div class="accordion mt-3" id="associations-accordion-TEMP_ID">
         | 
| 7 | 
            +
                <div class="card overflow-unset">
         | 
| 8 | 
            +
                  <button
         | 
| 9 | 
            +
                    class="card-header btn btn-link btn-block text-decoration-none text-left text-dark"
         | 
| 10 | 
            +
                    type="button"
         | 
| 11 | 
            +
                    data-bs-toggle="collapse"
         | 
| 12 | 
            +
                    data-bs-target="#associations-collapse-TEMP_ID"
         | 
| 13 | 
            +
                    aria-expanded="false"
         | 
| 14 | 
            +
                  ></button>
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                  <div class="collapse show" id="associations-collapse-TEMP_ID">
         | 
| 17 | 
            +
                    <div class="card-body" id="associations-container-TEMP_ID">
         | 
| 18 | 
            +
                      <select
         | 
| 19 | 
            +
                        id="columns-select-TEMP_ID"
         | 
| 20 | 
            +
                        class="p-0"
         | 
| 21 | 
            +
                        data-placeholder="<%= t(".select_columns") %>"
         | 
| 22 | 
            +
                        multiple
         | 
| 23 | 
            +
                      ></select>
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                      <% if ModelExplorer.association_scopes_enabled? %>
         | 
| 26 | 
            +
                        <select
         | 
| 27 | 
            +
                          id="scopes-select-TEMP_ID"
         | 
| 28 | 
            +
                          class="mt-3 p-0"
         | 
| 29 | 
            +
                          data-placeholder="<%= t(".select_scopes") %>"
         | 
| 30 | 
            +
                          multiple
         | 
| 31 | 
            +
                        ></select>
         | 
| 32 | 
            +
                      <% end %>
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                      <% if ModelExplorer.association_select_enabled? %>
         | 
| 35 | 
            +
                        <select
         | 
| 36 | 
            +
                          id="associations-select-TEMP_ID"
         | 
| 37 | 
            +
                          name="association_attributes[scopes][]"
         | 
| 38 | 
            +
                          class="mt-3 p-0"
         | 
| 39 | 
            +
                          data-placeholder="<%= t(".select_associations") %>"
         | 
| 40 | 
            +
                          multiple
         | 
| 41 | 
            +
                        ></select>
         | 
| 42 | 
            +
                      <% end %>
         | 
| 43 | 
            +
             | 
| 44 | 
            +
                      <input
         | 
| 45 | 
            +
                        id="associations-input-TEMP_ID"
         | 
| 46 | 
            +
                        name="association_attributes[name]"
         | 
| 47 | 
            +
                        type="hidden"
         | 
| 48 | 
            +
                      >
         | 
| 49 | 
            +
                    </div>
         | 
| 50 | 
            +
                  </div>
         | 
| 51 | 
            +
                </div>
         | 
| 52 | 
            +
              </div>
         | 
| 53 | 
            +
            </template>
         | 
    
        data/config/routes.rb
    ADDED
    
    
    
        data/docs/example.png
    ADDED
    
    | Binary file | 
| @@ -0,0 +1,7 @@ | |
| 1 | 
            +
            Description:
         | 
| 2 | 
            +
                Generates an initializer to configure ModelExplorer and mount the engine in the routes file.
         | 
| 3 | 
            +
             | 
| 4 | 
            +
            Example:
         | 
| 5 | 
            +
                `bin/rails generate model_explorer:install --routes
         | 
| 6 | 
            +
                  create  config/initializers/model_explorer.rb
         | 
| 7 | 
            +
                   route  mount ModelExplorer::Engine, at: "/model_explorer"`
         | 
| @@ -0,0 +1,24 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "rails/generators/active_record"
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            module ModelExplorer
         | 
| 6 | 
            +
              class InstallGenerator < Rails::Generators::Base
         | 
| 7 | 
            +
                source_root File.expand_path("templates", __dir__)
         | 
| 8 | 
            +
             | 
| 9 | 
            +
                class_option :routes, desc: "Generate routes", type: :boolean, default: false
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                def copy_initializer
         | 
| 12 | 
            +
                  template "model_explorer.rb", "config/initializers/model_explorer.rb"
         | 
| 13 | 
            +
                end
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                def add_model_explorer_routes
         | 
| 16 | 
            +
                  return unless options.routes?
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                  route <<~ROUTE
         | 
| 19 | 
            +
                    mount ModelExplorer::Engine, at: "/model_explorer"
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                  ROUTE
         | 
| 22 | 
            +
                end
         | 
| 23 | 
            +
              end
         | 
| 24 | 
            +
            end
         | 
| @@ -0,0 +1,24 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            ModelExplorer.configure do |config|
         | 
| 4 | 
            +
              # Enable the basic auth. Disabled by default.
         | 
| 5 | 
            +
              # config.basic_auth_enabled = true
         | 
| 6 | 
            +
              # config.basic_auth_username = "admin"
         | 
| 7 | 
            +
              # config.basic_auth_password = "password"
         | 
| 8 | 
            +
             | 
| 9 | 
            +
              # Add a custom proc to verify the access.
         | 
| 10 | 
            +
              # config.verify_access_proc = ->(controller) do
         | 
| 11 | 
            +
              #   controller.current_admin_user&.super_admin?
         | 
| 12 | 
            +
              # end
         | 
| 13 | 
            +
             | 
| 14 | 
            +
              # Customize the regular expression for filtering attributes.
         | 
| 15 | 
            +
              # config.filter_attributes_regexp = /password|token|secret/i
         | 
| 16 | 
            +
             | 
| 17 | 
            +
              # Set the maximum number of items to select per association.
         | 
| 18 | 
            +
              # Disable by setting to 0.
         | 
| 19 | 
            +
              # config.max_items_per_association = 5
         | 
| 20 | 
            +
             | 
| 21 | 
            +
              # Set the maximum number of scopes to select per association.
         | 
| 22 | 
            +
              # Disable by setting to 0.
         | 
| 23 | 
            +
              # config.max_scopes_per_association = 5
         | 
| 24 | 
            +
            end
         | 
| @@ -0,0 +1,74 @@ | |
| 1 | 
            +
            module ModelExplorer
         | 
| 2 | 
            +
              module Associations
         | 
| 3 | 
            +
                class Base
         | 
| 4 | 
            +
                  extend Forwardable
         | 
| 5 | 
            +
             | 
| 6 | 
            +
                  def_delegators :reflection, :name, :macro, :klass
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  attr_reader :record, :reflection, :association
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                  def initialize(record, reflection, association)
         | 
| 11 | 
            +
                    @record = record
         | 
| 12 | 
            +
                    @reflection = reflection
         | 
| 13 | 
            +
                    @association = association
         | 
| 14 | 
            +
                  end
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                  def export
         | 
| 17 | 
            +
                    raise NotImplementedError
         | 
| 18 | 
            +
                  end
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                  def records
         | 
| 21 | 
            +
                    @_records ||=
         | 
| 22 | 
            +
                      klass
         | 
| 23 | 
            +
                      .connection
         | 
| 24 | 
            +
                      .exec_query(query.to_sql)
         | 
| 25 | 
            +
                      .map { |record_attributes| build_record(record_attributes) }
         | 
| 26 | 
            +
                  end
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                  protected
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                  def query
         | 
| 31 | 
            +
                    raise NotImplementedError
         | 
| 32 | 
            +
                  end
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                  def default_query
         | 
| 35 | 
            +
                    klass
         | 
| 36 | 
            +
                      .select(ModelExplorer::Select.new(klass, association[:columns]).to_a)
         | 
| 37 | 
            +
                      .where(reflection_query)
         | 
| 38 | 
            +
                  end
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                  def build_record(record_attributes)
         | 
| 41 | 
            +
                    attributes =
         | 
| 42 | 
            +
                      if association[:columns].present?
         | 
| 43 | 
            +
                        record_attributes.slice(*association[:columns])
         | 
| 44 | 
            +
                      else
         | 
| 45 | 
            +
                        record_attributes
         | 
| 46 | 
            +
                      end
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                    ModelExplorer::Record.new(
         | 
| 49 | 
            +
                      record_attributes[klass.primary_key],
         | 
| 50 | 
            +
                      attributes,
         | 
| 51 | 
            +
                      klass
         | 
| 52 | 
            +
                    )
         | 
| 53 | 
            +
                  end
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                  def reflection_query
         | 
| 56 | 
            +
                    foreign_key = reflection.foreign_key
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                    case reflection.macro
         | 
| 59 | 
            +
                    when :has_many, :has_one then {foreign_key => record[klass.primary_key]}
         | 
| 60 | 
            +
                    when :belongs_to then {"#{reflection.table_name}.id" => record[klass.primary_key]}
         | 
| 61 | 
            +
                    end
         | 
| 62 | 
            +
                  end
         | 
| 63 | 
            +
             | 
| 64 | 
            +
                  def export_records
         | 
| 65 | 
            +
                    records.map do |relation_record|
         | 
| 66 | 
            +
                      ModelExplorer::Export.new(
         | 
| 67 | 
            +
                        record: relation_record,
         | 
| 68 | 
            +
                        associations: association[:associations]
         | 
| 69 | 
            +
                      ).data
         | 
| 70 | 
            +
                    end
         | 
| 71 | 
            +
                  end
         | 
| 72 | 
            +
                end
         | 
| 73 | 
            +
              end
         | 
| 74 | 
            +
            end
         |