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,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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module DataSources
5
+ class SqliteAdapter < PostgresqlAdapter
6
+ end
7
+ end
8
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ VERSION = "0.1.0"
5
+ end