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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +8 -0
  4. data/.simplecov +17 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CODE_OF_CONDUCT.md +84 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +139 -0
  9. data/Rakefile +12 -0
  10. data/app/assets/config/model_explorer/manifest.js +2 -0
  11. data/app/assets/javascripts/model_explorer/application.js +5 -0
  12. data/app/assets/javascripts/model_explorer/association_manager.js +192 -0
  13. data/app/assets/javascripts/model_explorer/association_select.js +23 -0
  14. data/app/assets/javascripts/model_explorer/copy_button.js +25 -0
  15. data/app/assets/javascripts/model_explorer/model_form.js +61 -0
  16. data/app/assets/javascripts/model_explorer/models_controller.js +43 -0
  17. data/app/assets/stylesheets/model_explorer/application.css +7 -0
  18. data/app/assets/stylesheets/model_explorer/models.css +45 -0
  19. data/app/controllers/model_explorer/application_controller.rb +41 -0
  20. data/app/controllers/model_explorer/exports_controller.rb +53 -0
  21. data/app/controllers/model_explorer/models_controller.rb +23 -0
  22. data/app/serializers/application_serializer.rb +11 -0
  23. data/app/serializers/association_serializer.rb +21 -0
  24. data/app/serializers/model_serializer.rb +39 -0
  25. data/app/views/layouts/model_explorer/application.html.erb +28 -0
  26. data/app/views/model_explorer/models/index.html.erb +14 -0
  27. data/app/views/model_explorer/models/partials/_form.html.erb +49 -0
  28. data/app/views/model_explorer/models/partials/_record_details.html.erb +43 -0
  29. data/app/views/model_explorer/models/partials/_select_template.html.erb +53 -0
  30. data/config/locales/en.yml +3 -0
  31. data/config/locales/fr.yml +3 -0
  32. data/config/locales/views/layouts/model_explorer/application.en.yml +5 -0
  33. data/config/locales/views/layouts/model_explorer/application.fr.yml +5 -0
  34. data/config/locales/views/model_explorer/models/partials/form.en.yml +10 -0
  35. data/config/locales/views/model_explorer/models/partials/form.fr.yml +10 -0
  36. data/config/locales/views/model_explorer/models/partials/record_details.en.yml +9 -0
  37. data/config/locales/views/model_explorer/models/partials/record_details.fr.yml +9 -0
  38. data/config/locales/views/model_explorer/models/partials/select_template.en.yml +8 -0
  39. data/config/locales/views/model_explorer/models/partials/select_template.fr.yml +8 -0
  40. data/config/routes.rb +8 -0
  41. data/docs/example.png +0 -0
  42. data/lib/generators/model_explorer/USAGE +7 -0
  43. data/lib/generators/model_explorer/install_generator.rb +24 -0
  44. data/lib/generators/model_explorer/templates/model_explorer.rb.tt +24 -0
  45. data/lib/model_explorer/associations/base.rb +74 -0
  46. data/lib/model_explorer/associations/many.rb +43 -0
  47. data/lib/model_explorer/associations/singular.rb +19 -0
  48. data/lib/model_explorer/associations.rb +33 -0
  49. data/lib/model_explorer/engine.rb +39 -0
  50. data/lib/model_explorer/export.rb +53 -0
  51. data/lib/model_explorer/import.rb +64 -0
  52. data/lib/model_explorer/record.rb +15 -0
  53. data/lib/model_explorer/scopes.rb +23 -0
  54. data/lib/model_explorer/select.rb +28 -0
  55. data/lib/model_explorer/version.rb +5 -0
  56. data/lib/model_explorer.rb +77 -0
  57. data/sig/model_explorer.rbs +4 -0
  58. data/vendor/assets/javascripts/bootstrap.min.js +7 -0
  59. data/vendor/assets/javascripts/prism.min.js +4 -0
  60. data/vendor/assets/javascripts/tom_select.min.js +440 -0
  61. data/vendor/assets/stylesheets/bootstrap.min.css +6 -0
  62. data/vendor/assets/stylesheets/prism.min.css +3 -0
  63. data/vendor/assets/stylesheets/tom_select.min.css +15 -0
  64. 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationSerializer
4
+ def to_h
5
+ raise NotImplementedError
6
+ end
7
+
8
+ def to_json(*)
9
+ to_h.to_json
10
+ end
11
+ 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>
@@ -0,0 +1,3 @@
1
+ en:
2
+ model_explorer:
3
+ unauthorized: "You are not authorized to access this page."
@@ -0,0 +1,3 @@
1
+ fr:
2
+ model_explorer:
3
+ unauthorized: "Vous n'êtes pas autorisé à accéder à cette page."
@@ -0,0 +1,5 @@
1
+ en:
2
+ layouts:
3
+ model_explorer:
4
+ application:
5
+ title: "Model Explorer"
@@ -0,0 +1,5 @@
1
+ fr:
2
+ layouts:
3
+ model_explorer:
4
+ application:
5
+ title: "Model Explorer"
@@ -0,0 +1,10 @@
1
+ en:
2
+ model_explorer:
3
+ models:
4
+ partials:
5
+ form:
6
+ select_model: "Select a model to inspect"
7
+ select_prompt: "Select a model"
8
+ required_field: "Required field"
9
+ record_id_placeholder: "ID"
10
+ submit: "Search"
@@ -0,0 +1,10 @@
1
+ fr:
2
+ model_explorer:
3
+ models:
4
+ partials:
5
+ form:
6
+ select_model: "Sélectionnez un modèle à inspecter"
7
+ select_prompt: "Sélectionnez un modèle"
8
+ required_field: "Champ obligatoire"
9
+ record_id_placeholder: "ID"
10
+ submit: "Rechercher"
@@ -0,0 +1,9 @@
1
+ en:
2
+ model_explorer:
3
+ models:
4
+ partials:
5
+ record_details:
6
+ title: "Record Details"
7
+ no_record: "No record selected"
8
+ copy: "Copy"
9
+ copy_text: "Copied!"
@@ -0,0 +1,9 @@
1
+ fr:
2
+ model_explorer:
3
+ models:
4
+ partials:
5
+ record_details:
6
+ title: "Détails de l'enregistrement"
7
+ no_record: "Aucun enregistrement sélectionné"
8
+ copy: "Copier"
9
+ copy_text: "Copié !"
@@ -0,0 +1,8 @@
1
+ en:
2
+ model_explorer:
3
+ models:
4
+ partials:
5
+ select_template:
6
+ select_associations: "Select associations"
7
+ select_columns: "Select columns"
8
+ select_scopes: "Select scopes"
@@ -0,0 +1,8 @@
1
+ fr:
2
+ model_explorer:
3
+ models:
4
+ partials:
5
+ select_template:
6
+ select_associations: "Sélectionnez les associations"
7
+ select_columns: "Sélectionnez les colonnes"
8
+ select_scopes: "Sélectionnez les scopes"
data/config/routes.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ ModelExplorer::Engine.routes.draw do
4
+ root to: "models#index"
5
+
6
+ resources :models, only: [:index, :show]
7
+ resource :exports, only: [:show]
8
+ end
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