nquery 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 (150) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +52 -0
  4. data/Rakefile +9 -0
  5. data/app/assets/builds/nquery/application.css +339 -0
  6. data/app/assets/builds/nquery/application.js +989 -0
  7. data/app/assets/config/nquery_manifest.js +2 -0
  8. data/app/components/nquery/_card_component.html.erb +6 -0
  9. data/app/components/nquery/_card_table_component.html.erb +14 -0
  10. data/app/components/nquery/_flash_card_component.html.erb +32 -0
  11. data/app/components/nquery/_flash_cards_component.html.erb +3 -0
  12. data/app/components/nquery/_infinite_scroll_component.html.erb +11 -0
  13. data/app/components/nquery/card_component.rb +18 -0
  14. data/app/components/nquery/card_table_component.rb +19 -0
  15. data/app/components/nquery/component.rb +26 -0
  16. data/app/components/nquery/flash_card_component.rb +51 -0
  17. data/app/components/nquery/flash_cards_component.rb +20 -0
  18. data/app/components/nquery/infinite_scroll_component.rb +19 -0
  19. data/app/controllers/concerns/nquery/breadcrumbs.rb +238 -0
  20. data/app/controllers/concerns/nquery/browsable.rb +56 -0
  21. data/app/controllers/concerns/nquery/chart_actions.rb +104 -0
  22. data/app/controllers/concerns/nquery/chart_results.rb +30 -0
  23. data/app/controllers/nquery/admin/base_controller.rb +9 -0
  24. data/app/controllers/nquery/admin/data_sources_controller.rb +47 -0
  25. data/app/controllers/nquery/admin/groups_controller.rb +63 -0
  26. data/app/controllers/nquery/admin/logs_controller.rb +28 -0
  27. data/app/controllers/nquery/admin/permissions_controller.rb +53 -0
  28. data/app/controllers/nquery/admin/users_controller.rb +80 -0
  29. data/app/controllers/nquery/application_controller.rb +70 -0
  30. data/app/controllers/nquery/charts_controller.rb +51 -0
  31. data/app/controllers/nquery/collection/dashboards_controller.rb +38 -0
  32. data/app/controllers/nquery/collections_controller.rb +117 -0
  33. data/app/controllers/nquery/dashboard/charts_controller.rb +62 -0
  34. data/app/controllers/nquery/dashboards_controller.rb +90 -0
  35. data/app/controllers/nquery/embed/charts_controller.rb +24 -0
  36. data/app/controllers/nquery/embed/dashboards_controller.rb +17 -0
  37. data/app/controllers/nquery/home_controller.rb +16 -0
  38. data/app/controllers/nquery/imports_controller.rb +41 -0
  39. data/app/controllers/nquery/queries_controller.rb +105 -0
  40. data/app/controllers/nquery/registrations_controller.rb +31 -0
  41. data/app/controllers/nquery/sessions_controller.rb +28 -0
  42. data/app/helpers/nquery/charts_helper.rb +33 -0
  43. data/app/helpers/nquery/collections_helper.rb +13 -0
  44. data/app/helpers/nquery/components_helper.rb +29 -0
  45. data/app/helpers/nquery/home_helper.rb +23 -0
  46. data/app/helpers/nquery/icon_helper.rb +9 -0
  47. data/app/helpers/nquery/navigation_helper.rb +10 -0
  48. data/app/javascript/nquery/controllers/chart_preview_controller.js +125 -0
  49. data/app/javascript/nquery/controllers/csv_mapper_controller.js +23 -0
  50. data/app/javascript/nquery/controllers/dashboard_grid_controller.js +25 -0
  51. data/app/javascript/nquery/controllers/query_editor_controller.js +41 -0
  52. data/app/models/nquery/application_permission.rb +12 -0
  53. data/app/models/nquery/application_record.rb +7 -0
  54. data/app/models/nquery/audit.rb +38 -0
  55. data/app/models/nquery/chart.rb +37 -0
  56. data/app/models/nquery/collection.rb +35 -0
  57. data/app/models/nquery/collection_permission.rb +12 -0
  58. data/app/models/nquery/csv_upload.rb +11 -0
  59. data/app/models/nquery/dashboard.rb +27 -0
  60. data/app/models/nquery/dashboard_card.rb +8 -0
  61. data/app/models/nquery/data_permission.rb +13 -0
  62. data/app/models/nquery/data_source.rb +27 -0
  63. data/app/models/nquery/embed_token.rb +21 -0
  64. data/app/models/nquery/group.rb +27 -0
  65. data/app/models/nquery/group_membership.rb +10 -0
  66. data/app/models/nquery/query.rb +19 -0
  67. data/app/models/nquery/user.rb +58 -0
  68. data/app/views/layouts/nquery/application.html.erb +32 -0
  69. data/app/views/layouts/nquery/auth.html.erb +16 -0
  70. data/app/views/layouts/nquery/embed.html.erb +12 -0
  71. data/app/views/nquery/admin/data_sources/edit.html.erb +8 -0
  72. data/app/views/nquery/admin/data_sources/index.html.erb +21 -0
  73. data/app/views/nquery/admin/data_sources/new.html.erb +7 -0
  74. data/app/views/nquery/admin/groups/edit.html.erb +6 -0
  75. data/app/views/nquery/admin/groups/index.html.erb +21 -0
  76. data/app/views/nquery/admin/groups/new.html.erb +6 -0
  77. data/app/views/nquery/admin/groups/show.html.erb +24 -0
  78. data/app/views/nquery/admin/logs/index.html.erb +74 -0
  79. data/app/views/nquery/admin/permissions/_collection.html.erb +25 -0
  80. data/app/views/nquery/admin/permissions/_data_source.html.erb +27 -0
  81. data/app/views/nquery/admin/permissions/_group.html.erb +42 -0
  82. data/app/views/nquery/admin/permissions/by_collection.html.erb +10 -0
  83. data/app/views/nquery/admin/permissions/by_data_source.html.erb +7 -0
  84. data/app/views/nquery/admin/permissions/by_group.html.erb +12 -0
  85. data/app/views/nquery/admin/users/edit.html.erb +17 -0
  86. data/app/views/nquery/admin/users/index.html.erb +34 -0
  87. data/app/views/nquery/admin/users/new.html.erb +14 -0
  88. data/app/views/nquery/admin/users/show.html.erb +6 -0
  89. data/app/views/nquery/charts/_builder_form.html.erb +185 -0
  90. data/app/views/nquery/charts/_new_header.html.erb +31 -0
  91. data/app/views/nquery/charts/_schema_tree.html.erb +25 -0
  92. data/app/views/nquery/charts/edit.html.erb +6 -0
  93. data/app/views/nquery/charts/embed.html.erb +15 -0
  94. data/app/views/nquery/charts/new.html.erb +5 -0
  95. data/app/views/nquery/charts/show.html.erb +15 -0
  96. data/app/views/nquery/charts/update.turbo_stream.erb +1 -0
  97. data/app/views/nquery/collection/dashboards/new.html.erb +13 -0
  98. data/app/views/nquery/collections/_dashboard_card.html.erb +57 -0
  99. data/app/views/nquery/collections/edit.html.erb +15 -0
  100. data/app/views/nquery/collections/index.html.erb +41 -0
  101. data/app/views/nquery/collections/new.html.erb +15 -0
  102. data/app/views/nquery/collections/show.html.erb +100 -0
  103. data/app/views/nquery/dashboard/charts/new.html.erb +6 -0
  104. data/app/views/nquery/dashboards/_chart_card.html.erb +16 -0
  105. data/app/views/nquery/dashboards/edit.html.erb +34 -0
  106. data/app/views/nquery/dashboards/index.html.erb +35 -0
  107. data/app/views/nquery/dashboards/show.html.erb +46 -0
  108. data/app/views/nquery/embed/charts/show.html.erb +9 -0
  109. data/app/views/nquery/embed/dashboards/show.html.erb +7 -0
  110. data/app/views/nquery/home/index.html.erb +143 -0
  111. data/app/views/nquery/imports/new.html.erb +20 -0
  112. data/app/views/nquery/queries/edit.html.erb +40 -0
  113. data/app/views/nquery/queries/new.html.erb +37 -0
  114. data/app/views/nquery/registrations/new.html.erb +34 -0
  115. data/app/views/nquery/sessions/new.html.erb +29 -0
  116. data/app/views/nquery/shared/_auth_shell.html.erb +22 -0
  117. data/app/views/nquery/shared/_breadcrumbs.html.erb +15 -0
  118. data/app/views/nquery/shared/_chart_actions_menu.html.erb +27 -0
  119. data/app/views/nquery/shared/_chart_row_actions.html.erb +16 -0
  120. data/app/views/nquery/shared/_flash.html.erb +1 -0
  121. data/app/views/nquery/shared/_icon.html.erb +50 -0
  122. data/app/views/nquery/shared/_sidebar.html.erb +16 -0
  123. data/app/views/nquery/shared/_topbar.html.erb +12 -0
  124. data/config/importmap.rb +9 -0
  125. data/config/routes.rb +78 -0
  126. data/db/migrate/20260721000001_create_nquery_tables.rb +146 -0
  127. data/db/migrate/20260721180000_add_archived_at_to_nquery_dashboards.rb +8 -0
  128. data/db/migrate/20260721190000_add_archived_at_to_nquery_charts.rb +8 -0
  129. data/db/migrate/20260721200000_add_archived_at_to_nquery_collections.rb +8 -0
  130. data/lib/generators/nquery/install_generator.rb +38 -0
  131. data/lib/nquery/authorizes_collection.rb +50 -0
  132. data/lib/nquery/configuration.rb +44 -0
  133. data/lib/nquery/csv_importer.rb +68 -0
  134. data/lib/nquery/data_sources/adapter.rb +34 -0
  135. data/lib/nquery/data_sources/mysql_adapter.rb +24 -0
  136. data/lib/nquery/data_sources/postgresql_adapter.rb +46 -0
  137. data/lib/nquery/data_sources/rails_adapter.rb +54 -0
  138. data/lib/nquery/data_sources/sqlite_adapter.rb +8 -0
  139. data/lib/nquery/embed_token_service.rb +83 -0
  140. data/lib/nquery/engine.rb +55 -0
  141. data/lib/nquery/permissions/resolver.rb +83 -0
  142. data/lib/nquery/query_runner.rb +69 -0
  143. data/lib/nquery/sample_data/ecommerce.rb +157 -0
  144. data/lib/nquery/schema_explorer.rb +28 -0
  145. data/lib/nquery/seeder.rb +131 -0
  146. data/lib/nquery/version.rb +5 -0
  147. data/lib/nquery.rb +36 -0
  148. data/lib/tasks/nquery.rake +9 -0
  149. data/nquery.gemspec +40 -0
  150. metadata +395 -0
@@ -0,0 +1 @@
1
+ <%= nq_flash_cards %>
@@ -0,0 +1,50 @@
1
+ <% classes = ["nq-icon", html_options[:class]].compact.join(" ") %>
2
+ <svg class="<%= classes %>" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3
+ <% case name %>
4
+ <% when "edit" %>
5
+ <path d="M12 20h9" />
6
+ <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
7
+ <% when "archive" %>
8
+ <rect x="2" y="3" width="20" height="5" rx="1" />
9
+ <path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" />
10
+ <path d="M10 12h4" />
11
+ <% when "unarchive" %>
12
+ <path d="M21 8v13H3V8" />
13
+ <path d="M1 3h22v5H1z" />
14
+ <path d="M12 12v6" />
15
+ <path d="m9 15 3-3 3 3" />
16
+ <% when "trash" %>
17
+ <path d="M3 6h18" />
18
+ <path d="M8 6V4h8v2" />
19
+ <path d="M10 11v6" />
20
+ <path d="M14 11v6" />
21
+ <path d="M5 6v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6" />
22
+ <% when "folder" %>
23
+ <path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
24
+ <% when "chart" %>
25
+ <path d="M3 3v18h18" />
26
+ <path d="M7 16V9" />
27
+ <path d="M12 16V5" />
28
+ <path d="M17 16v-3" />
29
+ <% when "dashboard" %>
30
+ <rect x="3" y="3" width="7" height="9" rx="1" />
31
+ <rect x="14" y="3" width="7" height="5" rx="1" />
32
+ <rect x="14" y="12" width="7" height="9" rx="1" />
33
+ <rect x="3" y="16" width="7" height="5" rx="1" />
34
+ <% when "more" %>
35
+ <circle cx="12" cy="5" r="1" />
36
+ <circle cx="12" cy="12" r="1" />
37
+ <circle cx="12" cy="19" r="1" />
38
+ <% when "plus" %>
39
+ <path d="M12 5v14" />
40
+ <path d="M5 12h14" />
41
+ <% when "arrow-right" %>
42
+ <path d="M5 12h14" />
43
+ <path d="m12 5 7 7-7 7" />
44
+ <% when "format" %>
45
+ <path d="M21 10H3" />
46
+ <path d="M21 6H3" />
47
+ <path d="M11 14H3" />
48
+ <path d="M15 18H3" />
49
+ <% end %>
50
+ </svg>
@@ -0,0 +1,16 @@
1
+ <aside class="nq-sidebar">
2
+ <div class="nq-logo">nquery</div>
3
+ <nav class="nq-nav">
4
+ <%= link_to "Home", root_path, class: nav_link_class("nquery/home") %>
5
+ <%= link_to "Collections", collections_path, class: nav_link_class("nquery/collections") %>
6
+ <%= link_to "Dashboards", dashboards_path, class: nav_link_class("nquery/dashboards", "nquery/dashboard/charts") %>
7
+ <% if current_nquery_user&.admin? %>
8
+ <div class="nq-nav-section">Admin</div>
9
+ <%= link_to "Users", admin_users_path, class: nav_link_class("nquery/admin/users") %>
10
+ <%= link_to "Groups", admin_groups_path, class: nav_link_class("nquery/admin/groups") %>
11
+ <%= link_to "Data sources", admin_data_sources_path, class: nav_link_class("nquery/admin/data_sources") %>
12
+ <%= link_to "Permissions", by_group_admin_permissions_path, class: nav_link_class("nquery/admin/permissions") %>
13
+ <%= link_to "Logs", admin_logs_path, class: nav_link_class("nquery/admin/logs") %>
14
+ <% end %>
15
+ </nav>
16
+ </aside>
@@ -0,0 +1,12 @@
1
+ <header class="nq-topbar">
2
+ <div class="nq-search">
3
+ <input type="search" placeholder="Search charts, dashboards..." class="nq-input" />
4
+ </div>
5
+ <div class="nq-topbar-actions">
6
+ <span class="nq-badge"><%= Nquery::DataSource.active.first&.name || "Main DB" %></span>
7
+ <% if current_nquery_user %>
8
+ <span class="nq-user"><%= current_nquery_user.name %></span>
9
+ <%= button_to "Sign out", logout_path, method: :delete, class: "nq-btn nq-btn-ghost" %>
10
+ <% end %>
11
+ </div>
12
+ </header>
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ pin "application", preload: true
4
+ pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
5
+ pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
6
+ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
7
+ pin_all_from Nquery::Engine.root.join("app/javascript/nquery/controllers"), under: "nquery/controllers"
8
+ pin "nquery/chart", to: "nquery/chart.bundle.js"
9
+ pin "nquery/application", to: "nquery/application.js"
data/config/routes.rb ADDED
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ Nquery::Engine.routes.draw do
4
+ root to: "home#index"
5
+
6
+ get "browse", to: redirect("/collections")
7
+
8
+ resources :collections do
9
+ member do
10
+ patch :archive
11
+ patch :unarchive
12
+ end
13
+
14
+ resources :collections, only: %i[new create]
15
+ resources :dashboards, only: %i[new create], module: :collection
16
+ end
17
+
18
+ resources :queries, only: %i[new create edit update show] do
19
+ post :run, on: :collection
20
+ get :schema, on: :collection
21
+ end
22
+
23
+ resources :charts, only: %i[new create show edit update destroy] do
24
+ member do
25
+ get :embed
26
+ patch :archive
27
+ end
28
+ end
29
+
30
+ resources :dashboards, except: %i[new create] do
31
+ resources :charts, module: :dashboard do
32
+ member do
33
+ get :embed
34
+ patch :archive
35
+ end
36
+ end
37
+
38
+ member do
39
+ patch :update_layout
40
+ patch :archive
41
+ patch :unarchive
42
+ end
43
+ end
44
+
45
+ resources :imports, only: %i[new create]
46
+
47
+ namespace :admin do
48
+ resources :users, only: %i[index show new create edit update destroy] do
49
+ member do
50
+ patch :deactivate
51
+ end
52
+ end
53
+ resources :groups, only: %i[index show new create edit update] do
54
+ post :add_member, on: :member
55
+ delete :remove_member, on: :member
56
+ end
57
+ resources :data_sources, only: %i[index new create edit update]
58
+ resources :logs, only: %i[index]
59
+ resources :permissions, only: %i[index] do
60
+ collection do
61
+ get :by_group
62
+ get :by_data_source
63
+ get :by_collection
64
+ end
65
+ end
66
+ end
67
+
68
+ get "login", to: "sessions#new"
69
+ post "login", to: "sessions#create"
70
+ delete "logout", to: "sessions#destroy"
71
+ get "signup", to: "registrations#new"
72
+ post "signup", to: "registrations#create"
73
+
74
+ namespace :embed do
75
+ get "charts/show", to: "charts#show", as: :public_chart
76
+ get "dashboards/show", to: "dashboards#show", as: :public_dashboard
77
+ end
78
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateNqueryTables < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :nquery_users do |t|
6
+ t.string :email, null: false
7
+ t.string :password_digest
8
+ t.string :first_name
9
+ t.string :last_name
10
+ t.string :external_id
11
+ t.datetime :deactivated_at
12
+ t.timestamps
13
+ end
14
+ add_index :nquery_users, :email, unique: true
15
+ add_index :nquery_users, :external_id, unique: true, where: "external_id IS NOT NULL"
16
+
17
+ create_table :nquery_groups do |t|
18
+ t.string :name, null: false
19
+ t.string :system_group, null: false, default: "custom"
20
+ t.text :description
21
+ t.timestamps
22
+ end
23
+ add_index :nquery_groups, :system_group
24
+
25
+ create_table :nquery_group_memberships do |t|
26
+ t.references :user, null: false, foreign_key: { to_table: :nquery_users }
27
+ t.references :group, null: false, foreign_key: { to_table: :nquery_groups }
28
+ t.timestamps
29
+ end
30
+ add_index :nquery_group_memberships, %i[user_id group_id], unique: true
31
+
32
+ create_table :nquery_data_sources do |t|
33
+ t.string :name, null: false
34
+ t.string :adapter, null: false, default: "postgresql"
35
+ t.text :connection_config
36
+ t.boolean :active, null: false, default: true
37
+ t.timestamps
38
+ end
39
+
40
+ create_table :nquery_data_permissions do |t|
41
+ t.references :group, null: false, foreign_key: { to_table: :nquery_groups }
42
+ t.references :data_source, null: false, foreign_key: { to_table: :nquery_data_sources }
43
+ t.string :resource_path
44
+ t.string :permission_type, null: false
45
+ t.string :access_level, null: false
46
+ t.timestamps
47
+ end
48
+ add_index :nquery_data_permissions,
49
+ %i[group_id data_source_id resource_path permission_type],
50
+ unique: true,
51
+ name: "index_nquery_data_permissions_unique"
52
+
53
+ create_table :nquery_collections do |t|
54
+ t.string :name, null: false
55
+ t.string :kind, null: false, default: "standard"
56
+ t.references :parent, foreign_key: { to_table: :nquery_collections }
57
+ t.references :owner, foreign_key: { to_table: :nquery_users }
58
+ t.timestamps
59
+ end
60
+
61
+ create_table :nquery_collection_permissions do |t|
62
+ t.references :group, null: false, foreign_key: { to_table: :nquery_groups }
63
+ t.references :collection, null: false, foreign_key: { to_table: :nquery_collections }
64
+ t.string :access_level, null: false, default: "no_access"
65
+ t.timestamps
66
+ end
67
+ add_index :nquery_collection_permissions, %i[group_id collection_id], unique: true
68
+
69
+ create_table :nquery_application_permissions do |t|
70
+ t.references :group, null: false, foreign_key: { to_table: :nquery_groups }
71
+ t.string :feature, null: false
72
+ t.string :access_level, null: false, default: "no"
73
+ t.timestamps
74
+ end
75
+ add_index :nquery_application_permissions, %i[group_id feature], unique: true
76
+
77
+ create_table :nquery_queries do |t|
78
+ t.string :name
79
+ t.text :statement
80
+ t.references :data_source, foreign_key: { to_table: :nquery_data_sources }
81
+ t.references :creator, foreign_key: { to_table: :nquery_users }
82
+ t.references :collection, foreign_key: { to_table: :nquery_collections }
83
+ t.timestamps
84
+ end
85
+
86
+ create_table :nquery_charts do |t|
87
+ t.string :name, null: false
88
+ t.references :query, foreign_key: { to_table: :nquery_queries }
89
+ t.references :collection, foreign_key: { to_table: :nquery_collections }
90
+ t.references :creator, foreign_key: { to_table: :nquery_users }
91
+ t.json :visualization, null: false, default: {}
92
+ t.timestamps
93
+ end
94
+
95
+ create_table :nquery_dashboards do |t|
96
+ t.string :name, null: false
97
+ t.text :description
98
+ t.references :collection, foreign_key: { to_table: :nquery_collections }
99
+ t.references :creator, foreign_key: { to_table: :nquery_users }
100
+ t.json :settings, null: false, default: {}
101
+ t.timestamps
102
+ end
103
+
104
+ create_table :nquery_dashboard_cards do |t|
105
+ t.references :dashboard, null: false, foreign_key: { to_table: :nquery_dashboards }
106
+ t.references :chart, null: false, foreign_key: { to_table: :nquery_charts }
107
+ t.integer :pos_x, null: false, default: 0
108
+ t.integer :pos_y, null: false, default: 0
109
+ t.integer :width, null: false, default: 4
110
+ t.integer :height, null: false, default: 3
111
+ t.timestamps
112
+ end
113
+
114
+ create_table :nquery_csv_uploads do |t|
115
+ t.string :name
116
+ t.references :creator, foreign_key: { to_table: :nquery_users }
117
+ t.string :status, null: false, default: "pending"
118
+ t.json :column_mapping, null: false, default: {}
119
+ t.timestamps
120
+ end
121
+
122
+ create_table :nquery_embed_tokens do |t|
123
+ t.string :token, null: false
124
+ t.string :resource_type, null: false
125
+ t.bigint :resource_id, null: false
126
+ t.references :creator, foreign_key: { to_table: :nquery_users }
127
+ t.json :params, null: false, default: {}
128
+ t.datetime :expires_at
129
+ t.boolean :active, null: false, default: true
130
+ t.timestamps
131
+ end
132
+ add_index :nquery_embed_tokens, :token, unique: true
133
+ add_index :nquery_embed_tokens, %i[resource_type resource_id]
134
+
135
+ create_table :nquery_audits do |t|
136
+ t.references :user, foreign_key: { to_table: :nquery_users }
137
+ t.references :query, foreign_key: { to_table: :nquery_queries }
138
+ t.text :statement
139
+ t.string :status, null: false, default: "success"
140
+ t.integer :row_count
141
+ t.integer :duration_ms
142
+ t.text :error_message
143
+ t.timestamps
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddArchivedAtToNqueryDashboards < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :nquery_dashboards, :archived_at, :datetime
6
+ add_index :nquery_dashboards, :archived_at
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddArchivedAtToNqueryCharts < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :nquery_charts, :archived_at, :datetime
6
+ add_index :nquery_charts, :archived_at
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddArchivedAtToNqueryCollections < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :nquery_collections, :archived_at, :datetime
6
+ add_index :nquery_collections, :archived_at
7
+ end
8
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/migration"
5
+
6
+ module Nquery
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ desc "Install nquery: copies migration, initializer, and route mount snippet"
13
+
14
+ def copy_migration
15
+ migration_template "install.rb.tt", "db/migrate/install_nquery.rb", migration_version: migration_version
16
+ end
17
+
18
+ def copy_initializer
19
+ template "nquery.rb.tt", "config/initializers/nquery.rb"
20
+ end
21
+
22
+ def show_route_snippet
23
+ say "\nAdd to config/routes.rb:\n", :green
24
+ say <<~ROUTE
25
+ authenticate :user, ->(u) { u.admin? } do
26
+ mount Nquery::Engine, at: "/nquery"
27
+ end
28
+ ROUTE
29
+ end
30
+
31
+ private
32
+
33
+ def migration_version
34
+ "[#{ActiveRecord::Migration.current_version}]"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module AuthorizesCollection
5
+ extend ActiveSupport::Concern
6
+
7
+ COLLECTION_REQUIREMENTS = Permissions::Resolver::COLLECTION_LEVELS
8
+
9
+ private
10
+
11
+ def authorize_collection_access!(collection, required: :view)
12
+ return if permission_resolver.admin?
13
+ return if collection.nil?
14
+
15
+ access = permission_resolver.collection_access(collection)
16
+ required_level = COLLECTION_REQUIREMENTS.fetch(required.to_s)
17
+ actual_level = COLLECTION_REQUIREMENTS.fetch(access.to_s, 0)
18
+ return if actual_level >= required_level
19
+
20
+ deny_collection_access!("You do not have permission to access this collection.")
21
+ end
22
+
23
+ def authorize_data_source_access!(data_source, permission_type: "view_data")
24
+ return if permission_resolver.admin?
25
+ return if data_source.nil?
26
+
27
+ access = permission_resolver.data_access(data_source, permission_type: permission_type)
28
+ blocked = permission_type == "create_queries" ? access == :no : access == :blocked
29
+ return unless blocked
30
+
31
+ message = permission_type == "create_queries" ? "You do not have permission to run SQL queries" : "You do not have permission to view this data"
32
+ deny_collection_access!(message)
33
+ end
34
+
35
+ def viewable_collection?(collection)
36
+ return true if permission_resolver.admin?
37
+ return false if collection.nil?
38
+
39
+ permission_resolver.collection_access(collection) != :no_access
40
+ end
41
+
42
+ def deny_collection_access!(message)
43
+ if request.format.json?
44
+ render json: { error: message }, status: :forbidden
45
+ else
46
+ redirect_to root_path, alert: message
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ class Configuration
5
+ attr_accessor :authentication_mode
6
+ attr_accessor :current_user_method
7
+ attr_accessor :parent_controller
8
+ attr_accessor :data_sources
9
+ attr_accessor :query_timeout
10
+ attr_accessor :query_row_limit
11
+ attr_accessor :embed_secret
12
+
13
+ def initialize
14
+ @authentication_mode = :standalone
15
+ @current_user_method = :current_nquery_user
16
+ @data_sources = { main: :rails }
17
+ @query_timeout = 15
18
+ @query_row_limit = 10_000
19
+ @embed_secret = nil
20
+ @authenticate_with = nil
21
+ @resolve_nquery_user = nil
22
+ end
23
+
24
+ def authenticate_with(&block)
25
+ @authenticate_with = block
26
+ end
27
+
28
+ def authenticate
29
+ @authenticate_with
30
+ end
31
+
32
+ def resolve_nquery_user(&block)
33
+ @resolve_nquery_user = block
34
+ end
35
+
36
+ def resolve_user
37
+ @resolve_nquery_user
38
+ end
39
+
40
+ def embed_signing_key
41
+ embed_secret || Rails.application.secret_key_base
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Nquery
6
+ class CsvImporter
7
+ class Error < StandardError; end
8
+
9
+ def initialize(file:, name:, creator:, column_mapping: {})
10
+ @file = file
11
+ @name = name.presence || "Import #{Time.current.to_fs(:short)}"
12
+ @creator = creator
13
+ @column_mapping = column_mapping
14
+ end
15
+
16
+ def import
17
+ raise Error, "No file provided" unless @file
18
+
19
+ upload = CsvUpload.create!(
20
+ name: @name,
21
+ creator: @creator,
22
+ status: "processing",
23
+ column_mapping: @column_mapping
24
+ )
25
+
26
+ rows = CSV.read(@file.path, headers: true)
27
+ table_name = "nquery_import_#{upload.id}"
28
+
29
+ connection = ActiveRecord::Base.connection
30
+ columns = infer_columns(rows.headers)
31
+ create_import_table(connection, table_name, columns)
32
+ insert_rows(connection, table_name, rows, columns)
33
+
34
+ data_source = DataSource.create!(
35
+ name: @name,
36
+ adapter: connection.adapter_name.downcase.include?("postgres") ? "postgresql" : "rails",
37
+ connection_config: { import_table: table_name }.to_json
38
+ )
39
+
40
+ upload.update!(status: "completed", column_mapping: columns)
41
+ upload
42
+ rescue StandardError => e
43
+ upload&.update!(status: "failed")
44
+ raise Error, e.message
45
+ end
46
+
47
+ private
48
+
49
+ def infer_columns(headers)
50
+ headers.map { |h| { source: h, target: h.parameterize(separator: "_"), type: "text" } }
51
+ end
52
+
53
+ def create_import_table(connection, table_name, columns)
54
+ cols = columns.map { |c| "#{connection.quote_column_name(c[:target])} TEXT" }.join(", ")
55
+ connection.execute("CREATE TABLE IF NOT EXISTS #{connection.quote_table_name(table_name)} (#{cols})")
56
+ end
57
+
58
+ def insert_rows(connection, table_name, rows, columns)
59
+ rows.each do |row|
60
+ values = columns.map { |c| connection.quote(row[c[:source]]) }
61
+ col_names = columns.map { |c| connection.quote_column_name(c[:target]) }.join(", ")
62
+ connection.execute(
63
+ "INSERT INTO #{connection.quote_table_name(table_name)} (#{col_names}) VALUES (#{values.join(', ')})"
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module DataSources
5
+ class Adapter
6
+ def self.for(data_source)
7
+ case data_source.adapter
8
+ when "rails" then RailsAdapter.new(data_source)
9
+ when "postgresql" then PostgresqlAdapter.new(data_source)
10
+ when "mysql" then MysqlAdapter.new(data_source)
11
+ when "sqlite" then SqliteAdapter.new(data_source)
12
+ else
13
+ raise ArgumentError, "Unknown adapter: #{data_source.adapter}"
14
+ end
15
+ end
16
+
17
+ def initialize(data_source)
18
+ @data_source = data_source
19
+ end
20
+
21
+ def tables
22
+ raise NotImplementedError
23
+ end
24
+
25
+ def columns(table_name)
26
+ raise NotImplementedError
27
+ end
28
+
29
+ def execute_readonly(statement, timeout: 15, row_limit: 10_000)
30
+ raise NotImplementedError
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module DataSources
5
+ class MysqlAdapter < PostgresqlAdapter
6
+ def execute_readonly(statement, timeout: 15, row_limit: 10_000)
7
+ with_connection do |conn|
8
+ rows = []
9
+ columns = []
10
+ duration = Benchmark.realtime do
11
+ conn.transaction do
12
+ conn.execute("SET SESSION TRANSACTION READ ONLY")
13
+ result = conn.exec_query("#{statement.strip.sub(/;\s*\z/, '')} LIMIT #{row_limit.to_i}")
14
+ columns = result.columns
15
+ rows = result.rows
16
+ raise ActiveRecord::Rollback
17
+ end
18
+ end
19
+ { columns: columns, rows: rows, row_count: rows.size, duration_ms: (duration * 1000).round }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module DataSources
5
+ class PostgresqlAdapter < Adapter
6
+ def tables
7
+ with_connection { |conn| conn.tables }
8
+ end
9
+
10
+ def columns(table_name)
11
+ with_connection do |conn|
12
+ conn.columns(table_name).map { |c| { name: c.name, type: c.type.to_s } }
13
+ end
14
+ end
15
+
16
+ def execute_readonly(statement, timeout: 15, row_limit: 10_000)
17
+ with_connection do |conn|
18
+ rows = []
19
+ columns = []
20
+ duration = Benchmark.realtime do
21
+ conn.transaction do
22
+ conn.execute("SET TRANSACTION READ ONLY")
23
+ conn.execute("SET statement_timeout = '#{timeout.to_i}s'")
24
+ result = conn.exec_query("#{statement.strip.sub(/;\s*\z/, '')} LIMIT #{row_limit.to_i}")
25
+ columns = result.columns
26
+ rows = result.rows
27
+ raise ActiveRecord::Rollback
28
+ end
29
+ end
30
+ { columns: columns, rows: rows, row_count: rows.size, duration_ms: (duration * 1000).round }
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def with_connection
37
+ config = @data_source.connection_config_hash
38
+ klass = Class.new(ActiveRecord::Base) { self.abstract_class = true }
39
+ klass.establish_connection(config)
40
+ yield klass.connection
41
+ ensure
42
+ klass.remove_connection if defined?(klass) && klass.connected?
43
+ end
44
+ end
45
+ end
46
+ end