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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +52 -0
- data/Rakefile +9 -0
- data/app/assets/builds/nquery/application.css +339 -0
- data/app/assets/builds/nquery/application.js +989 -0
- data/app/assets/config/nquery_manifest.js +2 -0
- data/app/components/nquery/_card_component.html.erb +6 -0
- data/app/components/nquery/_card_table_component.html.erb +14 -0
- data/app/components/nquery/_flash_card_component.html.erb +32 -0
- data/app/components/nquery/_flash_cards_component.html.erb +3 -0
- data/app/components/nquery/_infinite_scroll_component.html.erb +11 -0
- data/app/components/nquery/card_component.rb +18 -0
- data/app/components/nquery/card_table_component.rb +19 -0
- data/app/components/nquery/component.rb +26 -0
- data/app/components/nquery/flash_card_component.rb +51 -0
- data/app/components/nquery/flash_cards_component.rb +20 -0
- data/app/components/nquery/infinite_scroll_component.rb +19 -0
- data/app/controllers/concerns/nquery/breadcrumbs.rb +238 -0
- data/app/controllers/concerns/nquery/browsable.rb +56 -0
- data/app/controllers/concerns/nquery/chart_actions.rb +104 -0
- data/app/controllers/concerns/nquery/chart_results.rb +30 -0
- data/app/controllers/nquery/admin/base_controller.rb +9 -0
- data/app/controllers/nquery/admin/data_sources_controller.rb +47 -0
- data/app/controllers/nquery/admin/groups_controller.rb +63 -0
- data/app/controllers/nquery/admin/logs_controller.rb +28 -0
- data/app/controllers/nquery/admin/permissions_controller.rb +53 -0
- data/app/controllers/nquery/admin/users_controller.rb +80 -0
- data/app/controllers/nquery/application_controller.rb +70 -0
- data/app/controllers/nquery/charts_controller.rb +51 -0
- data/app/controllers/nquery/collection/dashboards_controller.rb +38 -0
- data/app/controllers/nquery/collections_controller.rb +117 -0
- data/app/controllers/nquery/dashboard/charts_controller.rb +62 -0
- data/app/controllers/nquery/dashboards_controller.rb +90 -0
- data/app/controllers/nquery/embed/charts_controller.rb +24 -0
- data/app/controllers/nquery/embed/dashboards_controller.rb +17 -0
- data/app/controllers/nquery/home_controller.rb +16 -0
- data/app/controllers/nquery/imports_controller.rb +41 -0
- data/app/controllers/nquery/queries_controller.rb +105 -0
- data/app/controllers/nquery/registrations_controller.rb +31 -0
- data/app/controllers/nquery/sessions_controller.rb +28 -0
- data/app/helpers/nquery/charts_helper.rb +33 -0
- data/app/helpers/nquery/collections_helper.rb +13 -0
- data/app/helpers/nquery/components_helper.rb +29 -0
- data/app/helpers/nquery/home_helper.rb +23 -0
- data/app/helpers/nquery/icon_helper.rb +9 -0
- data/app/helpers/nquery/navigation_helper.rb +10 -0
- data/app/javascript/nquery/controllers/chart_preview_controller.js +125 -0
- data/app/javascript/nquery/controllers/csv_mapper_controller.js +23 -0
- data/app/javascript/nquery/controllers/dashboard_grid_controller.js +25 -0
- data/app/javascript/nquery/controllers/query_editor_controller.js +41 -0
- data/app/models/nquery/application_permission.rb +12 -0
- data/app/models/nquery/application_record.rb +7 -0
- data/app/models/nquery/audit.rb +38 -0
- data/app/models/nquery/chart.rb +37 -0
- data/app/models/nquery/collection.rb +35 -0
- data/app/models/nquery/collection_permission.rb +12 -0
- data/app/models/nquery/csv_upload.rb +11 -0
- data/app/models/nquery/dashboard.rb +27 -0
- data/app/models/nquery/dashboard_card.rb +8 -0
- data/app/models/nquery/data_permission.rb +13 -0
- data/app/models/nquery/data_source.rb +27 -0
- data/app/models/nquery/embed_token.rb +21 -0
- data/app/models/nquery/group.rb +27 -0
- data/app/models/nquery/group_membership.rb +10 -0
- data/app/models/nquery/query.rb +19 -0
- data/app/models/nquery/user.rb +58 -0
- data/app/views/layouts/nquery/application.html.erb +32 -0
- data/app/views/layouts/nquery/auth.html.erb +16 -0
- data/app/views/layouts/nquery/embed.html.erb +12 -0
- data/app/views/nquery/admin/data_sources/edit.html.erb +8 -0
- data/app/views/nquery/admin/data_sources/index.html.erb +21 -0
- data/app/views/nquery/admin/data_sources/new.html.erb +7 -0
- data/app/views/nquery/admin/groups/edit.html.erb +6 -0
- data/app/views/nquery/admin/groups/index.html.erb +21 -0
- data/app/views/nquery/admin/groups/new.html.erb +6 -0
- data/app/views/nquery/admin/groups/show.html.erb +24 -0
- data/app/views/nquery/admin/logs/index.html.erb +74 -0
- data/app/views/nquery/admin/permissions/_collection.html.erb +25 -0
- data/app/views/nquery/admin/permissions/_data_source.html.erb +27 -0
- data/app/views/nquery/admin/permissions/_group.html.erb +42 -0
- data/app/views/nquery/admin/permissions/by_collection.html.erb +10 -0
- data/app/views/nquery/admin/permissions/by_data_source.html.erb +7 -0
- data/app/views/nquery/admin/permissions/by_group.html.erb +12 -0
- data/app/views/nquery/admin/users/edit.html.erb +17 -0
- data/app/views/nquery/admin/users/index.html.erb +34 -0
- data/app/views/nquery/admin/users/new.html.erb +14 -0
- data/app/views/nquery/admin/users/show.html.erb +6 -0
- data/app/views/nquery/charts/_builder_form.html.erb +185 -0
- data/app/views/nquery/charts/_new_header.html.erb +31 -0
- data/app/views/nquery/charts/_schema_tree.html.erb +25 -0
- data/app/views/nquery/charts/edit.html.erb +6 -0
- data/app/views/nquery/charts/embed.html.erb +15 -0
- data/app/views/nquery/charts/new.html.erb +5 -0
- data/app/views/nquery/charts/show.html.erb +15 -0
- data/app/views/nquery/charts/update.turbo_stream.erb +1 -0
- data/app/views/nquery/collection/dashboards/new.html.erb +13 -0
- data/app/views/nquery/collections/_dashboard_card.html.erb +57 -0
- data/app/views/nquery/collections/edit.html.erb +15 -0
- data/app/views/nquery/collections/index.html.erb +41 -0
- data/app/views/nquery/collections/new.html.erb +15 -0
- data/app/views/nquery/collections/show.html.erb +100 -0
- data/app/views/nquery/dashboard/charts/new.html.erb +6 -0
- data/app/views/nquery/dashboards/_chart_card.html.erb +16 -0
- data/app/views/nquery/dashboards/edit.html.erb +34 -0
- data/app/views/nquery/dashboards/index.html.erb +35 -0
- data/app/views/nquery/dashboards/show.html.erb +46 -0
- data/app/views/nquery/embed/charts/show.html.erb +9 -0
- data/app/views/nquery/embed/dashboards/show.html.erb +7 -0
- data/app/views/nquery/home/index.html.erb +143 -0
- data/app/views/nquery/imports/new.html.erb +20 -0
- data/app/views/nquery/queries/edit.html.erb +40 -0
- data/app/views/nquery/queries/new.html.erb +37 -0
- data/app/views/nquery/registrations/new.html.erb +34 -0
- data/app/views/nquery/sessions/new.html.erb +29 -0
- data/app/views/nquery/shared/_auth_shell.html.erb +22 -0
- data/app/views/nquery/shared/_breadcrumbs.html.erb +15 -0
- data/app/views/nquery/shared/_chart_actions_menu.html.erb +27 -0
- data/app/views/nquery/shared/_chart_row_actions.html.erb +16 -0
- data/app/views/nquery/shared/_flash.html.erb +1 -0
- data/app/views/nquery/shared/_icon.html.erb +50 -0
- data/app/views/nquery/shared/_sidebar.html.erb +16 -0
- data/app/views/nquery/shared/_topbar.html.erb +12 -0
- data/config/importmap.rb +9 -0
- data/config/routes.rb +78 -0
- data/db/migrate/20260721000001_create_nquery_tables.rb +146 -0
- data/db/migrate/20260721180000_add_archived_at_to_nquery_dashboards.rb +8 -0
- data/db/migrate/20260721190000_add_archived_at_to_nquery_charts.rb +8 -0
- data/db/migrate/20260721200000_add_archived_at_to_nquery_collections.rb +8 -0
- data/lib/generators/nquery/install_generator.rb +38 -0
- data/lib/nquery/authorizes_collection.rb +50 -0
- data/lib/nquery/configuration.rb +44 -0
- data/lib/nquery/csv_importer.rb +68 -0
- data/lib/nquery/data_sources/adapter.rb +34 -0
- data/lib/nquery/data_sources/mysql_adapter.rb +24 -0
- data/lib/nquery/data_sources/postgresql_adapter.rb +46 -0
- data/lib/nquery/data_sources/rails_adapter.rb +54 -0
- data/lib/nquery/data_sources/sqlite_adapter.rb +8 -0
- data/lib/nquery/embed_token_service.rb +83 -0
- data/lib/nquery/engine.rb +55 -0
- data/lib/nquery/permissions/resolver.rb +83 -0
- data/lib/nquery/query_runner.rb +69 -0
- data/lib/nquery/sample_data/ecommerce.rb +157 -0
- data/lib/nquery/schema_explorer.rb +28 -0
- data/lib/nquery/seeder.rb +131 -0
- data/lib/nquery/version.rb +5 -0
- data/lib/nquery.rb +36 -0
- data/lib/tasks/nquery.rake +9 -0
- data/nquery.gemspec +40 -0
- metadata +395 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "benchmark"
|
|
4
|
+
|
|
5
|
+
module Nquery
|
|
6
|
+
module DataSources
|
|
7
|
+
class RailsAdapter < Adapter
|
|
8
|
+
def tables
|
|
9
|
+
connection.tables.reject { |t| hidden_table?(t) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def columns(table_name)
|
|
13
|
+
connection.columns(table_name).map { |c| { name: c.name, type: c.type.to_s } }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def execute_readonly(statement, timeout: 15, row_limit: 10_000)
|
|
17
|
+
rows = []
|
|
18
|
+
columns = []
|
|
19
|
+
duration = Benchmark.realtime do
|
|
20
|
+
connection.transaction do
|
|
21
|
+
connection.execute("SET TRANSACTION READ ONLY") if postgresql?
|
|
22
|
+
result = connection.exec_query(sanitize_limit(statement, row_limit))
|
|
23
|
+
columns = result.columns
|
|
24
|
+
rows = result.rows
|
|
25
|
+
raise ActiveRecord::Rollback
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
{ columns: columns, rows: rows, row_count: rows.size, duration_ms: (duration * 1000).round }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def hidden_table?(table_name)
|
|
34
|
+
return true if table_name.start_with?("ar_") || table_name == "schema_migrations"
|
|
35
|
+
return false if table_name.start_with?("nquery_sample_")
|
|
36
|
+
|
|
37
|
+
table_name.start_with?("nquery_")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def connection
|
|
41
|
+
ActiveRecord::Base.connection
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def postgresql?
|
|
45
|
+
connection.adapter_name.downcase.include?("postgres")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def sanitize_limit(statement, limit)
|
|
49
|
+
stripped = statement.strip.sub(/;\s*\z/, "")
|
|
50
|
+
"#{stripped} LIMIT #{limit.to_i}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Nquery
|
|
7
|
+
class EmbedTokenService
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
def self.sign(resource_type:, resource_id:, params: {}, expires_at: 1.hour.from_now, creator: nil)
|
|
11
|
+
token = generate_token
|
|
12
|
+
payload = {
|
|
13
|
+
token: token,
|
|
14
|
+
resource_type: resource_type,
|
|
15
|
+
resource_id: resource_id,
|
|
16
|
+
params: params,
|
|
17
|
+
exp: expires_at.to_i
|
|
18
|
+
}
|
|
19
|
+
data = Base64.urlsafe_encode64(payload.to_json)
|
|
20
|
+
|
|
21
|
+
signature = OpenSSL::HMAC.hexdigest("SHA256", signing_key, data)
|
|
22
|
+
signed = "#{data}.#{signature}"
|
|
23
|
+
|
|
24
|
+
EmbedToken.create!(
|
|
25
|
+
token: token,
|
|
26
|
+
resource_type: resource_type,
|
|
27
|
+
resource_id: resource_id,
|
|
28
|
+
creator: creator,
|
|
29
|
+
params: params,
|
|
30
|
+
expires_at: expires_at,
|
|
31
|
+
active: true
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
{ token: token, signed_token: signed, expires_at: expires_at }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.verify(signed_token)
|
|
38
|
+
data, signature = signed_token.to_s.split(".", 2)
|
|
39
|
+
raise Error, "Invalid token format" unless data.present? && signature.present?
|
|
40
|
+
|
|
41
|
+
expected_signature = OpenSSL::HMAC.hexdigest("SHA256", signing_key, data)
|
|
42
|
+
unless ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature)
|
|
43
|
+
raise Error, "Invalid token signature"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
payload = JSON.parse(Base64.urlsafe_decode64(data))
|
|
47
|
+
raise Error, "Token expired" if payload["exp"].to_i <= Time.current.to_i
|
|
48
|
+
|
|
49
|
+
record = EmbedToken.active.find_by(token: payload["token"])
|
|
50
|
+
raise Error, "Token not found" unless record
|
|
51
|
+
raise Error, "Token expired" if record.expired?
|
|
52
|
+
raise Error, "Token mismatch" unless record.resource_type == payload["resource_type"] &&
|
|
53
|
+
record.resource_id == payload["resource_id"].to_i
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
resource_type: record.resource_type,
|
|
57
|
+
resource_id: record.resource_id,
|
|
58
|
+
params: record.params
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.signed_token_for(record)
|
|
63
|
+
payload = {
|
|
64
|
+
token: record.token,
|
|
65
|
+
resource_type: record.resource_type,
|
|
66
|
+
resource_id: record.resource_id,
|
|
67
|
+
params: record.params || {},
|
|
68
|
+
exp: record.expires_at.to_i
|
|
69
|
+
}
|
|
70
|
+
data = Base64.urlsafe_encode64(payload.to_json)
|
|
71
|
+
signature = OpenSSL::HMAC.hexdigest("SHA256", signing_key, data)
|
|
72
|
+
"#{data}.#{signature}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.generate_token
|
|
76
|
+
SecureRandom.urlsafe_base64(24)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.signing_key
|
|
80
|
+
Nquery.configuration.embed_signing_key
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace Nquery
|
|
6
|
+
|
|
7
|
+
config.autoload_paths << root.join("app/components")
|
|
8
|
+
config.eager_load_paths << root.join("app/components")
|
|
9
|
+
|
|
10
|
+
config.generators do |g|
|
|
11
|
+
g.test_framework :rspec
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
initializer "nquery.assets" do |app|
|
|
15
|
+
next unless app.config.respond_to?(:assets)
|
|
16
|
+
|
|
17
|
+
app.config.assets.paths << root.join("app/assets/builds").to_s
|
|
18
|
+
app.config.assets.paths << root.join("app/assets/images").to_s
|
|
19
|
+
|
|
20
|
+
if app.config.respond_to?(:assets) && defined?(::Sprockets)
|
|
21
|
+
app.config.assets.precompile += %w[nquery_manifest.js]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
initializer "nquery.importmap", before: "importmap" do |app|
|
|
26
|
+
if app.respond_to?(:importmap)
|
|
27
|
+
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
28
|
+
app.config.importmap.cache_sweepers << root.join("app/assets/builds/nquery")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
initializer "nquery.migrations" do |app|
|
|
33
|
+
next if app.root.to_s.start_with?(root.to_s)
|
|
34
|
+
|
|
35
|
+
migration_path = root.join("db/migrate").to_s
|
|
36
|
+
app.config.paths["db/migrate"] << migration_path unless app.config.paths["db/migrate"].include?(migration_path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
initializer "nquery.config" do
|
|
40
|
+
Nquery.configure do |config|
|
|
41
|
+
config.authentication_mode = ENV.fetch("NQUERY_AUTHENTICATION_MODE", "standalone").to_sym
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
initializer "nquery.components" do
|
|
46
|
+
ActiveSupport.on_load(:action_controller) do
|
|
47
|
+
append_view_path Nquery::Engine.root.join("app/components")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
rake_tasks do
|
|
52
|
+
load root.join("lib/tasks/nquery.rake")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
module Permissions
|
|
5
|
+
class Resolver
|
|
6
|
+
COLLECTION_LEVELS = { "no_access" => 0, "view" => 1, "curate" => 2 }.freeze
|
|
7
|
+
DATA_VIEW_LEVELS = { "blocked" => 0, "can_view" => 1 }.freeze
|
|
8
|
+
CREATE_QUERY_LEVELS = { "no" => 0, "query_builder" => 1, "native" => 2, "query_builder_and_native" => 3 }.freeze
|
|
9
|
+
APP_LEVELS = { "no" => 0, "yes" => 1 }.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(user)
|
|
12
|
+
@user = user
|
|
13
|
+
@groups = user&.groups&.to_a || []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def admin?
|
|
17
|
+
@groups.any? { |g| g.system_group == "administrators" }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def collection_access(collection)
|
|
21
|
+
return :curate if admin?
|
|
22
|
+
|
|
23
|
+
levels = @groups.filter_map do |group|
|
|
24
|
+
perm = Nquery::CollectionPermission.find_by(group: group, collection: collection)
|
|
25
|
+
perm&.access_level
|
|
26
|
+
end
|
|
27
|
+
max_level(levels, COLLECTION_LEVELS)&.to_sym || :no_access
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def data_access(data_source, resource_path: nil, permission_type: "view_data")
|
|
31
|
+
return highest_data_level(permission_type) if admin?
|
|
32
|
+
|
|
33
|
+
levels = @groups.flat_map do |group|
|
|
34
|
+
Nquery::DataPermission.where(group: group, data_source: data_source, permission_type: permission_type)
|
|
35
|
+
.where("resource_path IS NULL OR resource_path = ?", resource_path)
|
|
36
|
+
.pluck(:access_level)
|
|
37
|
+
end
|
|
38
|
+
max_data_level(levels, permission_type)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def application_access(feature)
|
|
42
|
+
return :yes if admin?
|
|
43
|
+
|
|
44
|
+
levels = @groups.filter_map do |group|
|
|
45
|
+
Nquery::ApplicationPermission.find_by(group: group, feature: feature)&.access_level
|
|
46
|
+
end
|
|
47
|
+
(max_level(levels, APP_LEVELS) || "no").to_sym
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def all_users_more_permissive?(group, collection: nil, data_source: nil)
|
|
51
|
+
all_users = Nquery::Group.find_by(system_group: "all_users")
|
|
52
|
+
return false unless all_users
|
|
53
|
+
|
|
54
|
+
if collection
|
|
55
|
+
all_users_level = Nquery::CollectionPermission.find_by(group: all_users, collection: collection)&.access_level
|
|
56
|
+
group_level = Nquery::CollectionPermission.find_by(group: group, collection: collection)&.access_level
|
|
57
|
+
return COLLECTION_LEVELS[all_users_level].to_i > COLLECTION_LEVELS[group_level].to_i
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def max_level(levels, map)
|
|
66
|
+
levels.max_by { |l| map[l] || -1 }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def max_data_level(levels, permission_type)
|
|
70
|
+
map = permission_type == "create_queries" ? CREATE_QUERY_LEVELS : DATA_VIEW_LEVELS
|
|
71
|
+
(max_level(levels, map) || default_data_level(permission_type)).to_sym
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def highest_data_level(permission_type)
|
|
75
|
+
permission_type == "create_queries" ? :query_builder_and_native : :can_view
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def default_data_level(permission_type)
|
|
79
|
+
permission_type == "create_queries" ? "no" : "blocked"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
class QueryRunner
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
class PermissionError < Error; end
|
|
7
|
+
|
|
8
|
+
FORBIDDEN_KEYWORDS = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|INTO)\b/i
|
|
9
|
+
|
|
10
|
+
def initialize(data_source:, statement:, user: nil, query: nil)
|
|
11
|
+
@data_source = data_source
|
|
12
|
+
@statement = statement.to_s.strip
|
|
13
|
+
@user = user
|
|
14
|
+
@query = query
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run
|
|
18
|
+
validate_statement!
|
|
19
|
+
check_permissions!
|
|
20
|
+
|
|
21
|
+
adapter = DataSources::Adapter.for(@data_source)
|
|
22
|
+
result = adapter.execute_readonly(
|
|
23
|
+
@statement,
|
|
24
|
+
timeout: Nquery.configuration.query_timeout,
|
|
25
|
+
row_limit: Nquery.configuration.query_row_limit
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
record_audit!(result, "success")
|
|
29
|
+
result
|
|
30
|
+
rescue PermissionError
|
|
31
|
+
raise
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
record_audit!({}, "error", e.message)
|
|
34
|
+
raise Error, e.message
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def validate_statement!
|
|
40
|
+
raise Error, "Query cannot be blank" if @statement.blank?
|
|
41
|
+
raise Error, "Multi-statement queries are not allowed" if @statement.include?(";")
|
|
42
|
+
raise Error, "Query must start with SELECT or WITH" unless @statement.match?(/\A\s*(SELECT|WITH)\b/i)
|
|
43
|
+
raise Error, "Only SELECT queries are allowed" if @statement.match?(FORBIDDEN_KEYWORDS)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def check_permissions!
|
|
47
|
+
return unless @user
|
|
48
|
+
|
|
49
|
+
resolver = Permissions::Resolver.new(@user)
|
|
50
|
+
access = resolver.data_access(@data_source, permission_type: "view_data")
|
|
51
|
+
raise PermissionError, "You do not have permission to view this data" if access == :blocked
|
|
52
|
+
|
|
53
|
+
create_access = resolver.data_access(@data_source, permission_type: "create_queries")
|
|
54
|
+
raise PermissionError, "You do not have permission to run SQL queries" if create_access == :no
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def record_audit!(result, status, error_message = nil)
|
|
58
|
+
Audit.create!(
|
|
59
|
+
user: @user,
|
|
60
|
+
query: @query,
|
|
61
|
+
statement: @statement,
|
|
62
|
+
status: status,
|
|
63
|
+
row_count: result[:row_count],
|
|
64
|
+
duration_ms: result[:duration_ms],
|
|
65
|
+
error_message: error_message
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
module SampleData
|
|
5
|
+
class Ecommerce
|
|
6
|
+
TABLES = {
|
|
7
|
+
categories: "nquery_sample_categories",
|
|
8
|
+
products: "nquery_sample_products",
|
|
9
|
+
customers: "nquery_sample_customers",
|
|
10
|
+
orders: "nquery_sample_orders",
|
|
11
|
+
order_items: "nquery_sample_order_items"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
REQUIRED_TABLES = TABLES.values.freeze
|
|
15
|
+
|
|
16
|
+
def self.run!
|
|
17
|
+
new.run!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run!
|
|
21
|
+
connection = ActiveRecord::Base.connection
|
|
22
|
+
create_tables!(connection)
|
|
23
|
+
return if seeded?(connection)
|
|
24
|
+
|
|
25
|
+
seed!(connection)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def create_tables!(connection)
|
|
31
|
+
create_table_if_missing(connection, TABLES[:categories]) do |t|
|
|
32
|
+
t.string :name, null: false
|
|
33
|
+
t.timestamps
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
create_table_if_missing(connection, TABLES[:products]) do |t|
|
|
37
|
+
t.references :category, null: false, foreign_key: { to_table: TABLES[:categories] }
|
|
38
|
+
t.string :name, null: false
|
|
39
|
+
t.decimal :price, precision: 10, scale: 2, null: false
|
|
40
|
+
t.timestamps
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
create_table_if_missing(connection, TABLES[:customers]) do |t|
|
|
44
|
+
t.string :email, null: false
|
|
45
|
+
t.string :first_name, null: false
|
|
46
|
+
t.string :last_name, null: false
|
|
47
|
+
t.timestamps
|
|
48
|
+
end
|
|
49
|
+
connection.add_index TABLES[:customers], :email, unique: true unless connection.index_exists?(TABLES[:customers], :email)
|
|
50
|
+
|
|
51
|
+
create_table_if_missing(connection, TABLES[:orders]) do |t|
|
|
52
|
+
t.references :customer, null: false, foreign_key: { to_table: TABLES[:customers] }
|
|
53
|
+
t.string :status, null: false, default: "completed"
|
|
54
|
+
t.datetime :ordered_at, null: false
|
|
55
|
+
t.timestamps
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
create_table_if_missing(connection, TABLES[:order_items]) do |t|
|
|
59
|
+
t.references :order, null: false, foreign_key: { to_table: TABLES[:orders] }
|
|
60
|
+
t.references :product, null: false, foreign_key: { to_table: TABLES[:products] }
|
|
61
|
+
t.integer :quantity, null: false, default: 1
|
|
62
|
+
t.decimal :unit_price, precision: 10, scale: 2, null: false
|
|
63
|
+
t.timestamps
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def create_table_if_missing(connection, table_name, &block)
|
|
68
|
+
return if connection.table_exists?(table_name)
|
|
69
|
+
|
|
70
|
+
connection.create_table(table_name, &block)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def seeded?(connection)
|
|
74
|
+
connection.select_value(
|
|
75
|
+
"SELECT COUNT(*) FROM #{connection.quote_table_name(TABLES[:orders])}"
|
|
76
|
+
).to_i.positive?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def seed!(connection)
|
|
80
|
+
now = Time.current
|
|
81
|
+
categories = {
|
|
82
|
+
electronics: insert_row(connection, TABLES[:categories], name: "Electronics", created_at: now, updated_at: now),
|
|
83
|
+
apparel: insert_row(connection, TABLES[:categories], name: "Apparel", created_at: now, updated_at: now),
|
|
84
|
+
home: insert_row(connection, TABLES[:categories], name: "Home", created_at: now, updated_at: now)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
products = {
|
|
88
|
+
headphones: insert_row(connection, TABLES[:products], category_id: categories[:electronics], name: "Wireless Headphones", price: 79.99, created_at: now, updated_at: now),
|
|
89
|
+
keyboard: insert_row(connection, TABLES[:products], category_id: categories[:electronics], name: "Mechanical Keyboard", price: 129.99, created_at: now, updated_at: now),
|
|
90
|
+
mouse: insert_row(connection, TABLES[:products], category_id: categories[:electronics], name: "Ergonomic Mouse", price: 49.99, created_at: now, updated_at: now),
|
|
91
|
+
hoodie: insert_row(connection, TABLES[:products], category_id: categories[:apparel], name: "Cotton Hoodie", price: 59.99, created_at: now, updated_at: now),
|
|
92
|
+
tshirt: insert_row(connection, TABLES[:products], category_id: categories[:apparel], name: "Logo T-Shirt", price: 24.99, created_at: now, updated_at: now),
|
|
93
|
+
mug: insert_row(connection, TABLES[:products], category_id: categories[:home], name: "Ceramic Mug", price: 14.99, created_at: now, updated_at: now),
|
|
94
|
+
lamp: insert_row(connection, TABLES[:products], category_id: categories[:home], name: "Desk Lamp", price: 39.99, created_at: now, updated_at: now)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
customers = [
|
|
98
|
+
insert_row(connection, TABLES[:customers], email: "alice@example.com", first_name: "Alice", last_name: "Nguyen", created_at: now, updated_at: now),
|
|
99
|
+
insert_row(connection, TABLES[:customers], email: "bob@example.com", first_name: "Bob", last_name: "Martinez", created_at: now, updated_at: now),
|
|
100
|
+
insert_row(connection, TABLES[:customers], email: "carol@example.com", first_name: "Carol", last_name: "Patel", created_at: now, updated_at: now),
|
|
101
|
+
insert_row(connection, TABLES[:customers], email: "dan@example.com", first_name: "Dan", last_name: "Kim", created_at: now, updated_at: now),
|
|
102
|
+
insert_row(connection, TABLES[:customers], email: "eva@example.com", first_name: "Eva", last_name: "Silva", created_at: now, updated_at: now)
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
order_specs = [
|
|
106
|
+
{ customer: customers[0], ordered_at: Time.zone.parse("2026-01-05 10:00"), items: [[:headphones, 1], [:mug, 2]] },
|
|
107
|
+
{ customer: customers[1], ordered_at: Time.zone.parse("2026-01-12 14:30"), items: [[:keyboard, 1], [:mouse, 1]] },
|
|
108
|
+
{ customer: customers[2], ordered_at: Time.zone.parse("2026-01-20 09:15"), items: [[:hoodie, 2], [:tshirt, 1]] },
|
|
109
|
+
{ customer: customers[3], ordered_at: Time.zone.parse("2026-02-03 11:00"), items: [[:lamp, 1], [:mug, 1]] },
|
|
110
|
+
{ customer: customers[4], ordered_at: Time.zone.parse("2026-02-14 16:45"), items: [[:headphones, 1], [:tshirt, 2]] },
|
|
111
|
+
{ customer: customers[0], ordered_at: Time.zone.parse("2026-02-22 08:20"), items: [[:mouse, 2]] },
|
|
112
|
+
{ customer: customers[1], ordered_at: Time.zone.parse("2026-03-01 13:10"), items: [[:keyboard, 1], [:hoodie, 1]] },
|
|
113
|
+
{ customer: customers[2], ordered_at: Time.zone.parse("2026-03-08 17:30"), items: [[:lamp, 2], [:mug, 3]] },
|
|
114
|
+
{ customer: customers[3], ordered_at: Time.zone.parse("2026-03-15 10:50"), items: [[:headphones, 2], [:keyboard, 1]] },
|
|
115
|
+
{ customer: customers[4], ordered_at: Time.zone.parse("2026-03-21 19:00"), items: [[:tshirt, 3], [:mouse, 1]] }
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
order_specs.each do |spec|
|
|
119
|
+
order_id = insert_row(
|
|
120
|
+
connection,
|
|
121
|
+
TABLES[:orders],
|
|
122
|
+
customer_id: spec[:customer],
|
|
123
|
+
status: "completed",
|
|
124
|
+
ordered_at: spec[:ordered_at],
|
|
125
|
+
created_at: now,
|
|
126
|
+
updated_at: now
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
spec[:items].each do |product_key, quantity|
|
|
130
|
+
product_id = products[product_key]
|
|
131
|
+
unit_price = connection.select_value(
|
|
132
|
+
"SELECT price FROM #{connection.quote_table_name(TABLES[:products])} WHERE id = #{connection.quote(product_id)}"
|
|
133
|
+
)
|
|
134
|
+
insert_row(
|
|
135
|
+
connection,
|
|
136
|
+
TABLES[:order_items],
|
|
137
|
+
order_id: order_id,
|
|
138
|
+
product_id: product_id,
|
|
139
|
+
quantity: quantity,
|
|
140
|
+
unit_price: unit_price,
|
|
141
|
+
created_at: now,
|
|
142
|
+
updated_at: now
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def insert_row(connection, table, attrs)
|
|
149
|
+
columns = attrs.keys
|
|
150
|
+
values = attrs.values.map { |value| connection.quote(value) }
|
|
151
|
+
connection.insert(
|
|
152
|
+
"INSERT INTO #{connection.quote_table_name(table)} (#{columns.map { |c| connection.quote_column_name(c) }.join(', ')}) VALUES (#{values.join(', ')})"
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
class SchemaExplorer
|
|
5
|
+
def self.tables_for(data_source)
|
|
6
|
+
new(data_source).tables
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(data_source)
|
|
10
|
+
@data_source = data_source
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def tables
|
|
14
|
+
return [] unless @data_source
|
|
15
|
+
|
|
16
|
+
adapter = DataSources::Adapter.for(@data_source)
|
|
17
|
+
adapter.tables.map do |table|
|
|
18
|
+
{
|
|
19
|
+
name: table,
|
|
20
|
+
columns: adapter.columns(table)
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
Rails.logger.error("[Nquery::SchemaExplorer] #{e.class}: #{e.message}\n#{e.backtrace&.first(8)&.join("\n")}")
|
|
25
|
+
[]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
class Seeder
|
|
5
|
+
def self.run!
|
|
6
|
+
new.run!
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def run!
|
|
10
|
+
seed_sample_data!
|
|
11
|
+
|
|
12
|
+
admin_group = ensure_group!("Administrators", "administrators")
|
|
13
|
+
all_users_group = ensure_group!("All Users", "all_users")
|
|
14
|
+
engineering = ensure_group!("Engineering", "custom", "Engineering team")
|
|
15
|
+
|
|
16
|
+
admin = ensure_user!("admin@nquery.dev", "Admin", "User", "password123")
|
|
17
|
+
analyst = ensure_user!("analyst@nquery.dev", "Data", "Analyst", "password123")
|
|
18
|
+
|
|
19
|
+
[admin, analyst].each(&:ensure_all_users_membership!)
|
|
20
|
+
admin_group.group_memberships.find_or_create_by!(user: admin)
|
|
21
|
+
engineering.group_memberships.find_or_create_by!(user: analyst)
|
|
22
|
+
|
|
23
|
+
data_source = DataSource.find_or_create_by!(name: "Main Database") do |ds|
|
|
24
|
+
ds.adapter = "rails"
|
|
25
|
+
ds.connection_config = {}.to_json
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
root_collection = Collection.find_or_create_by!(name: "Our analytics", kind: "root")
|
|
29
|
+
admin.ensure_personal_collection!
|
|
30
|
+
|
|
31
|
+
seed_permissions!(admin_group, all_users_group, engineering, root_collection, data_source)
|
|
32
|
+
|
|
33
|
+
sample_tables = SampleData::Ecommerce::TABLES
|
|
34
|
+
query = Query.find_or_initialize_by(name: "Monthly revenue")
|
|
35
|
+
query.assign_attributes(
|
|
36
|
+
statement: <<~SQL.squish,
|
|
37
|
+
SELECT strftime('%Y-%m', #{sample_tables[:orders]}.ordered_at) AS month,
|
|
38
|
+
ROUND(SUM(#{sample_tables[:order_items]}.quantity * #{sample_tables[:order_items]}.unit_price), 2) AS revenue
|
|
39
|
+
FROM #{sample_tables[:orders]}
|
|
40
|
+
INNER JOIN #{sample_tables[:order_items]} ON #{sample_tables[:order_items]}.order_id = #{sample_tables[:orders]}.id
|
|
41
|
+
GROUP BY strftime('%Y-%m', #{sample_tables[:orders]}.ordered_at)
|
|
42
|
+
ORDER BY month
|
|
43
|
+
SQL
|
|
44
|
+
data_source: data_source,
|
|
45
|
+
creator: admin,
|
|
46
|
+
collection: root_collection
|
|
47
|
+
)
|
|
48
|
+
query.save!
|
|
49
|
+
|
|
50
|
+
chart = Chart.find_or_create_by!(name: "Revenue by month") do |c|
|
|
51
|
+
c.query = query
|
|
52
|
+
c.collection = root_collection
|
|
53
|
+
c.creator = admin
|
|
54
|
+
c.visualization = { "type" => "bar", "x" => "month", "y" => "revenue" }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
dashboard = Dashboard.find_or_create_by!(name: "Executive overview") do |d|
|
|
58
|
+
d.description = "Key metrics at a glance"
|
|
59
|
+
d.collection = root_collection
|
|
60
|
+
d.creator = admin
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
DashboardCard.find_or_create_by!(dashboard: dashboard, chart: chart) do |card|
|
|
64
|
+
card.pos_x = 0
|
|
65
|
+
card.pos_y = 0
|
|
66
|
+
card.width = 6
|
|
67
|
+
card.height = 4
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
EmbedTokenService.sign(
|
|
71
|
+
resource_type: "Nquery::Chart",
|
|
72
|
+
resource_id: chart.id,
|
|
73
|
+
creator: admin,
|
|
74
|
+
expires_at: 1.year.from_now
|
|
75
|
+
) unless EmbedToken.exists?(resource_type: "Nquery::Chart", resource_id: chart.id)
|
|
76
|
+
|
|
77
|
+
ApplicationPermission::FEATURES.each do |feature|
|
|
78
|
+
ApplicationPermission.find_or_create_by!(group: admin_group, feature: feature) do |p|
|
|
79
|
+
p.access_level = "yes"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def seed_sample_data!
|
|
87
|
+
return unless Rails.env.local?
|
|
88
|
+
|
|
89
|
+
SampleData::Ecommerce.run!
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ensure_group!(name, system_group, description = nil)
|
|
93
|
+
Group.find_or_create_by!(system_group: system_group) do |g|
|
|
94
|
+
g.name = name
|
|
95
|
+
g.description = description
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def ensure_user!(email, first_name, last_name, password)
|
|
100
|
+
User.find_or_create_by!(email: email) do |u|
|
|
101
|
+
u.first_name = first_name
|
|
102
|
+
u.last_name = last_name
|
|
103
|
+
u.password = password
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def seed_permissions!(admin_group, all_users_group, engineering, root_collection, data_source)
|
|
108
|
+
CollectionPermission.find_or_create_by!(group: admin_group, collection: root_collection) do |p|
|
|
109
|
+
p.access_level = "curate"
|
|
110
|
+
end
|
|
111
|
+
CollectionPermission.find_or_create_by!(group: all_users_group, collection: root_collection) do |p|
|
|
112
|
+
p.access_level = "view"
|
|
113
|
+
end
|
|
114
|
+
CollectionPermission.find_or_create_by!(group: engineering, collection: root_collection) do |p|
|
|
115
|
+
p.access_level = "view"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
[admin_group, all_users_group, engineering].each do |group|
|
|
119
|
+
level = group.system_group == "administrators" ? "can_view" : (group.system_group == "all_users" ? "can_view" : "blocked")
|
|
120
|
+
create_level = group.system_group == "administrators" ? "query_builder_and_native" : "no"
|
|
121
|
+
|
|
122
|
+
DataPermission.find_or_create_by!(group: group, data_source: data_source, permission_type: "view_data", resource_path: nil) do |p|
|
|
123
|
+
p.access_level = level
|
|
124
|
+
end
|
|
125
|
+
DataPermission.find_or_create_by!(group: group, data_source: data_source, permission_type: "create_queries", resource_path: nil) do |p|
|
|
126
|
+
p.access_level = create_level
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|